mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Align action audit verification projection
This commit is contained in:
@@ -424,7 +424,7 @@ func TestServer_StreamSSEOnceTranslatesEventsToNotifications(t *testing.T) {
|
||||
"event: heartbeat",
|
||||
"",
|
||||
"event: action.completed",
|
||||
"data: {\"actionId\":\"x1\",\"success\":true}",
|
||||
"data: {\"actionId\":\"x1\",\"success\":true,\"verification\":{\"ran\":true,\"success\":true,\"commandRedacted\":true}}",
|
||||
"",
|
||||
}, "\n") + "\n"
|
||||
|
||||
@@ -456,6 +456,9 @@ func TestServer_StreamSSEOnceTranslatesEventsToNotifications(t *testing.T) {
|
||||
if !strings.Contains(body, `"method":"notifications/action.completed"`) {
|
||||
t.Errorf("missing action.completed notification; got %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"verification":{"ran":true,"success":true,"commandRedacted":true}`) {
|
||||
t.Errorf("action.completed verification must round-trip through MCP notification params; got %s", body)
|
||||
}
|
||||
if strings.Contains(body, "stream.connected") {
|
||||
t.Errorf("stream.connected must be filtered out as transport plumbing; got %s", body)
|
||||
}
|
||||
|
||||
@@ -95,13 +95,13 @@ describe('ActionAuditAPI', () => {
|
||||
result: {
|
||||
success: true,
|
||||
output: 'OK',
|
||||
verification: {
|
||||
ran: true,
|
||||
command: "systemctl is-active 'workload'",
|
||||
output: 'active',
|
||||
success: true,
|
||||
ranAt: '2026-04-29T12:00:25Z',
|
||||
},
|
||||
},
|
||||
verification: {
|
||||
ran: true,
|
||||
command: "systemctl is-active 'workload'",
|
||||
output: 'active',
|
||||
success: true,
|
||||
ranAt: '2026-04-29T12:00:25Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -110,11 +110,52 @@ describe('ActionAuditAPI', () => {
|
||||
|
||||
const response = await ActionAuditAPI.listActionAudits({ resourceId: 'vm:42' });
|
||||
expect(response.audits).toHaveLength(1);
|
||||
const v = response.audits[0].result?.verification;
|
||||
const v = response.audits[0].verification;
|
||||
expect(v?.ran).toBe(true);
|
||||
expect(v?.command).toBe("systemctl is-active 'workload'");
|
||||
expect(v?.output).toBe('active');
|
||||
expect(v?.success).toBe(true);
|
||||
expect(response.audits[0].result?.verification).toEqual(v);
|
||||
});
|
||||
|
||||
it('normalizes legacy result verification onto the canonical action audit field', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
audits: [
|
||||
{
|
||||
id: 'action-legacy-verify',
|
||||
createdAt: '2026-04-29T12:00:00Z',
|
||||
updatedAt: '2026-04-29T12:00:30Z',
|
||||
state: 'completed',
|
||||
request: {
|
||||
requestId: 'req-legacy-verify',
|
||||
resourceId: 'vm:42',
|
||||
capabilityName: 'pulse_control',
|
||||
reason: 'restart workload after backup',
|
||||
requestedBy: 'pulse_patrol',
|
||||
},
|
||||
plan: {
|
||||
actionId: 'action-legacy-verify',
|
||||
requestId: 'req-legacy-verify',
|
||||
allowed: true,
|
||||
requiresApproval: false,
|
||||
approvalPolicy: 'none',
|
||||
rollbackAvailable: false,
|
||||
},
|
||||
result: {
|
||||
success: true,
|
||||
verification: {
|
||||
ran: false,
|
||||
success: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
} as any);
|
||||
|
||||
const response = await ActionAuditAPI.listActionAudits({ resourceId: 'vm:42' });
|
||||
expect(response.audits[0].verification).toEqual({ ran: false, success: false });
|
||||
expect(response.audits[0].result?.verification).toEqual({ ran: false, success: false });
|
||||
});
|
||||
|
||||
it('treats gated action audit endpoints as unavailable instead of throwing', async () => {
|
||||
|
||||
@@ -53,7 +53,9 @@ const normalizeActionAuditListResponse = (
|
||||
raw: RawActionAuditListResponse | null | undefined,
|
||||
fallbackResourceId?: string,
|
||||
): ActionAuditListResponse => {
|
||||
const audits = objectArrayFieldOrEmpty<ActionAuditRecord>(raw, 'audits');
|
||||
const audits = objectArrayFieldOrEmpty<ActionAuditRecord>(raw, 'audits').map(
|
||||
normalizeActionAuditRecord,
|
||||
);
|
||||
const count = Number.isFinite(raw?.count) ? Number(raw?.count) : audits.length;
|
||||
const resourceId =
|
||||
typeof raw?.resourceId === 'string' && raw.resourceId.trim()
|
||||
@@ -68,6 +70,22 @@ const normalizeActionAuditListResponse = (
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeActionAuditRecord = (audit: ActionAuditRecord): ActionAuditRecord => {
|
||||
const verification = audit.verification ?? audit.result?.verification;
|
||||
if (!verification) return audit;
|
||||
|
||||
return {
|
||||
...audit,
|
||||
verification,
|
||||
result: audit.result
|
||||
? {
|
||||
...audit.result,
|
||||
verification: audit.result.verification ?? verification,
|
||||
}
|
||||
: audit.result,
|
||||
};
|
||||
};
|
||||
|
||||
export class ActionAuditAPI {
|
||||
static async listActionAudits(
|
||||
options?: ListActionAuditsOptions,
|
||||
|
||||
@@ -5,7 +5,9 @@ import { formatRelativeTime } from '@/utils/format';
|
||||
import {
|
||||
formatActionApprovalPolicyLabel,
|
||||
formatActionCapabilityLabel,
|
||||
getActionAuditVerification,
|
||||
getActionAuditStatePresentation,
|
||||
shouldRenderActionAuditVerification,
|
||||
} from '@/utils/actionAuditPresentation';
|
||||
|
||||
interface ResourceActionHistoryProps {
|
||||
@@ -20,6 +22,7 @@ const ActionHistoryRow: Component<{ audit: ActionAuditRecord }> = (props) => {
|
||||
const state = () => getActionAuditStatePresentation(props.audit.state);
|
||||
const preflight = () => props.audit.plan?.preflight;
|
||||
const result = () => props.audit.result;
|
||||
const verification = () => getActionAuditVerification(props.audit);
|
||||
|
||||
return (
|
||||
<div class="rounded border border-border bg-surface-hover px-2 py-1.5 text-[10px]">
|
||||
@@ -87,17 +90,15 @@ const ActionHistoryRow: Component<{ audit: ActionAuditRecord }> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={result()?.verification?.ran}>
|
||||
<Show when={shouldRenderActionAuditVerification(props.audit)}>
|
||||
{(() => {
|
||||
const v = result()!.verification!;
|
||||
const v = verification()!;
|
||||
const toneClass = v.success
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
: '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 ? 'Verified' : 'Verification failed'}</div>
|
||||
<Show when={v.command}>
|
||||
<div class="mt-0.5 font-mono text-[10px] opacity-80">{v.command}</div>
|
||||
</Show>
|
||||
|
||||
+82
-5
@@ -1,11 +1,12 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen, within } from '@solidjs/testing-library';
|
||||
|
||||
const sourceText = readFileSync(
|
||||
resolve(__dirname, '..', 'ResourceActionHistory.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
import { ResourceActionHistory } from '../ResourceActionHistory';
|
||||
import type { ActionAuditRecord } from '@/types/actionAudit';
|
||||
|
||||
const sourceText = readFileSync(resolve(__dirname, '..', 'ResourceActionHistory.tsx'), 'utf-8');
|
||||
|
||||
describe('ResourceActionHistory verification rendering', () => {
|
||||
it('renders the post-dispatch verification outcome on each audit row when ran=true', () => {
|
||||
@@ -14,7 +15,7 @@ describe('ResourceActionHistory verification rendering', () => {
|
||||
// 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.
|
||||
expect(sourceText).toContain('result()?.verification?.ran');
|
||||
expect(sourceText).toContain('shouldRenderActionAuditVerification(props.audit)');
|
||||
expect(sourceText).toContain('Verified');
|
||||
expect(sourceText).toContain('Verification failed');
|
||||
});
|
||||
@@ -28,4 +29,80 @@ describe('ResourceActionHistory verification rendering', () => {
|
||||
expect(sourceText).toContain('v.output');
|
||||
expect(sourceText).toContain('v.note');
|
||||
});
|
||||
|
||||
it('renders exactly one verification row for ran=true and omits ran=false', () => {
|
||||
cleanup();
|
||||
render(() =>
|
||||
ResourceActionHistory({
|
||||
audits: [
|
||||
actionAudit({
|
||||
id: 'action-verified',
|
||||
verification: {
|
||||
ran: true,
|
||||
success: true,
|
||||
command: "systemctl is-active 'nginx'",
|
||||
output: 'active',
|
||||
note: 'service reported active',
|
||||
},
|
||||
}),
|
||||
actionAudit({
|
||||
id: 'action-unverified',
|
||||
request: {
|
||||
requestId: 'req-action-unverified',
|
||||
resourceId: 'vm:42',
|
||||
capabilityName: 'restart_service',
|
||||
reason: 'Ran without a derivable verifier',
|
||||
requestedBy: 'agent:ops',
|
||||
},
|
||||
verification: {
|
||||
ran: false,
|
||||
success: false,
|
||||
command: 'should not render',
|
||||
output: 'sensitive output',
|
||||
note: 'should not render',
|
||||
},
|
||||
}),
|
||||
],
|
||||
count: 2,
|
||||
loadingLabel: 'Actions loaded',
|
||||
error: '',
|
||||
onRetry: () => undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const actionHistory = within(screen.getByTestId('resource-action-history-section'));
|
||||
expect(actionHistory.getAllByText('Verified')).toHaveLength(1);
|
||||
expect(actionHistory.getByText("systemctl is-active 'nginx'")).toBeInTheDocument();
|
||||
expect(actionHistory.queryByText('should not render')).toBeNull();
|
||||
expect(actionHistory.queryByText('sensitive output')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const actionAudit = (overrides: Partial<ActionAuditRecord> = {}): ActionAuditRecord => ({
|
||||
id: overrides.id ?? 'action-1',
|
||||
createdAt: '2026-04-29T12:00:00Z',
|
||||
updatedAt: '2026-04-29T12:01:00Z',
|
||||
state: 'completed',
|
||||
request: overrides.request ?? {
|
||||
requestId: 'req-1',
|
||||
resourceId: 'vm:42',
|
||||
capabilityName: 'restart_service',
|
||||
reason: 'Restart nginx after patching',
|
||||
requestedBy: 'agent:ops',
|
||||
},
|
||||
plan: overrides.plan ?? {
|
||||
actionId: overrides.id ?? 'action-1',
|
||||
requestId: 'req-1',
|
||||
allowed: true,
|
||||
requiresApproval: true,
|
||||
approvalPolicy: 'admin',
|
||||
rollbackAvailable: true,
|
||||
},
|
||||
result: overrides.result ?? {
|
||||
success: true,
|
||||
output: 'completed',
|
||||
},
|
||||
verification: overrides.verification,
|
||||
approvals: overrides.approvals,
|
||||
verificationOutcome: overrides.verificationOutcome,
|
||||
});
|
||||
|
||||
+13
-1
@@ -198,7 +198,9 @@ describe('ResourceDetailDrawer change history section', () => {
|
||||
// verification outcome alongside the dispatch result, not silently
|
||||
// drop it. Pin the wiring so future refactors cannot regress to an
|
||||
// output-only render.
|
||||
expect(resourceActionHistorySource).toContain('result()?.verification?.ran');
|
||||
expect(resourceActionHistorySource).toContain(
|
||||
'shouldRenderActionAuditVerification(props.audit)',
|
||||
);
|
||||
expect(resourceActionHistorySource).toContain('Verified');
|
||||
expect(resourceActionHistorySource).toContain('Verification failed');
|
||||
expect(actionAuditApiSource).toContain('/api/audit/actions');
|
||||
@@ -781,6 +783,13 @@ describe('ResourceDetailDrawer change history section', () => {
|
||||
success: true,
|
||||
output: 'nginx restarted',
|
||||
},
|
||||
verification: {
|
||||
ran: true,
|
||||
command: "systemctl is-active 'nginx'",
|
||||
output: 'active',
|
||||
success: true,
|
||||
ranAt: '2026-04-29T12:05:20Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -822,6 +831,9 @@ describe('ResourceDetailDrawer change history section', () => {
|
||||
expect(actionHistory.getByText('Restart nginx')).toBeInTheDocument();
|
||||
expect(actionHistory.getByText('Approval scoped to this resource.')).toBeInTheDocument();
|
||||
expect(actionHistory.getByText('nginx restarted')).toBeInTheDocument();
|
||||
expect(actionHistory.getByText('Verified')).toBeInTheDocument();
|
||||
expect(actionHistory.getByText("systemctl is-active 'nginx'")).toBeInTheDocument();
|
||||
expect(actionHistory.getByText('active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps service details summary-first until the service-local reveal is opened', () => {
|
||||
|
||||
@@ -74,6 +74,13 @@ export interface ActionAuditExecutionResult {
|
||||
verification?: ActionVerificationResult;
|
||||
}
|
||||
|
||||
export type ActionVerificationStatus = 'unknown' | 'verified' | 'unverified' | 'failed' | string;
|
||||
|
||||
export interface ActionVerificationOutcome {
|
||||
status: ActionVerificationStatus;
|
||||
evidenceSummary?: string;
|
||||
}
|
||||
|
||||
export interface ActionAuditRecord {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -83,6 +90,8 @@ export interface ActionAuditRecord {
|
||||
plan: ActionAuditPlan;
|
||||
approvals?: ActionAuditApprovalRecord[];
|
||||
result?: ActionAuditExecutionResult;
|
||||
verification?: ActionVerificationResult;
|
||||
verificationOutcome?: ActionVerificationOutcome;
|
||||
}
|
||||
|
||||
export interface ActionAuditListResponse {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatActionApprovalPolicyLabel,
|
||||
formatActionCapabilityLabel,
|
||||
getActionAuditVerification,
|
||||
getActionAuditStatePresentation,
|
||||
shouldRenderActionAuditVerification,
|
||||
} from '@/utils/actionAuditPresentation';
|
||||
|
||||
describe('actionAuditPresentation', () => {
|
||||
@@ -24,4 +26,43 @@ describe('actionAuditPresentation', () => {
|
||||
expect(formatActionApprovalPolicyLabel('dry_run_only')).toBe('Dry run only');
|
||||
expect(formatActionApprovalPolicyLabel('mfa')).toBe('MFA approval');
|
||||
});
|
||||
|
||||
it('uses the canonical top-level verification field for rendering decisions', () => {
|
||||
expect(
|
||||
getActionAuditVerification({
|
||||
verification: {
|
||||
ran: true,
|
||||
success: true,
|
||||
command: "systemctl is-active 'nginx'",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ ran: true, command: "systemctl is-active 'nginx'" });
|
||||
expect(
|
||||
getActionAuditVerification({
|
||||
result: {
|
||||
success: true,
|
||||
verification: {
|
||||
ran: false,
|
||||
success: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ ran: false, success: false });
|
||||
expect(
|
||||
shouldRenderActionAuditVerification({
|
||||
verification: {
|
||||
ran: true,
|
||||
success: true,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRenderActionAuditVerification({
|
||||
verification: {
|
||||
ran: false,
|
||||
success: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ActionAuditState } from '@/types/actionAudit';
|
||||
import type {
|
||||
ActionAuditRecord,
|
||||
ActionAuditState,
|
||||
ActionVerificationResult,
|
||||
} from '@/types/actionAudit';
|
||||
|
||||
export interface ActionAuditStatePresentation {
|
||||
label: string;
|
||||
@@ -76,3 +80,11 @@ export const formatActionApprovalPolicyLabel = (policy: string | undefined): str
|
||||
return formatActionCapabilityLabel(policy || 'Policy');
|
||||
}
|
||||
};
|
||||
|
||||
export const getActionAuditVerification = (
|
||||
audit: Pick<ActionAuditRecord, 'verification' | 'result'>,
|
||||
): ActionVerificationResult | undefined => audit.verification ?? audit.result?.verification;
|
||||
|
||||
export const shouldRenderActionAuditVerification = (
|
||||
audit: Pick<ActionAuditRecord, 'verification' | 'result'>,
|
||||
): boolean => getActionAuditVerification(audit)?.ran === true;
|
||||
|
||||
@@ -1433,10 +1433,16 @@ func TestSetOnActionCompleted_RecordCarriesVerificationResult(t *testing.T) {
|
||||
if received.Result.Verification == nil {
|
||||
t.Fatal("callback record must carry a Verification block for service-restart actions — drift here breaks the certainty loop on action.completed")
|
||||
}
|
||||
if received.Verification == nil {
|
||||
t.Fatal("callback record must carry the canonical top-level Verification block")
|
||||
}
|
||||
v := received.Result.Verification
|
||||
if !v.Ran {
|
||||
t.Error("Verification.Ran must be true after the broker dispatched the probe")
|
||||
}
|
||||
if received.Verification.Command != v.Command {
|
||||
t.Errorf("canonical Verification.Command = %q, want %q", received.Verification.Command, v.Command)
|
||||
}
|
||||
if !v.Success {
|
||||
t.Errorf("Verification.Success: probe returned exit 0, expected Success=true; got %+v", v)
|
||||
}
|
||||
|
||||
@@ -520,6 +520,7 @@ func (h *AuditHandlers) HandleListUnifiedActionAudits(w http.ResponseWriter, r *
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query action audits", nil)
|
||||
return
|
||||
}
|
||||
audits = normalizeUnifiedActionAuditResponse(audits)
|
||||
|
||||
response := map[string]any{
|
||||
"audits": audits,
|
||||
@@ -533,6 +534,27 @@ func (h *AuditHandlers) HandleListUnifiedActionAudits(w http.ResponseWriter, r *
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func normalizeUnifiedActionAuditResponse(audits []unifiedresources.ActionAuditRecord) []unifiedresources.ActionAuditRecord {
|
||||
if len(audits) == 0 {
|
||||
return audits
|
||||
}
|
||||
normalized := make([]unifiedresources.ActionAuditRecord, 0, len(audits))
|
||||
for _, audit := range audits {
|
||||
canonical, err := unifiedresources.NormalizeActionAuditRecord(audit)
|
||||
if err != nil {
|
||||
canonical = audit
|
||||
canonical.Verification = unifiedresources.CanonicalActionVerification(audit)
|
||||
if canonical.Result != nil && canonical.Verification != nil && canonical.Result.Verification == nil {
|
||||
result := *canonical.Result
|
||||
result.Verification = canonical.Verification
|
||||
canonical.Result = &result
|
||||
}
|
||||
}
|
||||
normalized = append(normalized, canonical)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// HandleListUnifiedActionLifecycleEvents handles GET /api/audit/actions/{id}/events.
|
||||
func (h *AuditHandlers) HandleListUnifiedActionLifecycleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
|
||||
@@ -372,9 +372,9 @@ func (b *AgentEventBroadcaster) PublishActionCompletedRecord(record unifiedresou
|
||||
if record.Result != nil {
|
||||
payload.Success = record.Result.Success
|
||||
payload.ErrorMessage = record.Result.ErrorMessage
|
||||
if v := projectAgentResourceVerification(record.Result.Verification); v != nil {
|
||||
payload.Verification = v
|
||||
}
|
||||
}
|
||||
if v := projectAgentResourceVerification(unifiedresources.CanonicalActionVerification(record)); v != nil {
|
||||
payload.Verification = v
|
||||
}
|
||||
b.PublishActionCompleted(payload)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestAgentEventBroadcaster_SubscribeReceivesPublishedEvents(t *testing.T) {
|
||||
@@ -610,6 +611,39 @@ func TestAgentEventBroadcaster_PublishActionCompletedRoundTripsVerification(t *t
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for action.completed event")
|
||||
}
|
||||
|
||||
recordBroadcaster := NewAgentEventBroadcaster()
|
||||
recordEvents, recordUnsub := recordBroadcaster.Subscribe()
|
||||
defer recordUnsub()
|
||||
recordBroadcaster.PublishActionCompletedRecord(unifiedresources.ActionAuditRecord{
|
||||
ID: "action-verify-record",
|
||||
UpdatedAt: ranAt,
|
||||
State: unifiedresources.ActionStateCompleted,
|
||||
Request: unifiedresources.ActionRequest{
|
||||
ResourceID: "container:web-1",
|
||||
CapabilityName: "restart_service",
|
||||
RequestedBy: "agent:ops",
|
||||
},
|
||||
Result: &unifiedresources.ExecutionResult{Success: true},
|
||||
Verification: &unifiedresources.ActionVerificationResult{
|
||||
Ran: true,
|
||||
Success: true,
|
||||
Command: "systemctl is-active 'nginx'",
|
||||
RanAt: ranAt,
|
||||
},
|
||||
})
|
||||
select {
|
||||
case event := <-recordEvents:
|
||||
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("record payload type: got %T", event.Payload)
|
||||
}
|
||||
if payload.Verification == nil || payload.Verification.Command != "systemctl is-active 'nginx'" {
|
||||
t.Fatalf("record canonical verification did not project onto action.completed: %+v", payload.Verification)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for record-backed action.completed event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentEventBroadcaster_PublishActionCompletedAbsentVerificationOmitsField(t *testing.T) {
|
||||
|
||||
@@ -171,17 +171,64 @@ func NormalizeVerificationOutcome(outcome VerificationOutcome) VerificationOutco
|
||||
return outcome
|
||||
}
|
||||
|
||||
// NormalizeActionVerificationResult applies the canonical verification field
|
||||
// hygiene used by stored audit records and every action-audit projection.
|
||||
func NormalizeActionVerificationResult(result *ActionVerificationResult) *ActionVerificationResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
normalized := *result
|
||||
normalized.Command = strings.TrimSpace(normalized.Command)
|
||||
normalized.Output = strings.TrimSpace(normalized.Output)
|
||||
normalized.Note = strings.TrimSpace(normalized.Note)
|
||||
if normalized.RanAt.IsZero() {
|
||||
normalized.RanAt = time.Time{}
|
||||
} else {
|
||||
normalized.RanAt = normalized.RanAt.UTC()
|
||||
}
|
||||
if !normalized.Ran {
|
||||
normalized.Command = ""
|
||||
normalized.Output = ""
|
||||
normalized.Success = false
|
||||
normalized.RanAt = time.Time{}
|
||||
normalized.Note = ""
|
||||
}
|
||||
return &normalized
|
||||
}
|
||||
|
||||
func cloneActionVerificationResult(result *ActionVerificationResult) *ActionVerificationResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *result
|
||||
return &clone
|
||||
}
|
||||
|
||||
// CanonicalActionVerification returns the top-level verification projection for
|
||||
// an audit record, falling back to legacy result.verification records while
|
||||
// older persisted rows are still being read.
|
||||
func CanonicalActionVerification(record ActionAuditRecord) *ActionVerificationResult {
|
||||
if result := NormalizeActionVerificationResult(record.Verification); result != nil {
|
||||
return result
|
||||
}
|
||||
if record.Result == nil {
|
||||
return nil
|
||||
}
|
||||
return NormalizeActionVerificationResult(record.Result.Verification)
|
||||
}
|
||||
|
||||
// ActionAuditRecord tracks the full end-to-end lifecycle of a tool invocation.
|
||||
type ActionAuditRecord struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
State ActionState `json:"state"`
|
||||
Request ActionRequest `json:"request"`
|
||||
Plan ActionPlan `json:"plan"`
|
||||
Approvals []ActionApprovalRecord `json:"approvals,omitempty"`
|
||||
Result *ExecutionResult `json:"result,omitempty"`
|
||||
VerificationOutcome VerificationOutcome `json:"verificationOutcome"`
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
State ActionState `json:"state"`
|
||||
Request ActionRequest `json:"request"`
|
||||
Plan ActionPlan `json:"plan"`
|
||||
Approvals []ActionApprovalRecord `json:"approvals,omitempty"`
|
||||
Result *ExecutionResult `json:"result,omitempty"`
|
||||
Verification *ActionVerificationResult `json:"verification,omitempty"`
|
||||
VerificationOutcome VerificationOutcome `json:"verificationOutcome"`
|
||||
}
|
||||
|
||||
// ActionLifecycleEvent represents an append-only state transition in an action's life.
|
||||
@@ -555,6 +602,21 @@ func NormalizeActionAuditRecord(record ActionAuditRecord) (ActionAuditRecord, er
|
||||
}
|
||||
}
|
||||
record.Plan.Preflight = NormalizeActionPreflight(record.Plan.Preflight, record.Request, record.Plan)
|
||||
|
||||
if record.Result != nil {
|
||||
result := *record.Result
|
||||
result.Output = strings.TrimSpace(result.Output)
|
||||
result.ErrorMessage = strings.TrimSpace(result.ErrorMessage)
|
||||
result.Verification = NormalizeActionVerificationResult(result.Verification)
|
||||
record.Result = &result
|
||||
}
|
||||
record.Verification = NormalizeActionVerificationResult(record.Verification)
|
||||
if record.Verification == nil && record.Result != nil {
|
||||
record.Verification = cloneActionVerificationResult(record.Result.Verification)
|
||||
}
|
||||
if record.Verification != nil && record.Result != nil {
|
||||
record.Result.Verification = cloneActionVerificationResult(record.Verification)
|
||||
}
|
||||
record.VerificationOutcome = NormalizeVerificationOutcome(record.VerificationOutcome)
|
||||
|
||||
for i := range record.Approvals {
|
||||
|
||||
@@ -51,6 +51,48 @@ func TestNormalizeActionAuditRecordPopulatesGovernedPlanPreflight(t *testing.T)
|
||||
if len(record.Plan.Preflight.SafetyChecks) == 0 || len(record.Plan.Preflight.VerificationSteps) == 0 {
|
||||
t.Fatalf("preflight should carry safety and verification checks: %#v", record.Plan.Preflight)
|
||||
}
|
||||
|
||||
withLegacyVerification := record
|
||||
withLegacyVerification.Result = &ExecutionResult{
|
||||
Success: true,
|
||||
Verification: &ActionVerificationResult{
|
||||
Ran: true,
|
||||
Command: " systemctl is-active 'nginx' ",
|
||||
Output: " active\n",
|
||||
Success: true,
|
||||
RanAt: now.Add(time.Minute),
|
||||
},
|
||||
}
|
||||
withLegacyVerification, err = NormalizeActionAuditRecord(withLegacyVerification)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeActionAuditRecord(legacy verification) error = %v", err)
|
||||
}
|
||||
if withLegacyVerification.Verification == nil || withLegacyVerification.Verification.Command != "systemctl is-active 'nginx'" || withLegacyVerification.Verification.Output != "active" {
|
||||
t.Fatalf("canonical verification not populated from result verification: %#v", withLegacyVerification.Verification)
|
||||
}
|
||||
if withLegacyVerification.Result.Verification == nil || withLegacyVerification.Result.Verification.Command != withLegacyVerification.Verification.Command {
|
||||
t.Fatalf("legacy verification not kept aligned: result=%#v canonical=%#v", withLegacyVerification.Result.Verification, withLegacyVerification.Verification)
|
||||
}
|
||||
|
||||
withUnrunVerification := record
|
||||
withUnrunVerification.Verification = &ActionVerificationResult{
|
||||
Ran: false,
|
||||
Command: "should not persist",
|
||||
Output: "sensitive",
|
||||
Success: true,
|
||||
RanAt: now.Add(time.Minute),
|
||||
Note: "details",
|
||||
}
|
||||
withUnrunVerification, err = NormalizeActionAuditRecord(withUnrunVerification)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeActionAuditRecord(unrun verification) error = %v", err)
|
||||
}
|
||||
if withUnrunVerification.Verification == nil || withUnrunVerification.Verification.Ran {
|
||||
t.Fatalf("expected canonical ran=false verification, got %#v", withUnrunVerification.Verification)
|
||||
}
|
||||
if withUnrunVerification.Verification.Command != "" || withUnrunVerification.Verification.Output != "" || withUnrunVerification.Verification.Note != "" || !withUnrunVerification.Verification.RanAt.IsZero() {
|
||||
t.Fatalf("ran=false verification must not retain details: %#v", withUnrunVerification.Verification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeActionAuditRecordRejectsUngovernedRecords(t *testing.T) {
|
||||
@@ -365,13 +407,26 @@ func TestCompleteActionExecutionRecordsResult(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
updated, event, err := CompleteActionExecution(record, &ExecutionResult{Success: true, Output: "done"}, "operator@example.com", now)
|
||||
updated, event, err := CompleteActionExecution(record, &ExecutionResult{
|
||||
Success: true,
|
||||
Output: "done",
|
||||
Verification: &ActionVerificationResult{
|
||||
Ran: true,
|
||||
Command: "systemctl is-active 'nginx'",
|
||||
Output: "active",
|
||||
Success: true,
|
||||
RanAt: now,
|
||||
},
|
||||
}, "operator@example.com", now)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteActionExecution: %v", err)
|
||||
}
|
||||
if updated.State != ActionStateCompleted || updated.Result == nil || updated.Result.Output != "done" {
|
||||
t.Fatalf("completed action = %#v", updated)
|
||||
}
|
||||
if updated.Verification == nil || !updated.Verification.Ran || updated.Verification.Command != "systemctl is-active 'nginx'" {
|
||||
t.Fatalf("completed action verification = %#v", updated.Verification)
|
||||
}
|
||||
if event.State != ActionStateCompleted || event.Message != "Action execution completed." {
|
||||
t.Fatalf("completed event = %#v", event)
|
||||
}
|
||||
|
||||
@@ -1202,7 +1202,17 @@ func TestActionAuditRecord_RoundTrip(t *testing.T) {
|
||||
now := time.Date(2026, 3, 18, 13, 0, 0, 0, time.UTC)
|
||||
expires := now.Add(15 * time.Minute)
|
||||
approvedAt := now.Add(2 * time.Minute)
|
||||
result := &ExecutionResult{Success: true, Output: "completed"}
|
||||
result := &ExecutionResult{
|
||||
Success: true,
|
||||
Output: "completed",
|
||||
Verification: &ActionVerificationResult{
|
||||
Ran: true,
|
||||
Command: "systemctl is-active 'nginx'",
|
||||
Output: "active",
|
||||
Success: true,
|
||||
RanAt: now.Add(4 * time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
record := ActionAuditRecord{
|
||||
ID: "action-1",
|
||||
@@ -1274,6 +1284,13 @@ func TestActionAuditRecord_RoundTrip(t *testing.T) {
|
||||
if got.Result == nil || !got.Result.Success || got.Result.Output != result.Output {
|
||||
t.Fatalf("result round-trip failed: %+v", got.Result)
|
||||
}
|
||||
verification := CanonicalActionVerification(got)
|
||||
if verification == nil || !verification.Ran || verification.Command != result.Verification.Command {
|
||||
t.Fatalf("canonical verification round-trip failed: %+v", verification)
|
||||
}
|
||||
if got.Result.Verification == nil || got.Result.Verification.Command != verification.Command {
|
||||
t.Fatalf("result verification did not stay aligned with canonical verification: result=%+v canonical=%+v", got.Result.Verification, verification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStore_RecordActionAudit_UpsertsByID(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user