mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Add mock action lifecycle data
This commit is contained in:
parent
95001df0bb
commit
4c073d6b17
24 changed files with 875 additions and 40 deletions
|
|
@ -108,7 +108,7 @@ description includes its required auth scope. For a read-only external
|
|||
agent, start with `monitoring:read`. For the full published Pulse
|
||||
Intelligence surface, mint a token with the current manifest scopes:
|
||||
<!-- pulse-mcp-scope-list:start -->
|
||||
`monitoring:read`, `monitoring:write`, `settings:read`, `settings:write`, and `ai:execute`
|
||||
`monitoring:read`, `monitoring:write`, `settings:read`, `settings:write`, `ai:execute`, `actions:plan`, `actions:approve`, and `actions:execute`
|
||||
<!-- pulse-mcp-scope-list:end -->
|
||||
|
||||
You can also mint narrower tokens for specific workflows. For example,
|
||||
|
|
@ -248,9 +248,9 @@ the live set.
|
|||
|
||||
**Actions (governed plan/approval/execute):**
|
||||
|
||||
- `plan_action` (Plan action, `POST /api/actions/plan`, scope `ai:execute`, mode `write`, approval `action_plan`): Plan an action against a resource. The planner validates the request, looks up the capability on the resource, checks executor-owned live availability, and returns an ActionPlan with the approval policy, blast radius, plan hash, and preflight summary. The plan is persisted to the audit history at the planned/pending state only after the live availability check passes, so subsequent decide_action and execute_action calls can reference it by id. Plan-and-execute is a two-step flow when the resulting plan requires approval, one-step otherwise.
|
||||
- `decide_action` (Decide action, `POST /api/actions/{actionId}/decision`, scope `ai:execute`, mode `write`, approval `action_plan`): Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. Idempotent on the persisted decision: re-deciding a non-pending action surfaces the action_not_pending stable code so agents can branch on the conflict rather than retrying blindly.
|
||||
- `execute_action` (Execute action, `POST /api/actions/{actionId}/execute`, scope `ai:execute`, mode `write`, approval `action_plan`): Execute a previously planned and (when required) approved action. Returns the persisted audit record with the execution result attached. Refuses with stable codes when the action is in the wrong lifecycle state (action_not_approved, action_already_executing, action_execution_final, action_dry_run_only, action_plan_expired), when the approved plan no longer matches the current resource/capability contract (action_plan_drift), when the target is operator-locked against automated remediation (resource_remediation_locked), or when the API instance has no executor wired (action_executor_unavailable). action.completed SSE events fire on every terminal state so agents watching the stream do not need to poll this endpoint after dispatch.
|
||||
- `plan_action` (Plan action, `POST /api/actions/plan`, scope `actions:plan`, mode `write`, approval `action_plan`): Plan an action against a resource. The planner validates the request, looks up the capability on the resource, checks executor-owned live availability, and returns an ActionPlan with the approval policy, blast radius, plan hash, and preflight summary. The plan is persisted to the audit history at the planned/pending state only after the live availability check passes, so subsequent decide_action and execute_action calls can reference it by id. Plan-and-execute is a two-step flow when the resulting plan requires approval, one-step otherwise.
|
||||
- `decide_action` (Decide action, `POST /api/actions/{actionId}/decision`, scope `actions:approve`, mode `write`, approval `action_plan`): Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. An exact retry returns the authoritative persisted decision without adding an approval or lifecycle event; a conflicting retry fails closed.
|
||||
- `execute_action` (Execute action, `POST /api/actions/{actionId}/execute`, scope `actions:execute`, mode `write`, approval `action_plan`): Execute a previously planned and (when required) approved action. Returns the persisted audit record with the execution result attached. Refuses with stable codes when the action is in the wrong lifecycle state (action_not_approved, action_already_executing, action_execution_final, action_dry_run_only, action_plan_expired), when the approved plan no longer matches the current resource/capability contract (action_plan_drift), when the target is operator-locked against automated remediation (resource_remediation_locked), or when the API instance has no executor wired (action_executor_unavailable). action.completed SSE events fire on every terminal state so agents watching the stream do not need to poll this endpoint after dispatch.
|
||||
|
||||
**Provisioning (infrastructure onboarding):**
|
||||
|
||||
|
|
@ -319,9 +319,9 @@ Capability-specific stable codes are advertised by the manifest:
|
|||
- `snooze_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
|
||||
- `dismiss_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
|
||||
- `resolve_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
|
||||
- `plan_action`: `invalid_action_request`, `resource_not_found`, `capability_not_found`, and `action_execution_unavailable`
|
||||
- `decide_action`: `missing_id`, `invalid_id`, `invalid_action_decision`, `action_not_found`, `action_not_pending`, and `action_plan_expired`
|
||||
- `execute_action`: `missing_id`, `invalid_id`, `invalid_action_execution`, `action_not_found`, `action_not_approved`, `action_already_executing`, `action_execution_final`, `action_dry_run_only`, `action_plan_expired`, `action_plan_drift`, `resource_remediation_locked`, and `action_executor_unavailable`
|
||||
- `plan_action`: `invalid_action_request`, `mock_mode_enabled`, `action_actor_unavailable`, `resource_not_found`, `capability_not_found`, and `action_execution_unavailable`
|
||||
- `decide_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_decision`, `action_not_found`, `action_not_pending`, `action_plan_expired`, `action_plan_identity_mismatch`, `action_actor_unavailable`, `action_approval_forbidden`, `action_step_up_unavailable`, `action_decision_conflict`, `action_separation_required`, and `action_replan_required`
|
||||
- `execute_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_execution`, `action_not_found`, `action_not_approved`, `action_already_executing`, `action_execution_final`, `action_dry_run_only`, `action_plan_expired`, `action_plan_drift`, `action_plan_identity_mismatch`, `resource_remediation_locked`, `action_executor_unavailable`, `action_actor_unavailable`, `action_execution_forbidden`, `action_not_executing`, and `action_replan_required`
|
||||
<!-- pulse-mcp-errors:end -->
|
||||
|
||||
Cross-cutting codes from the auth / multi-tenant middleware
|
||||
|
|
|
|||
|
|
@ -409,6 +409,11 @@ broker may authorize eligible actions under core-owned tenant/resource policy,
|
|||
but that policy decision does not grant agent lifecycle code a parallel
|
||||
authorization primitive: all execution still enters through the shared action
|
||||
lifecycle and its agent executor.
|
||||
When runtime mock mode projects representative action audits through those
|
||||
same read routes, the response is explicitly `readOnly` and the rows remain
|
||||
inspection fixtures only. Agent lifecycle surfaces must remove decision and
|
||||
execution affordances, must not treat a mock approval as an agent command
|
||||
grant, and must not infer lifecycle reachability from fixture state.
|
||||
The investigation
|
||||
continuity reconciler in `internal/api/patrol_action_reconciliation.go` is
|
||||
also API-owned: callbacks only wake an authoritative action-audit re-read,
|
||||
|
|
|
|||
|
|
@ -907,6 +907,11 @@ is advertised there. The exported `agentcapabilities.AgentErrCode*` constants
|
|||
are the source of those values for both manifest declarations and
|
||||
`internal/api/` handler emissions; local string literals in either layer are
|
||||
contract drift.
|
||||
The governed action tools also declare `mock_mode_enabled` for read-only mock
|
||||
runtimes and `action_plan_identity_mismatch` when a presented reviewed hash no
|
||||
longer matches the authoritative plan. Pulse MCP documentation must remain a
|
||||
generated projection of those manifest codes so Assistant and external-agent
|
||||
adapters branch on the same refusal vocabulary as direct HTTP clients.
|
||||
`GET /api/actions` and `GET /api/actions/{id}` use that same shared vocabulary
|
||||
for invalid view/limit and internal list/detail failures even though those
|
||||
relay-mobile continuity reads are not separate Assistant tools; their codes
|
||||
|
|
|
|||
|
|
@ -7362,6 +7362,13 @@ both requests to the exact reviewed plan by sending `planHash`; the REST fields
|
|||
remain optional for transport compatibility, but any presented hash that differs
|
||||
from the authoritative persisted plan fails with
|
||||
`action_plan_identity_mismatch` before a decision write or executor dispatch.
|
||||
When runtime mock mode is enabled, these action read routes project the
|
||||
graph-owned mock audit and lifecycle fixtures through the same response shapes;
|
||||
plan, decision, and execution routes fail with `mock_mode_enabled`. Mock reads
|
||||
must not seed the durable audit database or create a second frontend-only action
|
||||
contract. The inbox and detail responses explicitly declare `readOnly: true`
|
||||
for that projection so every browser entrypoint can remove mutation controls
|
||||
without inferring mock state from fixture content.
|
||||
The canonical browser writer in `frontend-modern/src/api/resourceActions.ts`
|
||||
requires a non-empty reviewed hash for both mutations and always serializes it.
|
||||
Actions and Patrol browser consumers must pass the hash from the plan currently
|
||||
|
|
|
|||
|
|
@ -4960,7 +4960,10 @@ reachable, and the scroll-bounded panel keeps safety, execution, verification,
|
|||
recovery, delivery, and next-step content actionable at desktop and 390-pixel
|
||||
phone viewports. Responsive layout must not hide the exact parameter authority,
|
||||
evidence source, or recovery instruction, and it must not add a duplicate legacy
|
||||
verification card when `ActionResultV2` is present.
|
||||
action path or verification card when `ActionResultV2` is present. Read-only
|
||||
sessions keep the review packet inspectable but must not render approve, reject,
|
||||
or run controls, while settled historical records must not be mislabeled as
|
||||
expired actionable reviews.
|
||||
Its action controls are also plan-identity-bound: a missing reviewed `planHash`
|
||||
renders explicit replan guidance and hides approve, reject, and run controls,
|
||||
while an actionable record sends the exact displayed hash on every mutation.
|
||||
|
|
|
|||
|
|
@ -110,7 +110,8 @@ resource health.
|
|||
33. `internal/monitoring/docker_detection.go`
|
||||
34. `internal/monitoring/monitor_polling_containers.go`
|
||||
35. `internal/mock/fixture_graph.go`
|
||||
35a. `internal/mock/availability_fixtures.go`
|
||||
35a. `internal/mock/action_fixtures.go`
|
||||
35b. `internal/mock/availability_fixtures.go`
|
||||
36. `internal/dockeragent/docker_client.go`
|
||||
37. `pkg/agents/docker/report.go`
|
||||
38. `internal/models/models.go`
|
||||
|
|
@ -1444,6 +1445,11 @@ Availability mock fixtures belong to that same graph authority: UPS network
|
|||
cards, MQTT meters, HTTP panels, and controller ping targets must be authored
|
||||
once in `internal/mock/` and then projected into availability status, unified
|
||||
resources, and connections payloads from that shared graph.
|
||||
Governed action mock fixtures follow the same rule. Pending, approved,
|
||||
executing, completed, rejected, and failed action examples are authored once in
|
||||
`internal/mock/action_fixtures.go`, reference resources from the graph's
|
||||
canonical unified-resource snapshot, and are projected by the read-only action
|
||||
API without writing demo rows into the durable action-audit database.
|
||||
That same boundary now also owns native disk-history fallback when Pulse's own
|
||||
history is shallow. `internal/truenas/client.go`,
|
||||
`internal/truenas/provider.go`, `internal/monitoring/truenas_poller.go`, and
|
||||
|
|
|
|||
|
|
@ -4563,6 +4563,7 @@
|
|||
"internal/dockeragent/docker_client.go",
|
||||
"internal/dockeragent/swarm.go",
|
||||
"internal/kubernetesagent/agent.go",
|
||||
"internal/mock/action_fixtures.go",
|
||||
"internal/mock/availability_fixtures.go",
|
||||
"internal/mock/demo_scenarios.go",
|
||||
"internal/mock/fixture_graph.go",
|
||||
|
|
@ -4848,6 +4849,7 @@
|
|||
"label": "monitoring mock runtime fixture proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"internal/mock/action_fixtures.go",
|
||||
"internal/mock/availability_fixtures.go",
|
||||
"internal/mock/demo_scenarios.go",
|
||||
"internal/mock/fixture_graph.go",
|
||||
|
|
@ -4857,6 +4859,7 @@
|
|||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/mock/action_fixtures_test.go",
|
||||
"internal/mock/canonical_api_guardrails_test.go",
|
||||
"internal/mock/demo_scenarios_test.go",
|
||||
"internal/mock/generator_test.go",
|
||||
|
|
|
|||
|
|
@ -3917,6 +3917,11 @@ the shared `internal/api/actions.go` boundary must reject a presented mismatch
|
|||
before audit mutation or executor dispatch. Storage and recovery consumers must
|
||||
preserve that binding and must not treat an approved row or matching action id
|
||||
alone as authority for a recovery or storage mutation.
|
||||
Mock-mode action inbox and detail rows are explicitly read-only projections
|
||||
from the shared fixture graph. Storage and recovery consumers may inspect their
|
||||
typed execution, verification, and compensation examples, but must not persist
|
||||
them as recovery evidence, derive restore readiness from them, or expose any
|
||||
mutation control against those fixtures.
|
||||
That same shared `internal/api/` boundary also owns hosted browser-session
|
||||
precedence for adjacent protected reads. Storage- and recovery-adjacent hosted
|
||||
surfaces may run without local auth configured, but a valid tenant
|
||||
|
|
|
|||
|
|
@ -2129,6 +2129,11 @@ one graph instead of combining a legacy snapshot read with standalone provider
|
|||
defaults. The shared resource graph must therefore see one coherent mock
|
||||
platform set regardless of whether a platform is snapshot-backed or
|
||||
supplemental-provider-backed.
|
||||
The graph also owns governed action fixtures in
|
||||
`internal/mock/action_fixtures.go`. Those records must reference canonical
|
||||
resource IDs from the graph snapshot, carry current policy provenance and typed
|
||||
result truth, and reach the Actions workspace through the backend mock read
|
||||
projection rather than a page-local fallback or durable-store contamination.
|
||||
Agentless availability fixtures join that same graph-owned seed contract:
|
||||
mock UPS, MQTT, ESPHome, HTTP, and controller endpoints must project as
|
||||
`SourceAvailability` `network-endpoint` resources with real availability
|
||||
|
|
|
|||
|
|
@ -221,17 +221,20 @@ describe('ActionAuditAPI', () => {
|
|||
};
|
||||
|
||||
apiFetchJSONMock
|
||||
.mockResolvedValueOnce({ view: 'pending', actions: [], count: 0 })
|
||||
.mockResolvedValueOnce({ view: 'settled', actions: [audit], count: 1 })
|
||||
.mockResolvedValueOnce({ audit, events: [] });
|
||||
.mockResolvedValueOnce({ view: 'pending', actions: [], count: 0, readOnly: true })
|
||||
.mockResolvedValueOnce({ view: 'settled', actions: [audit], count: 1, readOnly: true })
|
||||
.mockResolvedValueOnce({ audit, events: [], readOnly: true });
|
||||
|
||||
await expect(ResourceActionsAPI.listActions('pending', 25)).resolves.toMatchObject({
|
||||
await expect(ResourceActionsAPI.listActions('pending', 25)).resolves.toEqual({
|
||||
view: 'pending',
|
||||
actions: [],
|
||||
count: 0,
|
||||
readOnly: true,
|
||||
});
|
||||
await expect(ResourceActionsAPI.listActions('settled', 25)).resolves.toMatchObject({
|
||||
view: 'settled',
|
||||
count: 1,
|
||||
readOnly: true,
|
||||
});
|
||||
const detail = await ResourceActionsAPI.getAction('action/one');
|
||||
|
||||
|
|
@ -239,6 +242,7 @@ describe('ActionAuditAPI', () => {
|
|||
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.readOnly).toBe(true);
|
||||
expect(detail.audit.result?.actionResultV2).toMatchObject({
|
||||
execution: { status: 'succeeded' },
|
||||
verification: { status: 'contradicted', evidenceClass: 'independent' },
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ResourceActionsAPI } from '@/api/resourceActions';
|
|||
import { Button } from '@/components/shared/Button';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
|
||||
import type { ActionDetailResponse } from '@/types/actionAudit';
|
||||
import { ActionDecisionPacket } from './ActionDecisionPacket';
|
||||
import { formatActionName } from './actionPresentation';
|
||||
|
|
@ -18,6 +19,9 @@ export const ActionReviewDialog: Component<{
|
|||
const [error, setError] = createSignal('');
|
||||
const [clock, setClock] = createSignal(Date.now());
|
||||
const audit = () => props.detail?.audit;
|
||||
const readOnly = createMemo(
|
||||
() => props.detail?.readOnly === true || presentationPolicyIsReadOnly(),
|
||||
);
|
||||
createEffect(() => {
|
||||
if (!props.detail) return;
|
||||
setClock(Date.now());
|
||||
|
|
@ -39,12 +43,14 @@ export const ActionReviewDialog: Component<{
|
|||
return Number.isNaN(timestamp) || timestamp <= clock();
|
||||
});
|
||||
const canDecide = () =>
|
||||
!readOnly() &&
|
||||
reviewedPlanHash() &&
|
||||
hasCurrentPolicyProvenance() &&
|
||||
aptParametersValid() &&
|
||||
!isExpired() &&
|
||||
audit()?.state === 'pending_approval';
|
||||
const canExecute = () =>
|
||||
!readOnly() &&
|
||||
reviewedPlanHash() &&
|
||||
hasCurrentPolicyProvenance() &&
|
||||
aptParametersValid() &&
|
||||
|
|
@ -52,6 +58,11 @@ export const ActionReviewDialog: Component<{
|
|||
(audit()?.state === 'approved' ||
|
||||
(audit()?.state === 'planned' && !audit()?.plan.requiresApproval));
|
||||
const invalidActionMessage = createMemo(() => {
|
||||
const state = audit()?.state;
|
||||
const actionable = state === 'pending_approval' || state === 'approved' || state === 'planned';
|
||||
if (!actionable) return '';
|
||||
if (readOnly())
|
||||
return 'This session is read-only. You can inspect the action and its policy evidence, but you cannot approve or run it.';
|
||||
if (!reviewedPlanHash())
|
||||
return 'This action has no reviewed plan identity. Close it and create a new plan before approving or running anything.';
|
||||
if (!hasCurrentPolicyProvenance())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import { syncSessionPresentationPolicy } from '@/stores/sessionPresentationPolicy';
|
||||
import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit';
|
||||
import { ActionReviewDialog } from '../ActionReviewDialog';
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ vi.mock('@/stores/notifications', () => ({
|
|||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
syncSessionPresentationPolicy(null);
|
||||
});
|
||||
|
||||
const makeAudit = (
|
||||
|
|
@ -142,6 +144,44 @@ describe('ActionReviewDialog trust gates', () => {
|
|||
expect(screen.queryByRole('button', { name: 'Run action' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps mock and other read-only sessions inspectable without mutation controls', () => {
|
||||
syncSessionPresentationPolicy({
|
||||
presentationPolicy: {
|
||||
demoMode: true,
|
||||
readOnly: true,
|
||||
hideCommercial: true,
|
||||
hideUpgrade: true,
|
||||
},
|
||||
});
|
||||
render(() => (
|
||||
<ActionReviewDialog
|
||||
detail={detail(makeAudit('resolved', '2099-01-01T00:00:00Z'))}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByTestId('action-review-invalid')).toHaveTextContent('session is read-only');
|
||||
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Run action' })).toBeNull();
|
||||
});
|
||||
|
||||
it('honors the action API read-only projection when the broader session remains writable', () => {
|
||||
const readOnlyDetail = {
|
||||
...detail(makeAudit('resolved', '2099-01-01T00:00:00Z')),
|
||||
readOnly: true,
|
||||
};
|
||||
render(() => <ActionReviewDialog detail={readOnlyDetail} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('action-review-invalid')).toHaveTextContent('session is read-only');
|
||||
expect(screen.queryByRole('button', { name: 'Reject' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull();
|
||||
});
|
||||
|
||||
it('does not describe a settled historical action as an expired review', () => {
|
||||
const audit = makeAudit('resolved', '2026-07-12T00:10:00Z');
|
||||
audit.state = 'completed';
|
||||
render(() => <ActionReviewDialog detail={detail(audit)} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('action-review-invalid')).toBeNull();
|
||||
});
|
||||
|
||||
it('binds an approval to the plan shown in the dialog', async () => {
|
||||
const audit = makeAudit('resolved', '2099-01-01T00:00:00Z');
|
||||
vi.mocked(ResourceActionsAPI.decideAction).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function Actions() {
|
|||
const [actions, setActions] = createSignal<ActionAuditRecord[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadError, setLoadError] = createSignal('');
|
||||
const [readOnly, setReadOnly] = createSignal(false);
|
||||
|
||||
const loadActions = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -22,8 +23,10 @@ export function Actions() {
|
|||
try {
|
||||
const response = await ResourceActionsAPI.listActions(view());
|
||||
setActions(response.actions);
|
||||
setReadOnly(response.readOnly === true);
|
||||
} catch (cause) {
|
||||
setActions([]);
|
||||
setReadOnly(false);
|
||||
setLoadError(cause instanceof Error ? cause.message : 'The action store is unavailable.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -37,32 +40,140 @@ export function Actions() {
|
|||
|
||||
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.'); }
|
||||
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." />
|
||||
<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
|
||||
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>
|
||||
<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={readOnly()}>
|
||||
<div class="rounded-lg border border-blue-300 bg-blue-50 p-3 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-100">
|
||||
Mock action history is read-only. Open any record to inspect its plan, policy evidence,
|
||||
lifecycle, and outcome.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
<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
|
||||
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(); }} />
|
||||
<ActionReviewDialog
|
||||
detail={selected()}
|
||||
onClose={() => setSelected(null)}
|
||||
onChanged={async (detail) => {
|
||||
setSelected(detail);
|
||||
await loadActions();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,9 +40,7 @@ export interface ActionApprovalRequirement {
|
|||
|
||||
export type ActionPolicyDecisionStatus = 'resolved' | 'legacy_unknown';
|
||||
export type ActionPolicyAuthorityKind =
|
||||
| 'capability_registry'
|
||||
| 'tenant_patrol_policy'
|
||||
| 'resource_operator_policy';
|
||||
'capability_registry' | 'tenant_patrol_policy' | 'resource_operator_policy';
|
||||
export type ActionPolicyAuthorityStatus = 'consulted' | 'unavailable' | 'not_found';
|
||||
export type ActionPolicyReasonCode =
|
||||
| 'capability_approval_none'
|
||||
|
|
@ -145,10 +143,7 @@ export interface ActionVerificationResult {
|
|||
|
||||
export type ActionExecutionStatus = 'not_run' | 'succeeded' | 'failed' | 'inconclusive';
|
||||
export type ActionVerificationTruthStatus =
|
||||
| 'not_attempted'
|
||||
| 'confirmed'
|
||||
| 'contradicted'
|
||||
| 'inconclusive';
|
||||
'not_attempted' | 'confirmed' | 'contradicted' | 'inconclusive';
|
||||
export type ActionEvidenceClass = 'none' | 'agent_attested' | 'independent';
|
||||
|
||||
export interface ActionEvidenceRef {
|
||||
|
|
@ -289,6 +284,7 @@ export interface PatrolActionReference {
|
|||
export interface PendingActionsResponse {
|
||||
actions: ActionAuditRecord[];
|
||||
count: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export type ActionInboxView = 'pending' | 'settled';
|
||||
|
|
@ -338,6 +334,7 @@ export interface ActionDetailResponse {
|
|||
events: ActionLifecycleEvent[];
|
||||
attempt?: ActionDispatchAttempt;
|
||||
receipt?: ActionDispatchReceipt;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionDecisionResponse {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ const (
|
|||
AgentErrCodeActionNotExecuting = "action_not_executing"
|
||||
AgentErrCodeActionDryRunOnly = "action_dry_run_only"
|
||||
AgentErrCodeActionPlanDrift = "action_plan_drift"
|
||||
AgentErrCodeActionPlanIdentityMismatch = "action_plan_identity_mismatch"
|
||||
AgentErrCodeResourceRemediationLocked = "resource_remediation_locked"
|
||||
AgentErrCodeMockModeEnabled = "mock_mode_enabled"
|
||||
AgentErrCodeActionExecutorUnavailable = "action_executor_unavailable"
|
||||
AgentErrCodeActionQueueUnavailable = "action_queue_unavailable"
|
||||
AgentErrCodeActionQueueQueryFailed = "action_queue_query_failed"
|
||||
|
|
|
|||
|
|
@ -607,18 +607,21 @@ var (
|
|||
}
|
||||
agentCapabilityPlanActionErrorCodes = []string{
|
||||
AgentErrCodeInvalidActionRequest,
|
||||
AgentErrCodeMockModeEnabled,
|
||||
AgentErrCodeActionActorUnavailable,
|
||||
AgentErrCodeResourceNotFound,
|
||||
AgentErrCodeCapabilityNotFound,
|
||||
AgentErrCodeActionExecutionUnavailable,
|
||||
}
|
||||
agentCapabilityDecisionActionErrorCodes = []string{
|
||||
AgentErrCodeMockModeEnabled,
|
||||
AgentErrCodeMissingID,
|
||||
AgentErrCodeInvalidID,
|
||||
AgentErrCodeInvalidActionDecision,
|
||||
AgentErrCodeActionNotFound,
|
||||
AgentErrCodeActionNotPending,
|
||||
AgentErrCodeActionPlanExpired,
|
||||
AgentErrCodeActionPlanIdentityMismatch,
|
||||
AgentErrCodeActionActorUnavailable,
|
||||
AgentErrCodeActionApprovalForbidden,
|
||||
AgentErrCodeActionStepUpUnavailable,
|
||||
|
|
@ -627,6 +630,7 @@ var (
|
|||
AgentErrCodeActionReplanRequired,
|
||||
}
|
||||
agentCapabilityExecuteActionErrorCodes = []string{
|
||||
AgentErrCodeMockModeEnabled,
|
||||
AgentErrCodeMissingID,
|
||||
AgentErrCodeInvalidID,
|
||||
AgentErrCodeInvalidActionExecution,
|
||||
|
|
@ -637,6 +641,7 @@ var (
|
|||
AgentErrCodeActionDryRunOnly,
|
||||
AgentErrCodeActionPlanExpired,
|
||||
AgentErrCodeActionPlanDrift,
|
||||
AgentErrCodeActionPlanIdentityMismatch,
|
||||
AgentErrCodeResourceRemediationLocked,
|
||||
AgentErrCodeActionExecutorUnavailable,
|
||||
AgentErrCodeActionActorUnavailable,
|
||||
|
|
|
|||
|
|
@ -461,6 +461,7 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test
|
|||
approval: ApprovalPolicyActionPlan,
|
||||
errorCodes: []string{
|
||||
AgentErrCodeInvalidActionRequest,
|
||||
AgentErrCodeMockModeEnabled,
|
||||
AgentErrCodeActionActorUnavailable,
|
||||
AgentErrCodeResourceNotFound,
|
||||
AgentErrCodeCapabilityNotFound,
|
||||
|
|
@ -476,12 +477,14 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test
|
|||
mode: ActionModeWrite,
|
||||
approval: ApprovalPolicyActionPlan,
|
||||
errorCodes: []string{
|
||||
AgentErrCodeMockModeEnabled,
|
||||
AgentErrCodeMissingID,
|
||||
AgentErrCodeInvalidID,
|
||||
AgentErrCodeInvalidActionDecision,
|
||||
AgentErrCodeActionNotFound,
|
||||
AgentErrCodeActionNotPending,
|
||||
AgentErrCodeActionPlanExpired,
|
||||
AgentErrCodeActionPlanIdentityMismatch,
|
||||
AgentErrCodeActionActorUnavailable,
|
||||
AgentErrCodeActionApprovalForbidden,
|
||||
AgentErrCodeActionStepUpUnavailable,
|
||||
|
|
@ -499,6 +502,7 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test
|
|||
mode: ActionModeWrite,
|
||||
approval: ApprovalPolicyActionPlan,
|
||||
errorCodes: []string{
|
||||
AgentErrCodeMockModeEnabled,
|
||||
AgentErrCodeMissingID,
|
||||
AgentErrCodeInvalidID,
|
||||
AgentErrCodeInvalidActionExecution,
|
||||
|
|
@ -509,6 +513,7 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test
|
|||
AgentErrCodeActionDryRunOnly,
|
||||
AgentErrCodeActionPlanExpired,
|
||||
AgentErrCodeActionPlanDrift,
|
||||
AgentErrCodeActionPlanIdentityMismatch,
|
||||
AgentErrCodeResourceRemediationLocked,
|
||||
AgentErrCodeActionExecutorUnavailable,
|
||||
AgentErrCodeActionActorUnavailable,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
|
|
@ -69,14 +70,21 @@ type actionExecutionResponse struct {
|
|||
}
|
||||
|
||||
type pendingActionsResponse struct {
|
||||
Actions []unified.ActionAuditRecord `json:"actions"`
|
||||
Count int `json:"count"`
|
||||
Actions []unified.ActionAuditRecord `json:"actions"`
|
||||
Count int `json:"count"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
}
|
||||
|
||||
type actionInboxResponse struct {
|
||||
View actionlifecycle.ActionListView `json:"view"`
|
||||
Actions []unified.ActionAuditRecord `json:"actions"`
|
||||
Count int `json:"count"`
|
||||
View actionlifecycle.ActionListView `json:"view"`
|
||||
Actions []unified.ActionAuditRecord `json:"actions"`
|
||||
Count int `json:"count"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
}
|
||||
|
||||
type actionDetailResponse struct {
|
||||
actionlifecycle.ActionDetail
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
}
|
||||
|
||||
// ActionLifecycle returns the shared transport-independent action lifecycle
|
||||
|
|
@ -105,6 +113,10 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque
|
|||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeMockModeEnabled, "Cannot plan actions in mock mode")
|
||||
return
|
||||
}
|
||||
|
||||
var publicReq publicActionPlanRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionPlanRequestBytes))
|
||||
|
|
@ -155,6 +167,14 @@ func (h *ResourceHandlers) HandleListPendingActions(w http.ResponseWriter, r *ht
|
|||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
actions := mockActionAuditsByState(maxPendingActionAudits, unified.ActionStatePending)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(pendingActionsResponse{Actions: actions, Count: len(actions), ReadOnly: true}); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueEncodeFailed, "Failed to encode pending actions")
|
||||
}
|
||||
return
|
||||
}
|
||||
actions, err := h.ActionLifecycle().DecisionQueue(GetOrgID(r.Context()), maxPendingActionAudits)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueQueryFailed, "Failed to query pending actions")
|
||||
|
|
@ -184,6 +204,21 @@ func (h *ResourceHandlers) HandleListActions(w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
limit = parsed
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
states := []unified.ActionState{unified.ActionStatePlanned, unified.ActionStatePending, unified.ActionStateApproved, unified.ActionStateExecuting}
|
||||
if view == actionlifecycle.ActionListSettled {
|
||||
states = []unified.ActionState{unified.ActionStateRejected, unified.ActionStateExpired, unified.ActionStateCompleted, unified.ActionStateFailed}
|
||||
} else if view != actionlifecycle.ActionListPending {
|
||||
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionListView, "Action list view must be pending or settled")
|
||||
return
|
||||
}
|
||||
actions := mockActionAuditsByState(limit, states...)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(actionInboxResponse{View: view, Actions: actions, Count: len(actions), ReadOnly: true}); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionListEncodeFailed, "Failed to encode actions")
|
||||
}
|
||||
return
|
||||
}
|
||||
actions, err := h.ActionLifecycle().List(GetOrgID(r.Context()), view, limit)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "unsupported action list view") {
|
||||
|
|
@ -211,6 +246,23 @@ func (h *ResourceHandlers) HandleGetAction(w http.ResponseWriter, r *http.Reques
|
|||
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidID, "Invalid action ID format")
|
||||
return
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
for _, fixture := range mock.ActionFixtures() {
|
||||
if fixture.Audit.ID != actionID {
|
||||
continue
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(actionDetailResponse{
|
||||
ActionDetail: actionlifecycle.ActionDetail{Audit: fixture.Audit, Events: fixture.Events},
|
||||
ReadOnly: true,
|
||||
}); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode action detail")
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{"actionId": actionID})
|
||||
return
|
||||
}
|
||||
detail, found, err := h.ActionLifecycle().Detail(GetOrgID(r.Context()), actionID)
|
||||
if err != nil {
|
||||
writeActionLifecycleReadError(w, err, func() {
|
||||
|
|
@ -275,6 +327,10 @@ func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Req
|
|||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeMockModeEnabled, "Cannot decide actions in mock mode")
|
||||
return
|
||||
}
|
||||
|
||||
actionID := strings.TrimSpace(r.PathValue("id"))
|
||||
if actionID == "" {
|
||||
|
|
@ -330,7 +386,7 @@ func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Req
|
|||
return
|
||||
}
|
||||
if decision.PlanHash != "" && decision.PlanHash != record.Plan.PlanHash {
|
||||
writeJSONError(w, http.StatusConflict, "action_plan_identity_mismatch", "The reviewed action plan changed; reload it before deciding")
|
||||
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanIdentityMismatch, "The reviewed action plan changed; reload it before deciding")
|
||||
return
|
||||
}
|
||||
canonicalDecision := unified.ActionDecision{
|
||||
|
|
@ -372,6 +428,10 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re
|
|||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeMockModeEnabled, "Cannot execute actions in mock mode")
|
||||
return
|
||||
}
|
||||
|
||||
actionID := strings.TrimSpace(r.PathValue("id"))
|
||||
if actionID == "" {
|
||||
|
|
@ -421,7 +481,7 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re
|
|||
return
|
||||
}
|
||||
if execution.PlanHash != record.Plan.PlanHash {
|
||||
writeJSONError(w, http.StatusConflict, "action_plan_identity_mismatch", "The approved action plan changed; reload it before execution")
|
||||
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanIdentityMismatch, "The approved action plan changed; reload it before execution")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -444,6 +504,24 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re
|
|||
}
|
||||
}
|
||||
|
||||
func mockActionAuditsByState(limit int, states ...unified.ActionState) []unified.ActionAuditRecord {
|
||||
allowed := make(map[unified.ActionState]struct{}, len(states))
|
||||
for _, state := range states {
|
||||
allowed[state] = struct{}{}
|
||||
}
|
||||
actions := make([]unified.ActionAuditRecord, 0, len(states))
|
||||
for _, fixture := range mock.ActionFixtures() {
|
||||
if _, ok := allowed[fixture.Audit.State]; !ok {
|
||||
continue
|
||||
}
|
||||
actions = append(actions, fixture.Audit)
|
||||
if len(actions) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// writeActionLifecycleReadError handles the store/query/not-found failures
|
||||
// shared by the decision and execution endpoints, delegating anything else
|
||||
// to the endpoint-specific fallback.
|
||||
|
|
|
|||
134
internal/api/actions_mock_test.go
Normal file
134
internal/api/actions_mock_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
)
|
||||
|
||||
func TestMockActionInboxAndDetailUseCanonicalResponses(t *testing.T) {
|
||||
previous := mock.IsMockEnabled()
|
||||
if err := mock.SetEnabled(true); err != nil {
|
||||
t.Fatalf("enable mock mode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = mock.SetEnabled(previous) })
|
||||
handler := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
|
||||
for _, test := range []struct {
|
||||
view string
|
||||
wantCount int
|
||||
}{
|
||||
{view: "pending", wantCount: 3},
|
||||
{view: "settled", wantCount: 3},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.HandleListActions(recorder, httptest.NewRequest(http.MethodGet, "/api/actions?view="+test.view, nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("list %s status = %d, body=%s", test.view, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response actionInboxResponse
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode %s response: %v", test.view, err)
|
||||
}
|
||||
if response.Count != test.wantCount || len(response.Actions) != test.wantCount {
|
||||
t.Fatalf("list %s count = %d actions=%d, want %d", test.view, response.Count, len(response.Actions), test.wantCount)
|
||||
}
|
||||
if !response.ReadOnly {
|
||||
t.Fatalf("list %s must declare the mock projection read-only", test.view)
|
||||
}
|
||||
for _, action := range response.Actions {
|
||||
if action.Plan.PolicyDecision.DecisionID == "" || action.Plan.PlanHash == "" {
|
||||
t.Fatalf("list %s returned incomplete review identity: %#v", test.view, action.Plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fixtures := mock.ActionFixtures()
|
||||
detailRecorder := httptest.NewRecorder()
|
||||
detailRequest := httptest.NewRequest(http.MethodGet, "/api/actions/"+fixtures[0].Audit.ID, nil)
|
||||
detailRequest.SetPathValue("id", fixtures[0].Audit.ID)
|
||||
handler.HandleGetAction(detailRecorder, detailRequest)
|
||||
if detailRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d, body=%s", detailRecorder.Code, detailRecorder.Body.String())
|
||||
}
|
||||
var detail actionDetailResponse
|
||||
if err := json.NewDecoder(detailRecorder.Body).Decode(&detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Audit.ID != fixtures[0].Audit.ID || len(detail.Events) == 0 {
|
||||
t.Fatalf("detail = %#v", detail)
|
||||
}
|
||||
if !detail.ReadOnly {
|
||||
t.Fatal("mock detail must declare itself read-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockActionInboxHonorsLimitAndLegacyPendingProjection(t *testing.T) {
|
||||
previous := mock.IsMockEnabled()
|
||||
if err := mock.SetEnabled(true); err != nil {
|
||||
t.Fatalf("enable mock mode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = mock.SetEnabled(previous) })
|
||||
handler := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
|
||||
limited := httptest.NewRecorder()
|
||||
handler.HandleListActions(limited, httptest.NewRequest(http.MethodGet, "/api/actions?view=pending&limit=1", nil))
|
||||
var limitedResponse actionInboxResponse
|
||||
if err := json.NewDecoder(limited.Body).Decode(&limitedResponse); err != nil {
|
||||
t.Fatalf("decode limited response: %v", err)
|
||||
}
|
||||
if limitedResponse.Count != 1 || len(limitedResponse.Actions) != 1 {
|
||||
t.Fatalf("limited response count=%d actions=%d", limitedResponse.Count, len(limitedResponse.Actions))
|
||||
}
|
||||
if !limitedResponse.ReadOnly {
|
||||
t.Fatal("limited mock response must declare itself read-only")
|
||||
}
|
||||
|
||||
pending := httptest.NewRecorder()
|
||||
handler.HandleListPendingActions(pending, httptest.NewRequest(http.MethodGet, "/api/actions/pending", nil))
|
||||
var pendingResponse pendingActionsResponse
|
||||
if err := json.NewDecoder(pending.Body).Decode(&pendingResponse); err != nil {
|
||||
t.Fatalf("decode pending response: %v", err)
|
||||
}
|
||||
if pendingResponse.Count != 1 || pendingResponse.Actions[0].State != "pending_approval" {
|
||||
t.Fatalf("legacy pending response = %#v", pendingResponse)
|
||||
}
|
||||
if !pendingResponse.ReadOnly {
|
||||
t.Fatal("legacy pending response must declare itself read-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockActionMutationsFailReadOnly(t *testing.T) {
|
||||
previous := mock.IsMockEnabled()
|
||||
if err := mock.SetEnabled(true); err != nil {
|
||||
t.Fatalf("enable mock mode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = mock.SetEnabled(previous) })
|
||||
handler := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
run func(http.ResponseWriter, *http.Request)
|
||||
}{
|
||||
{name: "plan", path: "/api/actions/plan", body: `{}`, run: handler.HandlePlanAction},
|
||||
{name: "decide", path: "/api/actions/demo-action-install-updates/decision", body: `{"outcome":"approved"}`, run: handler.HandleDecideAction},
|
||||
{name: "execute", path: "/api/actions/demo-action-restart-checkout/execute", body: `{}`, run: handler.HandleExecuteAction},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewBufferString(test.body))
|
||||
test.run(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden || !bytes.Contains(recorder.Body.Bytes(), []byte("mock_mode_enabled")) {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -19889,7 +19889,9 @@ func TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations(t *testing.T)
|
|||
"AgentErrCodeActionNotExecuting": agentcapabilities.AgentErrCodeActionNotExecuting,
|
||||
"AgentErrCodeActionDryRunOnly": agentcapabilities.AgentErrCodeActionDryRunOnly,
|
||||
"AgentErrCodeActionPlanDrift": agentcapabilities.AgentErrCodeActionPlanDrift,
|
||||
"AgentErrCodeActionPlanIdentityMismatch": agentcapabilities.AgentErrCodeActionPlanIdentityMismatch,
|
||||
"AgentErrCodeResourceRemediationLocked": agentcapabilities.AgentErrCodeResourceRemediationLocked,
|
||||
"AgentErrCodeMockModeEnabled": agentcapabilities.AgentErrCodeMockModeEnabled,
|
||||
"AgentErrCodeActionExecutorUnavailable": agentcapabilities.AgentErrCodeActionExecutorUnavailable,
|
||||
"AgentErrCodeActionQueueUnavailable": agentcapabilities.AgentErrCodeActionQueueUnavailable,
|
||||
"AgentErrCodeActionQueueQueryFailed": agentcapabilities.AgentErrCodeActionQueueQueryFailed,
|
||||
|
|
|
|||
318
internal/mock/action_fixtures.go
Normal file
318
internal/mock/action_fixtures.go
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
package mock
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// ActionFixture is one graph-owned action audit and its append-only lifecycle.
|
||||
// Mock API handlers project this shape through the same public response types as
|
||||
// the durable store without writing demo records into a user's audit database.
|
||||
type ActionFixture struct {
|
||||
Audit unifiedresources.ActionAuditRecord
|
||||
Events []unifiedresources.ActionLifecycleEvent
|
||||
}
|
||||
|
||||
// ActionFixtures returns defensive copies of the canonical mock action set.
|
||||
func ActionFixtures() []ActionFixture {
|
||||
if !IsMockEnabled() {
|
||||
return nil
|
||||
}
|
||||
return CurrentFixtureGraph().ActionFixtures
|
||||
}
|
||||
|
||||
func buildActionFixtures(resources []unifiedresources.Resource, now time.Time) []ActionFixture {
|
||||
now = now.UTC()
|
||||
hostID := mockActionResourceID(resources, "agent", "system", "physical-host")
|
||||
vmID := mockActionResourceID(resources, "vm")
|
||||
containerID := mockActionResourceID(resources, "app-container")
|
||||
|
||||
pending := newMockActionFixture(mockActionFixtureSpec{
|
||||
ID: "demo-action-install-updates",
|
||||
ResourceID: hostID,
|
||||
CapabilityName: "install_os_updates",
|
||||
Reason: "Patrol found security updates on a production host.",
|
||||
RequestedBy: "pulse_patrol",
|
||||
PlannedAt: now.Add(-12 * time.Minute),
|
||||
ExpiresAt: now.Add(108 * time.Minute),
|
||||
ApprovalFloor: unifiedresources.ApprovalAdmin,
|
||||
AutoClass: unifiedresources.PolicyReasonCapabilityAutoElevated,
|
||||
CurrentState: "8 security updates are available; package manager health is healthy.",
|
||||
IntendedChange: "Install the complete approved update set. A reboot, if needed, remains a separate action.",
|
||||
SafetyChecks: []string{"Package manager health is known", "No package or reboot parameters were supplied"},
|
||||
Verification: []string{"Refresh the update inventory", "Record remaining updates and reboot requirement"},
|
||||
})
|
||||
|
||||
approved := approveMockAction(newMockActionFixture(mockActionFixtureSpec{
|
||||
ID: "demo-action-restart-checkout",
|
||||
ResourceID: vmID,
|
||||
CapabilityName: "restart",
|
||||
Reason: "Recover checkout after three failed health checks.",
|
||||
RequestedBy: "pulse_patrol",
|
||||
PlannedAt: now.Add(-20 * time.Minute),
|
||||
ExpiresAt: now.Add(40 * time.Minute),
|
||||
ApprovalFloor: unifiedresources.ApprovalAdmin,
|
||||
AutoClass: unifiedresources.PolicyReasonCapabilityAutoNever,
|
||||
CurrentState: "The workload is running but its service health check is failing.",
|
||||
IntendedChange: "Restart the workload once and verify that service health recovers.",
|
||||
SafetyChecks: []string{"Target identity is current", "Restart is bounded to one workload"},
|
||||
Verification: []string{"Observe workload uptime reset", "Confirm the service health check"},
|
||||
}), "alex@example.com", unifiedresources.OutcomeApproved, now.Add(-8*time.Minute))
|
||||
|
||||
executing := approveMockAction(newMockActionFixture(mockActionFixtureSpec{
|
||||
ID: "demo-action-restart-worker",
|
||||
ResourceID: containerID,
|
||||
CapabilityName: "restart_container",
|
||||
Reason: "Restart the stuck notifications worker after queue processing stopped.",
|
||||
RequestedBy: "pulse_patrol",
|
||||
PlannedAt: now.Add(-9 * time.Minute),
|
||||
ExpiresAt: now.Add(51 * time.Minute),
|
||||
ApprovalFloor: unifiedresources.ApprovalAdmin,
|
||||
AutoClass: unifiedresources.PolicyReasonCapabilityAutoNever,
|
||||
CurrentState: "The container is running, but queue throughput has been zero for 15 minutes.",
|
||||
IntendedChange: "Restart the container and wait for a durable agent receipt.",
|
||||
SafetyChecks: []string{"Container identity is current", "No image or configuration change is included"},
|
||||
Verification: []string{"Confirm a new container start time", "Observe queue throughput"},
|
||||
}), "sam@example.com", unifiedresources.OutcomeApproved, now.Add(-5*time.Minute))
|
||||
executing.Audit.State = unifiedresources.ActionStateExecuting
|
||||
executing.Audit.UpdatedAt = now.Add(-3 * time.Minute)
|
||||
executing.Events = append(executing.Events, mockActionTransition(executing.Audit, unifiedresources.ActionStateExecuting, executing.Audit.UpdatedAt, "Pulse recorded dispatch before sending the action."))
|
||||
executing = mustNormalizeMockActionFixture(executing)
|
||||
|
||||
completed := newMockActionFixture(mockActionFixtureSpec{
|
||||
ID: "demo-action-clean-cache",
|
||||
ResourceID: hostID,
|
||||
CapabilityName: "clean_package_cache",
|
||||
Reason: "Reclaim downloaded package data after the root filesystem crossed its warning threshold.",
|
||||
RequestedBy: "pulse_patrol",
|
||||
PlannedAt: now.Add(-2 * time.Hour),
|
||||
ExpiresAt: now.Add(-90 * time.Minute),
|
||||
ApprovalFloor: unifiedresources.ApprovalNone,
|
||||
AutoClass: unifiedresources.PolicyReasonCapabilityAutoLowRisk,
|
||||
CurrentState: "Downloaded package data is using 100 MB on the pressured filesystem.",
|
||||
IntendedChange: "Clear downloaded package data only; installed packages and configuration remain unchanged.",
|
||||
SafetyChecks: []string{"The pressured filesystem is the package-cache filesystem", "The action accepts no path"},
|
||||
Verification: []string{"Measure package-cache bytes again", "Request a fresh storage scan"},
|
||||
})
|
||||
completedAt := now.Add(-112 * time.Minute)
|
||||
completed.Audit.State = unifiedresources.ActionStateCompleted
|
||||
completed.Audit.UpdatedAt = completedAt
|
||||
completed.Audit.Result = &unifiedresources.ExecutionResult{ActionResultV2: &unifiedresources.ActionResultV2{
|
||||
Version: unifiedresources.ActionResultV2Version,
|
||||
Execution: unifiedresources.ActionExecutionTruth{Status: unifiedresources.ActionExecutionSucceeded, Summary: "APT package cache: phase=complete; 104857600 bytes before, 25165824 bytes after, 79691776 bytes reclaimed; rollback available: false; rescan required: true"},
|
||||
Verification: unifiedresources.ActionVerificationTruth{
|
||||
Status: unifiedresources.ActionVerificationConfirmed,
|
||||
EvidenceClass: unifiedresources.ActionEvidenceAgentAttested,
|
||||
Summary: "The executing agent measured the cache after cleanup.",
|
||||
Evidence: []unifiedresources.ActionEvidence{{
|
||||
Version: unifiedresources.ActionEvidenceVersion, ID: "demo-evidence-clean-cache", ObserverID: hostID,
|
||||
ObserverKind: "agent", ObserverTrustDomain: "agent:" + hostID, ExecutorTrustDomain: "agent:" + hostID,
|
||||
Method: "typed_read_after_write", SubjectID: hostID, ObservedAt: completedAt.Add(-10 * time.Second), ReceivedAt: completedAt,
|
||||
Summary: "Package-cache usage fell from 100 MB to 24 MB.",
|
||||
}},
|
||||
},
|
||||
Compensation: unifiedresources.ActionCompensationTruth{Support: unifiedresources.ActionCompensationUnavailable, Status: unifiedresources.ActionCompensationNotAvailable, Summary: "Downloaded package data cannot be restored; a fresh scan is required."},
|
||||
}}
|
||||
completed.Events = append(completed.Events,
|
||||
mockActionTransition(completed.Audit, unifiedresources.ActionStateExecuting, completedAt.Add(-35*time.Second), "Policy-authorized action execution started."),
|
||||
mockActionTransition(completed.Audit, unifiedresources.ActionStateCompleted, completedAt, "Action execution and verification were recorded."),
|
||||
)
|
||||
completed = mustNormalizeMockActionFixture(completed)
|
||||
|
||||
rejected := approveMockAction(newMockActionFixture(mockActionFixtureSpec{
|
||||
ID: "demo-action-rejected-restart",
|
||||
ResourceID: vmID,
|
||||
CapabilityName: "restart",
|
||||
Reason: "Restart a reporting service after elevated response times.",
|
||||
RequestedBy: "pulse_patrol",
|
||||
PlannedAt: now.Add(-5 * time.Hour),
|
||||
ExpiresAt: now.Add(-4 * time.Hour),
|
||||
ApprovalFloor: unifiedresources.ApprovalAdmin,
|
||||
AutoClass: unifiedresources.PolicyReasonCapabilityAutoNever,
|
||||
CurrentState: "Response time is elevated, but requests are still succeeding.",
|
||||
IntendedChange: "Restart the workload once and verify its service health.",
|
||||
SafetyChecks: []string{"No outage is currently confirmed", "Restart affects one workload"},
|
||||
Verification: []string{"Confirm the service returns healthy", "Compare response time after restart"},
|
||||
}), "morgan@example.com", unifiedresources.OutcomeRejected, now.Add(-4*time.Hour-48*time.Minute))
|
||||
|
||||
failed := approveMockAction(newMockActionFixture(mockActionFixtureSpec{
|
||||
ID: "demo-action-failed-restart",
|
||||
ResourceID: containerID,
|
||||
CapabilityName: "restart_container",
|
||||
Reason: "Recover the inventory worker after repeated liveness failures.",
|
||||
RequestedBy: "pulse_patrol",
|
||||
PlannedAt: now.Add(-26 * time.Hour),
|
||||
ExpiresAt: now.Add(-25 * time.Hour),
|
||||
ApprovalFloor: unifiedresources.ApprovalAdmin,
|
||||
AutoClass: unifiedresources.PolicyReasonCapabilityAutoNever,
|
||||
CurrentState: "The container is running but its liveness check is failing.",
|
||||
IntendedChange: "Restart the container once and verify that liveness recovers.",
|
||||
SafetyChecks: []string{"Container identity is current", "Restart is bounded to one container"},
|
||||
Verification: []string{"Confirm a new start time", "Observe the liveness check"},
|
||||
}), "alex@example.com", unifiedresources.OutcomeApproved, now.Add(-25*time.Hour-55*time.Minute))
|
||||
failedAt := now.Add(-25*time.Hour - 52*time.Minute)
|
||||
failed.Audit.State = unifiedresources.ActionStateFailed
|
||||
failed.Audit.UpdatedAt = failedAt
|
||||
failed.Audit.Result = &unifiedresources.ExecutionResult{ActionResultV2: &unifiedresources.ActionResultV2{
|
||||
Version: unifiedresources.ActionResultV2Version,
|
||||
Execution: unifiedresources.ActionExecutionTruth{Status: unifiedresources.ActionExecutionFailed, ReasonCode: "agent_unreachable", Summary: "The target agent was unreachable before the mutation began."},
|
||||
Verification: unifiedresources.ActionVerificationTruth{Status: unifiedresources.ActionVerificationNotAttempted, EvidenceClass: unifiedresources.ActionEvidenceNone},
|
||||
Compensation: unifiedresources.ActionCompensationTruth{Support: unifiedresources.ActionCompensationUnavailable, Status: unifiedresources.ActionCompensationNotAvailable, Summary: "No recovery action was needed because the mutation did not start."},
|
||||
}}
|
||||
failed.Events = append(failed.Events,
|
||||
mockActionTransition(failed.Audit, unifiedresources.ActionStateExecuting, failedAt.Add(-20*time.Second), "Pulse recorded dispatch before contacting the agent."),
|
||||
mockActionTransition(failed.Audit, unifiedresources.ActionStateFailed, failedAt, "The action did not run because the target agent was unreachable."),
|
||||
)
|
||||
failed = mustNormalizeMockActionFixture(failed)
|
||||
|
||||
fixtures := []ActionFixture{pending, approved, executing, completed, rejected, failed}
|
||||
sort.SliceStable(fixtures, func(i, j int) bool { return fixtures[i].Audit.UpdatedAt.After(fixtures[j].Audit.UpdatedAt) })
|
||||
return fixtures
|
||||
}
|
||||
|
||||
type mockActionFixtureSpec struct {
|
||||
ID string
|
||||
ResourceID string
|
||||
CapabilityName string
|
||||
Reason string
|
||||
RequestedBy string
|
||||
PlannedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ApprovalFloor unifiedresources.ActionApprovalLevel
|
||||
AutoClass unifiedresources.ActionPolicyReasonCode
|
||||
CurrentState string
|
||||
IntendedChange string
|
||||
SafetyChecks []string
|
||||
Verification []string
|
||||
}
|
||||
|
||||
func newMockActionFixture(spec mockActionFixtureSpec) ActionFixture {
|
||||
requiresApproval := spec.ApprovalFloor == unifiedresources.ApprovalAdmin || spec.ApprovalFloor == unifiedresources.ApprovalMultiFactor
|
||||
requirement := unifiedresources.ApprovalRequirementForFloor(spec.ApprovalFloor)
|
||||
actor := unifiedresources.ActionActor{SubjectID: spec.RequestedBy, Kind: unifiedresources.ActionActorService, CredentialID: "service:mock-patrol", OrgID: "default"}
|
||||
scope := unifiedresources.ActionPolicyDecisionScope{OrgID: "default", ResourceID: spec.ResourceID, CapabilityName: spec.CapabilityName}
|
||||
authorities := []unifiedresources.ActionPolicyAuthorityFactor{
|
||||
{Kind: unifiedresources.ActionPolicyAuthorityCapability, SourceID: "capability-registry:" + spec.CapabilityName, Revision: mockActionRevision("policy:sha256:", spec.ID+":capability"), Status: unifiedresources.ActionPolicyAuthorityConsulted, ApprovalFloor: spec.ApprovalFloor, ReasonCodes: []unifiedresources.ActionPolicyReasonCode{mockActionApprovalReason(spec.ApprovalFloor), spec.AutoClass}},
|
||||
{Kind: unifiedresources.ActionPolicyAuthorityTenant, SourceID: "patrol-tenant-policy", Revision: mockActionRevision("tenant-policy:sha256:", spec.ID+":tenant"), Status: unifiedresources.ActionPolicyAuthorityConsulted, ReasonCodes: []unifiedresources.ActionPolicyReasonCode{unifiedresources.PolicyReasonTenantModeAssisted, unifiedresources.PolicyReasonTenantFullLocked}},
|
||||
{Kind: unifiedresources.ActionPolicyAuthorityResource, SourceID: "resource-operator-policy:" + spec.ResourceID, Revision: mockActionRevision("resource-policy:sha256:", spec.ID+":resource"), Status: unifiedresources.ActionPolicyAuthorityConsulted, ReasonCodes: []unifiedresources.ActionPolicyReasonCode{unifiedresources.PolicyReasonResourceCapabilityAllow, unifiedresources.PolicyReasonResourceWindowOpen}},
|
||||
}
|
||||
policy, err := unifiedresources.BuildActionPolicyDecisionProvenance(spec.ID, scope, authorities, requirement, true, requiresApproval)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("build mock action policy %q: %v", spec.ID, err))
|
||||
}
|
||||
state := unifiedresources.ActionStatePlanned
|
||||
if requiresApproval {
|
||||
state = unifiedresources.ActionStatePending
|
||||
}
|
||||
audit := unifiedresources.ActionAuditRecord{
|
||||
ID: spec.ID, CreatedAt: spec.PlannedAt, UpdatedAt: spec.PlannedAt, State: state,
|
||||
Request: unifiedresources.ActionRequest{RequestID: "request-" + spec.ID, ResourceID: spec.ResourceID, CapabilityName: spec.CapabilityName, Params: map[string]any{}, Reason: spec.Reason, RequestedBy: spec.RequestedBy, Actor: actor},
|
||||
Plan: unifiedresources.ActionPlan{
|
||||
ActionID: spec.ID, RequestID: "request-" + spec.ID, Allowed: true, RequiresApproval: requiresApproval,
|
||||
ApprovalPolicy: spec.ApprovalFloor, ApprovalRequirement: requirement, RollbackAvailable: false,
|
||||
Message: spec.IntendedChange, PlannedAt: spec.PlannedAt, ExpiresAt: spec.ExpiresAt,
|
||||
ResourceVersion: mockActionRevision("resource:sha256:", spec.ID+":state"), PolicyVersion: mockActionRevision("policy:sha256:", spec.ID+":plan"),
|
||||
PolicyDecision: policy, PlanHash: mockActionRevision("sha256:", spec.ID+":"+spec.ResourceID),
|
||||
Preflight: &unifiedresources.ActionPreflight{Target: spec.ResourceID, CurrentState: spec.CurrentState, IntendedChange: spec.IntendedChange, DryRunAvailable: false, DryRunSummary: "This capability has no provider dry run.", SafetyChecks: spec.SafetyChecks, VerificationSteps: spec.Verification, GeneratedAt: spec.PlannedAt},
|
||||
},
|
||||
Origin: &unifiedresources.ActionOrigin{Surface: "patrol", FindingID: "finding-" + spec.ID, InvestigationID: "investigation-demo-actions"},
|
||||
}
|
||||
events := []unifiedresources.ActionLifecycleEvent{mockActionTransition(audit, unifiedresources.ActionStatePlanned, spec.PlannedAt, "Action plan created from a Patrol finding.")}
|
||||
if requiresApproval {
|
||||
events = append(events, mockActionTransition(audit, unifiedresources.ActionStatePending, spec.PlannedAt, "Action is waiting for operator approval."))
|
||||
}
|
||||
return mustNormalizeMockActionFixture(ActionFixture{Audit: audit, Events: events})
|
||||
}
|
||||
|
||||
func approveMockAction(fixture ActionFixture, subject string, outcome unifiedresources.ApprovalOutcome, decidedAt time.Time) ActionFixture {
|
||||
actor := unifiedresources.ActionActor{SubjectID: subject, Kind: unifiedresources.ActionActorUser, CredentialID: "session:mock-demo", OrgID: "default"}
|
||||
evidence := unifiedresources.ApprovalEvidence{Version: 1, Method: unifiedresources.MethodSession, Actor: actor, OrgID: "default", ActionID: fixture.Audit.ID, PlanHash: fixture.Audit.Plan.PlanHash, Outcome: outcome, IssuedAt: decidedAt, ExpiresAt: fixture.Audit.Plan.ExpiresAt}
|
||||
approval := unifiedresources.ActionApprovalRecord{Actor: subject, ActorBinding: actor, Method: unifiedresources.MethodSession, Timestamp: decidedAt, Outcome: outcome, Reason: mockActionDecisionReason(outcome), Evidence: &evidence}
|
||||
audit, event, err := unifiedresources.ApplyActionDecision(fixture.Audit, approval, decidedAt)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("apply mock action decision %q: %v", fixture.Audit.ID, err))
|
||||
}
|
||||
fixture.Audit = audit
|
||||
fixture.Events = append(fixture.Events, event)
|
||||
return mustNormalizeMockActionFixture(fixture)
|
||||
}
|
||||
|
||||
func mockActionTransition(audit unifiedresources.ActionAuditRecord, state unifiedresources.ActionState, at time.Time, message string) unifiedresources.ActionLifecycleEvent {
|
||||
return unifiedresources.ActionLifecycleEvent{ActionID: audit.ID, Timestamp: at, State: state, Kind: unifiedresources.ActionLifecycleEventTransition, Actor: audit.Request.RequestedBy, Message: message}
|
||||
}
|
||||
|
||||
func mustNormalizeMockActionFixture(fixture ActionFixture) ActionFixture {
|
||||
audit, err := unifiedresources.NormalizeActionAuditRecord(fixture.Audit)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("normalize mock action %q: %v", fixture.Audit.ID, err))
|
||||
}
|
||||
if fixture.Audit.Request.Params != nil {
|
||||
audit.Request.Params = make(map[string]any, len(fixture.Audit.Request.Params))
|
||||
for key, value := range fixture.Audit.Request.Params {
|
||||
audit.Request.Params[key] = value
|
||||
}
|
||||
}
|
||||
events := make([]unifiedresources.ActionLifecycleEvent, len(fixture.Events))
|
||||
for index, event := range fixture.Events {
|
||||
events[index], err = unifiedresources.NormalizeActionLifecycleEvent(event)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("normalize mock action event %q: %v", fixture.Audit.ID, err))
|
||||
}
|
||||
}
|
||||
return ActionFixture{Audit: audit, Events: events}
|
||||
}
|
||||
|
||||
func cloneActionFixtures(fixtures []ActionFixture) []ActionFixture {
|
||||
if fixtures == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]ActionFixture, len(fixtures))
|
||||
for index, fixture := range fixtures {
|
||||
cloned[index] = mustNormalizeMockActionFixture(fixture)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func mockActionResourceID(resources []unifiedresources.Resource, resourceTypes ...string) string {
|
||||
for _, resourceType := range resourceTypes {
|
||||
for _, resource := range resources {
|
||||
if string(resource.Type) == resourceType && strings.TrimSpace(resource.ID) != "" {
|
||||
return resource.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return "resource:demo-unavailable"
|
||||
}
|
||||
|
||||
func mockActionApprovalReason(floor unifiedresources.ActionApprovalLevel) unifiedresources.ActionPolicyReasonCode {
|
||||
switch floor {
|
||||
case unifiedresources.ApprovalNone:
|
||||
return unifiedresources.PolicyReasonCapabilityApprovalNone
|
||||
case unifiedresources.ApprovalMultiFactor:
|
||||
return unifiedresources.PolicyReasonCapabilityApprovalMFA
|
||||
case unifiedresources.ApprovalDryRun:
|
||||
return unifiedresources.PolicyReasonCapabilityDryRun
|
||||
default:
|
||||
return unifiedresources.PolicyReasonCapabilityApprovalAdmin
|
||||
}
|
||||
}
|
||||
|
||||
func mockActionDecisionReason(outcome unifiedresources.ApprovalOutcome) string {
|
||||
if outcome == unifiedresources.OutcomeRejected {
|
||||
return "The service is degraded but still available; investigate before restarting."
|
||||
}
|
||||
return "Approved after reviewing the target, scope, and verification plan."
|
||||
}
|
||||
|
||||
func mockActionRevision(prefix, seed string) string {
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(seed)))
|
||||
if prefix == "sha256:" {
|
||||
return prefix + digest
|
||||
}
|
||||
return prefix + digest[:24]
|
||||
}
|
||||
83
internal/mock/action_fixtures_test.go
Normal file
83
internal/mock/action_fixtures_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package mock
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestCurrentFixtureGraphCarriesActionInboxFixtures(t *testing.T) {
|
||||
previous := IsMockEnabled()
|
||||
if err := SetEnabled(true); err != nil {
|
||||
t.Fatalf("enable mock mode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetEnabled(previous) })
|
||||
|
||||
graph := CurrentFixtureGraph()
|
||||
if len(graph.ActionFixtures) != 6 {
|
||||
t.Fatalf("action fixture count = %d, want 6", len(graph.ActionFixtures))
|
||||
}
|
||||
resources, _ := graph.UnifiedResourceSnapshot()
|
||||
resourceIDs := make(map[string]struct{}, len(resources))
|
||||
for _, resource := range resources {
|
||||
resourceIDs[resource.ID] = struct{}{}
|
||||
}
|
||||
|
||||
states := make(map[unifiedresources.ActionState]int)
|
||||
for _, fixture := range graph.ActionFixtures {
|
||||
if _, err := unifiedresources.NormalizeActionAuditRecord(fixture.Audit); err != nil {
|
||||
t.Fatalf("normalize action %q: %v", fixture.Audit.ID, err)
|
||||
}
|
||||
if _, ok := resourceIDs[fixture.Audit.Request.ResourceID]; !ok {
|
||||
t.Fatalf("action %q references non-graph resource %q", fixture.Audit.ID, fixture.Audit.Request.ResourceID)
|
||||
}
|
||||
if fixture.Audit.Plan.PolicyDecision.Status != unifiedresources.ActionPolicyDecisionResolved {
|
||||
t.Fatalf("action %q policy status = %q", fixture.Audit.ID, fixture.Audit.Plan.PolicyDecision.Status)
|
||||
}
|
||||
if len(fixture.Events) == 0 {
|
||||
t.Fatalf("action %q has no lifecycle events", fixture.Audit.ID)
|
||||
}
|
||||
states[fixture.Audit.State]++
|
||||
}
|
||||
|
||||
for _, state := range []unifiedresources.ActionState{
|
||||
unifiedresources.ActionStatePending,
|
||||
unifiedresources.ActionStateApproved,
|
||||
unifiedresources.ActionStateExecuting,
|
||||
unifiedresources.ActionStateCompleted,
|
||||
unifiedresources.ActionStateRejected,
|
||||
unifiedresources.ActionStateFailed,
|
||||
} {
|
||||
if states[state] != 1 {
|
||||
t.Fatalf("state %q fixture count = %d, want 1", state, states[state])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionFixturesReturnsDefensiveCopies(t *testing.T) {
|
||||
previous := IsMockEnabled()
|
||||
if err := SetEnabled(true); err != nil {
|
||||
t.Fatalf("enable mock mode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetEnabled(previous) })
|
||||
|
||||
first := ActionFixtures()
|
||||
if len(first) == 0 {
|
||||
t.Fatal("expected action fixtures")
|
||||
}
|
||||
originalID := first[0].Audit.ID
|
||||
first[0].Audit.ID = "mutated"
|
||||
first[0].Audit.Request.Params["unexpected"] = true
|
||||
first[0].Events[0].Message = "mutated"
|
||||
|
||||
second := ActionFixtures()
|
||||
if second[0].Audit.ID != originalID {
|
||||
t.Fatalf("fixture ID mutated through returned copy: %q", second[0].Audit.ID)
|
||||
}
|
||||
if len(second[0].Audit.Request.Params) != 0 {
|
||||
t.Fatalf("fixture params mutated through returned copy: %#v", second[0].Audit.Request.Params)
|
||||
}
|
||||
if second[0].Events[0].Message == "mutated" {
|
||||
t.Fatal("fixture event mutated through returned copy")
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ type FixtureGraph struct {
|
|||
PlatformFixtures PlatformFixtures
|
||||
AvailabilityFixtures []AvailabilityFixture
|
||||
DiscoveryFixtures []*DiscoveryFixture
|
||||
ActionFixtures []ActionFixture
|
||||
}
|
||||
|
||||
func emptyFixtureGraph() FixtureGraph {
|
||||
|
|
@ -37,6 +38,8 @@ func buildFixtureGraph(cfg MockConfig, now time.Time) FixtureGraph {
|
|||
syncMetricRoleRegistryFromGraph(graph)
|
||||
graph.UpdateMetrics(cfg, now)
|
||||
graph.AlertHistory = buildAlertHistory(graph.State.Nodes, graph.State.VMs, graph.State.Containers)
|
||||
resources, _ := graph.UnifiedResourceSnapshot()
|
||||
graph.ActionFixtures = buildActionFixtures(resources, now)
|
||||
syncMetricRoleRegistryFromGraph(graph)
|
||||
return graph
|
||||
}
|
||||
|
|
@ -48,6 +51,7 @@ func cloneFixtureGraph(in FixtureGraph) FixtureGraph {
|
|||
PlatformFixtures: clonePlatformFixtures(in.PlatformFixtures),
|
||||
AvailabilityFixtures: cloneAvailabilityFixtures(in.AvailabilityFixtures),
|
||||
DiscoveryFixtures: cloneDiscoveryFixtures(in.DiscoveryFixtures),
|
||||
ActionFixtures: cloneActionFixtures(in.ActionFixtures),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3906,6 +3906,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
def test_lookup_paths_assigns_mock_runtime_fixture_authority_to_monitoring(self) -> None:
|
||||
result = lookup_paths(
|
||||
[
|
||||
"internal/mock/action_fixtures.go",
|
||||
"internal/mock/demo_scenarios.go",
|
||||
"internal/mock/fixture_graph.go",
|
||||
"internal/mock/platform_fixtures.go",
|
||||
|
|
@ -3935,6 +3936,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
match["verification_requirement"]["exact_files"],
|
||||
[
|
||||
"internal/mock/action_fixtures_test.go",
|
||||
"internal/mock/canonical_api_guardrails_test.go",
|
||||
"internal/mock/demo_scenarios_test.go",
|
||||
"internal/mock/generator_test.go",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue