mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-21 06:53:29 +00:00
Show verified Patrol work receipts
Contract-Neutral: Patrol receipt API does not change agent lifecycle or storage recovery contracts
This commit is contained in:
parent
7fb39f65cf
commit
d3a345bd66
16 changed files with 533 additions and 136 deletions
|
|
@ -119,6 +119,7 @@ continues to mint and quote the enrollment token.
|
|||
3. `internal/api/discovery_handlers.go`
|
||||
4. `internal/api/alerts.go`
|
||||
4a. `internal/api/attention_handlers.go`
|
||||
4b. `internal/api/attention_receipts.go`
|
||||
5. `internal/api/activity_audit_handlers.go`
|
||||
5a. `pkg/extensions/audit_admin.go`
|
||||
6. `internal/api/actions.go`
|
||||
|
|
@ -3833,7 +3834,9 @@ the authoritative analysis outcome.
|
|||
`frontend-modern/src/api/patrolAttention.ts` own the typed read transport for
|
||||
`GET /api/ai/patrol/attention`,
|
||||
`GET /api/ai/patrol/attention/summary`, and
|
||||
`GET /api/ai/patrol/attention/{id}`. Read routes require `monitoring:read`,
|
||||
`GET /api/ai/patrol/attention/{id}`. The same authenticated transport exposes
|
||||
`GET /api/ai/patrol/attention/receipts` as the bounded verified-work projection.
|
||||
Read routes require `monitoring:read`,
|
||||
and — because the attention workbench supersedes the legacy patrol findings
|
||||
routes that mobile devices already used — they equally accept the
|
||||
backend-owned `relay:mobile:access` capability and the legacy `ai:execute`
|
||||
|
|
@ -3846,6 +3849,13 @@ drift back to a single-scope gate: a registered phone carries only
|
|||
mobile alert sync on upgrade (v6.1.0-rc.4 regression).
|
||||
Lists use bounded pagination with a maximum of 200 records and one bounded
|
||||
protection-posture batch. The summary path does not read recovery history.
|
||||
Receipt reads are separate from lifecycle pagination: the unified action store
|
||||
filters the trusted `patrol` and `operational_trust_attention` origins and a
|
||||
canonically confirmed postcondition before applying the requested maximum of
|
||||
50, orders by verification update newest first, and returns only bounded
|
||||
presentation fields. A client must not scan a generic resolved-attention or
|
||||
settled-action page and filter it after pagination, because unrelated history
|
||||
can otherwise hide genuine verified work.
|
||||
The Patrol workbench may project the summary counts into its fixed lifecycle
|
||||
filter labels at either desktop or narrow widths, but changing presentation
|
||||
does not create a parallel count or filter vocabulary: every selection still
|
||||
|
|
@ -7408,10 +7418,11 @@ oversized requests clamp to the backend maximum of `100`.
|
|||
The outcome-first Patrol home consumes the existing typed attention contract;
|
||||
it does not create a second server lifecycle. `GET /api/ai/patrol/attention`
|
||||
with `filter=active` remains the source for current typed evidence and governed
|
||||
action offers, while `filter=resolved` supplies receipt candidates. Frontend
|
||||
presentation may call a resolved item handled only when the API-authored
|
||||
`state` is `resolved` and `verificationState` is `succeeded`; it must preserve
|
||||
pending, failed, unknown, and unavailable verification as non-receipts. The
|
||||
action offers. `GET /api/ai/patrol/attention/receipts` is the canonical recent
|
||||
work source for both autonomous Patrol actions and Operational Trust attention
|
||||
actions. It admits only completed execution with a confirmed postcondition and
|
||||
therefore never returns pending, failed, contradicted, inconclusive, rejected,
|
||||
or unrelated-origin work as a receipt. The
|
||||
frontend may use action eligibility and approval fields to decide whether an
|
||||
item needs another operator decision under the effective autonomy level, but
|
||||
that projection grants no action authority and cannot override server planning,
|
||||
|
|
|
|||
|
|
@ -778,6 +778,12 @@ genuine decisions, verified receipts, and secondary operational records; the
|
|||
shared primitive boundary owns keyboard focus, touch targets, responsive
|
||||
stacking, and visual consistency. Plan-locked paid-mode discovery is not a
|
||||
daily-page primitive and must not be added beside the effective mode.
|
||||
The compact `Recently handled` list consumes server-authored Patrol work
|
||||
receipts rather than filtering generic history in the browser. It may format
|
||||
the canonical capability name and verification time with shared presentation
|
||||
helpers, but it must preserve the server's verified-only membership, evidence
|
||||
summary, newest-first order, empty state, and last-truthful-data behavior during
|
||||
a refresh error.
|
||||
For Patrol, that includes the Open work description: it may use concise
|
||||
row-level guidance such as review evidence, approve a change, inspect automatic
|
||||
actions, or review verification results, but it must remain descriptive copy
|
||||
|
|
|
|||
|
|
@ -93,11 +93,16 @@ authority or a bypass around planning, feasibility, approval, dispatch, and
|
|||
verification.
|
||||
|
||||
The `Recently handled` surface is proof-constrained. It may render a receipt
|
||||
only for a resolved attention record whose verification state is `succeeded`,
|
||||
and it describes the resolved record rather than inferring which offered action
|
||||
ran. Pending, failed, unknown, or unavailable verification must never be styled
|
||||
as handled work. Objective summary language similarly counts protection only
|
||||
from active objectives whose server-authored coverage state is `covered`.
|
||||
only from the server-filtered verified-work endpoint backed by canonical action
|
||||
audits. That projection covers both autonomous `patrol` actions and governed
|
||||
`operational_trust_attention` actions, applies origin plus confirmed
|
||||
postcondition filtering before its display limit, and orders by verification
|
||||
time newest first. The browser never reconstructs receipts from a paginated
|
||||
resolved-attention or settled-action list. Pending, failed, contradicted,
|
||||
inconclusive, rejected, unknown, unavailable, and unrelated-origin work must
|
||||
never be styled as handled work. Objective summary language similarly counts
|
||||
protection only from active objectives whose server-authored coverage state is
|
||||
`covered`.
|
||||
|
||||
The effective autonomy label and plain-language consequence stay visible, but
|
||||
the four-level selector is a secondary disclosure. A plan-locked daily operator
|
||||
|
|
|
|||
|
|
@ -2446,11 +2446,17 @@ Memory and SQLite preserve the same object through replay and reopen.
|
|||
Action audits are the durable source of truth for Patrol action continuity.
|
||||
The store exposes optional `ActionAuditOriginReader` and
|
||||
`PendingActionAuditReader` capabilities; origin lookup is scoped by org and
|
||||
investigation identity, while pending reads are oldest-first. SQLite persists
|
||||
investigation identity, while pending reads are oldest-first. The optional
|
||||
`VerifiedActionAuditOriginReader` is the canonical receipt source: it applies
|
||||
the trusted origin set and confirmed postcondition predicate before the bound,
|
||||
returns newest verification first, and never asks a product client to scan a
|
||||
generic settled-action page for proof. SQLite persists
|
||||
an absent origin as NULL, guards JSON-expression queries and indexes with
|
||||
`json_valid(origin_json)`, and keeps dedicated origin/state indexes so an old
|
||||
empty or malformed value cannot reject otherwise valid audit rows. Memory and
|
||||
SQLite implementations preserve the same ordering and clone semantics.
|
||||
empty or malformed value cannot reject otherwise valid audit rows. Its
|
||||
origin/verification/update index keeps the receipt read bounded as audit
|
||||
history grows. Memory and SQLite implementations preserve the same filtering,
|
||||
ordering, and clone semantics.
|
||||
Terminal audit persistence derives `VerificationOutcome` from the canonical
|
||||
execution result: no verifier is unknown, a configured verifier that did not
|
||||
run is unverified, a successful read-back is verified, and a failed read-back
|
||||
|
|
|
|||
|
|
@ -1,30 +1,25 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "48fac73ff020491c1731c281d0c69b874c27f71b",
|
||||
"verified_at": "2026-08-14T12:03:35Z",
|
||||
"base_sha": "7fb39f65cf25a3fc822f5795abc99dd670e93a48",
|
||||
"verified_at": "2026-08-15T07:42:09Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/components/shared/mobileNavBarModel.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
|
||||
"frontend-modern/src/pages/Actions.tsx",
|
||||
"frontend-modern/src/utils/assistantPageContext.ts"
|
||||
"frontend-modern/src/api/patrolAttention.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx",
|
||||
"frontend-modern/src/features/patrol/patrolHomePresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/AppLayout.tsx": "91e6aa7723872ec61437da7d3851fe82be89e707f53c66a3c3ff74fa7851671a",
|
||||
"frontend-modern/src/components/shared/mobileNavBarModel.ts": "bccadf7522c9e7affa06ecebd816c8963295bd40ce2ac122a022d3662e1a5e7d",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "b6eaffbb9aa1e0b9e69a5245d0e2e4bce5b9c88df353d207db095189df6e82c1",
|
||||
"frontend-modern/src/pages/Actions.tsx": "878397bb4571fc714684e35474acc6a60220498b4ac256c4bd57f39e41533ba5",
|
||||
"frontend-modern/src/utils/assistantPageContext.ts": "7943a778ee4d4e1fb56c0a09778fffc8402d528da2e336713b1ff93e110acf6f"
|
||||
"frontend-modern/src/api/patrolAttention.ts": "2038e240e586d096f289e477e05c0acdb9d33eb08fe5791dd8080666a43350df",
|
||||
"frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx": "c996100b27f7101a2a64c742ff0c1602cb2017397afad22a3da5e8e768ac8798",
|
||||
"frontend-modern/src/features/patrol/patrolHomePresentation.ts": "41991e1b605df792768237e488edf0d1f8c1190896e5be865dfbd5f24017e9aa"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/actions"
|
||||
"/patrol"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
|
|
@ -32,18 +27,17 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"Patrol desktop navigation with one pending universal action",
|
||||
"Patrol Activity history handoff with pending-review badge",
|
||||
"Activity history open queue with Patrol selected in desktop navigation",
|
||||
"Activity history open queue with Patrol selected in mobile navigation",
|
||||
"Patrol and Activity history at 390 pixels without horizontal overflow"
|
||||
"Recently handled populated with six newest server-filtered verified Patrol receipts",
|
||||
"Refresh failure keeps the last truthful receipts visible with an explicit unavailable message",
|
||||
"Refresh recovery clears the error and reloads the same six verified receipts",
|
||||
"Activity history handoff remains adjacent to verified receipts",
|
||||
"Phone-width receipt list wraps verification evidence without horizontal overflow"
|
||||
],
|
||||
"interactions": [
|
||||
"opened Activity history from the Patrol page",
|
||||
"confirmed the stable /actions route and Activity history browser title",
|
||||
"confirmed no Actions destination exists in desktop or mobile navigation",
|
||||
"confirmed Patrol remains selected while Activity history is open",
|
||||
"returned to Patrol through mobile navigation",
|
||||
"refreshed the populated Recently handled list",
|
||||
"stopped the disposable server and refreshed to exercise the error state",
|
||||
"confirmed six existing receipts remained visible during the refresh error",
|
||||
"restored the healthy server and refreshed to clear the error",
|
||||
"resized to 390 by 844 and confirmed document width stayed at 390"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getPatrolAttentionDetail,
|
||||
getPatrolAttentionEvidence,
|
||||
getPatrolAttentionSummary,
|
||||
getPatrolWorkReceipts,
|
||||
planPatrolAttentionAction,
|
||||
suppressPatrolAttention,
|
||||
unacknowledgePatrolAttention,
|
||||
|
|
@ -45,6 +46,11 @@ describe('Patrol attention API', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('uses the server-filtered verified work receipt projection', async () => {
|
||||
await getPatrolWorkReceipts(6);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/receipts?limit=6');
|
||||
});
|
||||
|
||||
it('uses canonical item-scoped lifecycle mutations with bounded suppression input', async () => {
|
||||
await acknowledgePatrolAttention('record/one');
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
|
|
|
|||
|
|
@ -97,6 +97,26 @@ export interface AttentionEvidenceResponse {
|
|||
retained: boolean;
|
||||
}
|
||||
|
||||
export interface PatrolWorkReceipt {
|
||||
actionId: string;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceType?: string;
|
||||
capabilityName: string;
|
||||
verifiedAt: string;
|
||||
verificationSummary: string;
|
||||
evidenceClass: 'none' | 'agent_attested' | 'independent';
|
||||
originSurface: 'patrol' | 'operational_trust_attention';
|
||||
findingId?: string;
|
||||
operationalRecordId?: string;
|
||||
}
|
||||
|
||||
export interface PatrolWorkReceiptListResponse {
|
||||
data: PatrolWorkReceipt[];
|
||||
count: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface AttentionMutationResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
|
@ -118,6 +138,13 @@ export async function getPatrolAttentionSummary(): Promise<AttentionSummary> {
|
|||
return apiFetchJSON<AttentionSummary>('/api/ai/patrol/attention/summary');
|
||||
}
|
||||
|
||||
export async function getPatrolWorkReceipts(limit = 6): Promise<PatrolWorkReceiptListResponse> {
|
||||
const search = new URLSearchParams({ limit: String(limit) });
|
||||
return apiFetchJSON<PatrolWorkReceiptListResponse>(
|
||||
`/api/ai/patrol/attention/receipts?${search.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPatrolAttentionDetail(itemId: string): Promise<AttentionItemDetail> {
|
||||
return apiFetchJSON<AttentionItemDetail>(
|
||||
`/api/ai/patrol/attention/${encodeURIComponent(itemId)}`,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import CheckCircleIcon from 'lucide-solid/icons/circle-check';
|
||||
import RefreshIcon from 'lucide-solid/icons/refresh-cw';
|
||||
import { getPatrolAttention, type AttentionItem } from '@/api/patrolAttention';
|
||||
import { getPatrolWorkReceipts, type PatrolWorkReceipt } from '@/api/patrolAttention';
|
||||
import { Button } from '@/components/shared/Button';
|
||||
import { formatActionName } from '@/features/actions/actionPresentation';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { getVerifiedPatrolReceiptSummary, isVerifiedPatrolReceipt } from './patrolHomePresentation';
|
||||
|
||||
const RECEIPT_LIMIT = 6;
|
||||
|
||||
|
|
@ -12,17 +12,15 @@ const formatRecentWorkError = (error: unknown): string =>
|
|||
error instanceof Error ? error.message : 'Verified work could not be loaded.';
|
||||
|
||||
export function PatrolRecentWorkPanel() {
|
||||
const [items, setItems] = createSignal<AttentionItem[]>([]);
|
||||
const [items, setItems] = createSignal<PatrolWorkReceipt[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal('');
|
||||
const receipts = createMemo(() =>
|
||||
items().filter(isVerifiedPatrolReceipt).slice(0, RECEIPT_LIMIT),
|
||||
);
|
||||
const receipts = createMemo(() => items().slice(0, RECEIPT_LIMIT));
|
||||
|
||||
const load = async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
try {
|
||||
const response = await getPatrolAttention('resolved', 1, 50);
|
||||
const response = await getPatrolWorkReceipts(RECEIPT_LIMIT);
|
||||
setItems(response.data);
|
||||
setError('');
|
||||
} catch (cause) {
|
||||
|
|
@ -116,15 +114,15 @@ export function PatrolRecentWorkPanel() {
|
|||
Verified
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{formatRelativeTime(item.lastObservedAt, { compact: true })}
|
||||
{formatRelativeTime(item.verifiedAt, { compact: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-semibold text-base-content">{item.title}</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">
|
||||
{getVerifiedPatrolReceiptSummary(item)}
|
||||
<p class="mt-1 text-sm font-semibold text-base-content">
|
||||
{formatActionName(item.capabilityName)} verified
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">{item.verificationSummary}</p>
|
||||
<p class="mt-1 truncate text-xs font-medium text-base-content">
|
||||
{item.subjectResourceName}
|
||||
{item.resourceName}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,93 +1,55 @@
|
|||
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AttentionItem, AttentionListResponse } from '@/api/patrolAttention';
|
||||
import type { PatrolWorkReceipt, PatrolWorkReceiptListResponse } from '@/api/patrolAttention';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({ getList: vi.fn() }));
|
||||
const apiMocks = vi.hoisted(() => ({ getReceipts: vi.fn() }));
|
||||
|
||||
vi.mock('@/api/patrolAttention', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/api/patrolAttention')>();
|
||||
return {
|
||||
...original,
|
||||
getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args),
|
||||
getPatrolWorkReceipts: (...args: unknown[]) => apiMocks.getReceipts(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { PatrolRecentWorkPanel } from '../PatrolRecentWorkPanel';
|
||||
|
||||
const receipt = (overrides: Partial<AttentionItem> = {}): AttentionItem => ({
|
||||
id: 'receipt-1',
|
||||
operationalRecordId: 'record-1',
|
||||
subjectResourceId: 'docker:host/service/jellyfin',
|
||||
subjectResourceName: 'Jellyfin',
|
||||
kind: 'availability',
|
||||
title: 'Jellyfin playback restored',
|
||||
plainLanguageSummary: 'Playback recovered.',
|
||||
severity: 'warning',
|
||||
state: 'resolved',
|
||||
firstObservedAt: '2026-08-14T07:00:00Z',
|
||||
lastObservedAt: '2026-08-14T07:05:00Z',
|
||||
evidenceFreshness: 'fresh',
|
||||
evidenceCompleteness: 'complete',
|
||||
relatedResources: [],
|
||||
availableActions: [
|
||||
{
|
||||
targetResourceId: 'docker:host/service/jellyfin',
|
||||
capability: 'restart_service',
|
||||
kind: 'restart',
|
||||
label: 'Restart Jellyfin',
|
||||
mode: 'execute',
|
||||
risk: 'low',
|
||||
approval: 'not-required',
|
||||
eligibility: 'eligible',
|
||||
reasons: [],
|
||||
evidenceIds: ['evidence-1'],
|
||||
expectedPostcondition: 'Playback health checks return to normal.',
|
||||
verificationPolicy: 'availability',
|
||||
requiresApproval: false,
|
||||
},
|
||||
],
|
||||
verificationState: 'succeeded',
|
||||
const receipt = (overrides: Partial<PatrolWorkReceipt> = {}): PatrolWorkReceipt => ({
|
||||
actionId: 'action-1',
|
||||
resourceId: 'docker:host/service/jellyfin',
|
||||
resourceName: 'Jellyfin',
|
||||
capabilityName: 'restart',
|
||||
verifiedAt: '2026-08-14T07:05:00Z',
|
||||
verificationSummary: 'Playback recovered.',
|
||||
evidenceClass: 'agent_attested',
|
||||
originSurface: 'patrol',
|
||||
findingId: 'finding-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const response = (items: AttentionItem[]): AttentionListResponse => ({
|
||||
const response = (items: PatrolWorkReceipt[]): PatrolWorkReceiptListResponse => ({
|
||||
data: items,
|
||||
summary: {
|
||||
activeCount: 0,
|
||||
openCount: 0,
|
||||
acknowledgedCount: 0,
|
||||
suppressedCount: 0,
|
||||
uncertainCount: 0,
|
||||
resolvedCount: items.length,
|
||||
calm: true,
|
||||
coverageState: 'current',
|
||||
evaluatedAt: '2026-08-14T07:05:00Z',
|
||||
},
|
||||
meta: { page: 1, limit: 50, total: items.length, totalPages: items.length ? 1 : 0 },
|
||||
count: items.length,
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
describe('PatrolRecentWorkPanel', () => {
|
||||
beforeEach(() => apiMocks.getList.mockReset());
|
||||
beforeEach(() => apiMocks.getReceipts.mockReset());
|
||||
afterEach(cleanup);
|
||||
|
||||
it('shows only successfully verified resolved work as receipts', async () => {
|
||||
apiMocks.getList.mockResolvedValue(
|
||||
response([
|
||||
receipt(),
|
||||
receipt({ id: 'failed', verificationState: 'failed' }),
|
||||
receipt({ id: 'open', state: 'open' }),
|
||||
]),
|
||||
);
|
||||
it('shows server-authored verified Patrol work receipts', async () => {
|
||||
apiMocks.getReceipts.mockResolvedValue(response([receipt()]));
|
||||
|
||||
render(() => <PatrolRecentWorkPanel />);
|
||||
|
||||
expect(await screen.findByText('Playback recovered.')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Verified')).toHaveLength(1);
|
||||
expect(apiMocks.getList).toHaveBeenCalledWith('resolved', 1, 50);
|
||||
expect(screen.getByText('Restart verified')).toBeInTheDocument();
|
||||
expect(apiMocks.getReceipts).toHaveBeenCalledWith(6);
|
||||
});
|
||||
|
||||
it('does not imply successful work when there is no verified receipt', async () => {
|
||||
apiMocks.getList.mockResolvedValue(response([receipt({ verificationState: 'unknown' })]));
|
||||
apiMocks.getReceipts.mockResolvedValue(response([]));
|
||||
|
||||
render(() => <PatrolRecentWorkPanel />);
|
||||
|
||||
|
|
@ -98,8 +60,8 @@ describe('PatrolRecentWorkPanel', () => {
|
|||
});
|
||||
|
||||
it('keeps the last truthful receipts visible if a background refresh fails', async () => {
|
||||
apiMocks.getList.mockResolvedValueOnce(response([receipt()]));
|
||||
apiMocks.getList.mockRejectedValueOnce(new Error('relay unavailable'));
|
||||
apiMocks.getReceipts.mockResolvedValueOnce(response([receipt()]));
|
||||
apiMocks.getReceipts.mockRejectedValueOnce(new Error('relay unavailable'));
|
||||
|
||||
render(() => <PatrolRecentWorkPanel />);
|
||||
await screen.findByText('Playback recovered.');
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import type { AttentionItem } from '@/api/patrolAttention';
|
|||
import {
|
||||
getPatrolAttentionDecisionReason,
|
||||
getPatrolObjectiveProtectionSummary,
|
||||
getVerifiedPatrolReceiptSummary,
|
||||
isVerifiedPatrolReceipt,
|
||||
partitionPatrolAttention,
|
||||
} from '../patrolHomePresentation';
|
||||
|
||||
|
|
@ -146,18 +144,4 @@ describe('patrol home presentation', () => {
|
|||
tone: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates receipts only from resolved records with successful verification', () => {
|
||||
const resolved = attentionItem({
|
||||
state: 'resolved',
|
||||
verificationState: 'succeeded',
|
||||
plainLanguageSummary: 'Playback health checks returned to normal.',
|
||||
});
|
||||
|
||||
expect(isVerifiedPatrolReceipt(resolved)).toBe(true);
|
||||
expect(isVerifiedPatrolReceipt(attentionItem({ state: 'resolved' }))).toBe(false);
|
||||
expect(getVerifiedPatrolReceiptSummary(resolved)).toBe(
|
||||
'Playback health checks returned to normal.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -176,11 +176,3 @@ export function getPatrolObjectiveProtectionSummary(
|
|||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
export function isVerifiedPatrolReceipt(item: AttentionItem): boolean {
|
||||
return item.state === 'resolved' && item.verificationState === 'succeeded';
|
||||
}
|
||||
|
||||
export function getVerifiedPatrolReceiptSummary(item: AttentionItem): string {
|
||||
return item.plainLanguageSummary;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ func (h *AttentionHandlers) HandleAttention(w http.ResponseWriter, r *http.Reque
|
|||
h.handleAttentionList(w, r)
|
||||
case path == "/summary":
|
||||
h.handleAttentionSummary(w, r)
|
||||
case path == "/receipts":
|
||||
h.handleAttentionReceipts(w, r)
|
||||
case strings.Contains(path, "/evidence/"):
|
||||
h.handleAttentionEvidence(w, r, path)
|
||||
case strings.HasPrefix(path, "/"):
|
||||
|
|
|
|||
|
|
@ -13,11 +13,118 @@ import (
|
|||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestAttentionHandlersReceiptsUseVerifiedPatrolActionsBeforeLimit(t *testing.T) {
|
||||
now := time.Date(2026, 8, 15, 8, 0, 0, 0, time.UTC)
|
||||
resources := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
store, err := resources.getStore("default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = resources.CloseStores() })
|
||||
for _, record := range []unifiedresources.ActionAuditRecord{
|
||||
attentionReceiptTestRecord("patrol-old", patrolActionOriginSurface, now.Add(-time.Minute), true),
|
||||
attentionReceiptTestRecord("attention-new", operationalTrustActionOriginSurface, now, true),
|
||||
attentionReceiptTestRecord("assistant-newer", "assistant", now.Add(time.Minute), true),
|
||||
attentionReceiptTestRecord("patrol-unverified", patrolActionOriginSurface, now.Add(2*time.Minute), false),
|
||||
} {
|
||||
if err := store.RecordActionAudit(record); err != nil {
|
||||
t.Fatalf("RecordActionAudit(%s): %v", record.ID, err)
|
||||
}
|
||||
}
|
||||
handler := &AttentionHandlers{resources: resources}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/receipts?limit=6",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload patrolWorkReceiptListResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Count != 2 || payload.Limit != 6 || len(payload.Data) != 2 {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if payload.Data[0].ActionID != "attention-new" ||
|
||||
payload.Data[1].ActionID != "patrol-old" {
|
||||
t.Fatalf("receipt order = %#v", payload.Data)
|
||||
}
|
||||
for _, receipt := range payload.Data {
|
||||
if receipt.VerificationSummary == "" || receipt.EvidenceClass != unifiedresources.ActionEvidenceAgentAttested {
|
||||
t.Fatalf("receipt omitted verification proof: %#v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
limitedRequest := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/receipts?limit=1",
|
||||
nil,
|
||||
)
|
||||
limitedResponse := httptest.NewRecorder()
|
||||
handler.HandleAttention(limitedResponse, limitedRequest)
|
||||
var limited patrolWorkReceiptListResponse
|
||||
if err := json.Unmarshal(limitedResponse.Body.Bytes(), &limited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(limited.Data) != 1 || limited.Data[0].ActionID != "attention-new" {
|
||||
t.Fatalf("limited receipts = %#v", limited.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersReceiptsRejectInvalidLimit(t *testing.T) {
|
||||
handler := &AttentionHandlers{}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/receipts?limit=51",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func attentionReceiptTestRecord(
|
||||
id string,
|
||||
surface string,
|
||||
updatedAt time.Time,
|
||||
verified bool,
|
||||
) unifiedresources.ActionAuditRecord {
|
||||
verification := &unifiedresources.ActionVerificationResult{
|
||||
Ran: true, Success: verified, RanAt: updatedAt,
|
||||
}
|
||||
outcome := unifiedresources.VerificationOutcome{Status: unifiedresources.VerificationFailed}
|
||||
if verified {
|
||||
outcome = unifiedresources.VerificationOutcome{
|
||||
Status: unifiedresources.VerificationVerified,
|
||||
EvidenceSummary: "Service healthy after restart.",
|
||||
}
|
||||
}
|
||||
return unifiedresources.ActionAuditRecord{
|
||||
ID: id, CreatedAt: updatedAt.Add(-time.Second), UpdatedAt: updatedAt,
|
||||
State: unifiedresources.ActionStateCompleted,
|
||||
Request: unifiedresources.ActionRequest{
|
||||
RequestID: id + "-request", ResourceID: "app-container:test", CapabilityName: "restart", RequestedBy: "pulse_patrol",
|
||||
},
|
||||
Plan: unifiedresources.ActionPlan{ActionID: id, RequestID: id + "-request", Allowed: true},
|
||||
Origin: &unifiedresources.ActionOrigin{Surface: surface, FindingID: id + "-finding"},
|
||||
Result: &unifiedresources.ExecutionResult{Success: true, Verification: verification},
|
||||
VerificationOutcome: outcome,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersListUsesCanonicalCountAndFilters(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 6, 0, 0, 0, time.UTC)
|
||||
handler := &AttentionHandlers{
|
||||
|
|
|
|||
152
internal/api/attention_receipts.go
Normal file
152
internal/api/attention_receipts.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPatrolReceiptLimit = 6
|
||||
maxPatrolReceiptLimit = 50
|
||||
)
|
||||
|
||||
var patrolReceiptOriginSurfaces = []string{
|
||||
patrolActionOriginSurface,
|
||||
operationalTrustActionOriginSurface,
|
||||
}
|
||||
|
||||
type patrolWorkReceipt struct {
|
||||
ActionID string `json:"actionId"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
ResourceName string `json:"resourceName"`
|
||||
ResourceType unifiedresources.ResourceType `json:"resourceType,omitempty"`
|
||||
CapabilityName string `json:"capabilityName"`
|
||||
VerifiedAt time.Time `json:"verifiedAt"`
|
||||
VerificationSummary string `json:"verificationSummary"`
|
||||
EvidenceClass unifiedresources.ActionEvidenceClass `json:"evidenceClass"`
|
||||
OriginSurface string `json:"originSurface"`
|
||||
FindingID string `json:"findingId,omitempty"`
|
||||
OperationalRecordID string `json:"operationalRecordId,omitempty"`
|
||||
}
|
||||
|
||||
type patrolWorkReceiptListResponse struct {
|
||||
Data []patrolWorkReceipt `json:"data"`
|
||||
Count int `json:"count"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionReceipts(w http.ResponseWriter, r *http.Request) {
|
||||
limit, ok := parsePatrolReceiptLimit(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h == nil || h.resources == nil {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "patrol_receipts_unavailable", "Verified Patrol work is unavailable.", nil)
|
||||
return
|
||||
}
|
||||
orgID := GetOrgID(r.Context())
|
||||
store, err := h.resources.getStore(orgID)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "patrol_receipts_unavailable", "Verified Patrol work is unavailable.", nil)
|
||||
return
|
||||
}
|
||||
reader, ok := store.(unifiedresources.VerifiedActionAuditOriginReader)
|
||||
if !ok {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "patrol_receipts_unavailable", "Verified Patrol work is unavailable.", nil)
|
||||
return
|
||||
}
|
||||
records, err := reader.GetVerifiedActionAuditsByOrigins(patrolReceiptOriginSurfaces, limit)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "patrol_receipts_unavailable", "Verified Patrol work is unavailable.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
registry, _ := h.resources.buildRegistry(orgID)
|
||||
receipts := make([]patrolWorkReceipt, 0, len(records))
|
||||
for _, record := range records {
|
||||
if receipt, projected := projectPatrolWorkReceipt(record, registry); projected {
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
}
|
||||
response := patrolWorkReceiptListResponse{Data: receipts, Count: len(receipts), Limit: limit}
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize verified Patrol work receipts")
|
||||
}
|
||||
}
|
||||
|
||||
func parsePatrolReceiptLimit(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("limit"))
|
||||
if raw == "" {
|
||||
return defaultPatrolReceiptLimit, true
|
||||
}
|
||||
limit, err := strconv.Atoi(raw)
|
||||
if err != nil || limit < 1 || limit > maxPatrolReceiptLimit {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_patrol_receipt_limit",
|
||||
"Receipt limit must be between 1 and 50.",
|
||||
map[string]string{"limit": strconv.Itoa(maxPatrolReceiptLimit)},
|
||||
)
|
||||
return 0, false
|
||||
}
|
||||
return limit, true
|
||||
}
|
||||
|
||||
func projectPatrolWorkReceipt(
|
||||
record unifiedresources.ActionAuditRecord,
|
||||
registry *unifiedresources.ResourceRegistry,
|
||||
) (patrolWorkReceipt, bool) {
|
||||
if record.Origin == nil {
|
||||
return patrolWorkReceipt{}, false
|
||||
}
|
||||
originSurface := strings.TrimSpace(record.Origin.Surface)
|
||||
if originSurface != patrolActionOriginSurface &&
|
||||
originSurface != operationalTrustActionOriginSurface {
|
||||
return patrolWorkReceipt{}, false
|
||||
}
|
||||
truth := unifiedresources.CanonicalActionResultV2(record)
|
||||
if record.State != unifiedresources.ActionStateCompleted ||
|
||||
truth.Execution.Status != unifiedresources.ActionExecutionSucceeded ||
|
||||
truth.Verification.Status != unifiedresources.ActionVerificationConfirmed {
|
||||
return patrolWorkReceipt{}, false
|
||||
}
|
||||
resourceID := strings.TrimSpace(record.Request.ResourceID)
|
||||
resourceName := resourceID
|
||||
var resourceType unifiedresources.ResourceType
|
||||
if registry != nil {
|
||||
if resource, found := registry.Get(resourceID); found {
|
||||
resourceName = strings.TrimSpace(resource.Name)
|
||||
resourceType = resource.Type
|
||||
}
|
||||
}
|
||||
if resourceName == "" {
|
||||
resourceName = resourceID
|
||||
}
|
||||
summary := strings.TrimSpace(truth.Verification.Summary)
|
||||
if summary == "" {
|
||||
summary = strings.TrimSpace(record.VerificationOutcome.EvidenceSummary)
|
||||
}
|
||||
if summary == "" {
|
||||
summary = "Patrol confirmed the expected outcome after the change."
|
||||
}
|
||||
return patrolWorkReceipt{
|
||||
ActionID: record.ID,
|
||||
ResourceID: resourceID,
|
||||
ResourceName: resourceName,
|
||||
ResourceType: resourceType,
|
||||
CapabilityName: strings.TrimSpace(record.Request.CapabilityName),
|
||||
VerifiedAt: record.UpdatedAt.UTC(),
|
||||
VerificationSummary: summary,
|
||||
EvidenceClass: truth.Verification.EvidenceClass,
|
||||
OriginSurface: originSurface,
|
||||
FindingID: strings.TrimSpace(record.Origin.FindingID),
|
||||
OperationalRecordID: strings.TrimSpace(record.Origin.OperationalRecordID),
|
||||
}, true
|
||||
}
|
||||
|
|
@ -112,6 +112,15 @@ type OperationalActionAuditOriginBatchReader interface {
|
|||
GetLatestActionAuditsByOperationalRecords(surface string, operationalRecordIDs []string) (map[string]ActionAuditRecord, error)
|
||||
}
|
||||
|
||||
// VerifiedActionAuditOriginReader owns the bounded, newest-first projection of
|
||||
// actions whose postcondition was actually confirmed. Product surfaces such as
|
||||
// Patrol receipts must filter origin and verification before applying their
|
||||
// display limit; scanning a generic settled-action page can silently hide real
|
||||
// verified work behind unrelated history.
|
||||
type VerifiedActionAuditOriginReader interface {
|
||||
GetVerifiedActionAuditsByOrigins(surfaces []string, limit int) ([]ActionAuditRecord, error)
|
||||
}
|
||||
|
||||
// PendingActionAuditReader owns the indexed operator queue for canonical
|
||||
// actions awaiting a decision. Mobile and desktop clients must not reconstruct
|
||||
// this queue from the retired command-approval store.
|
||||
|
|
@ -734,6 +743,13 @@ func (s *SQLiteResourceStore) migrateActionAuditsSchema() error {
|
|||
`); err != nil {
|
||||
return fmt.Errorf("create action audit origin operational record index: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_action_audits_origin_verification_updated
|
||||
ON action_audits(json_extract(origin_json, '$.surface'), json_extract(verification_outcome_json, '$.status'), updated_at DESC, created_at DESC)
|
||||
WHERE json_valid(origin_json) AND json_valid(verification_outcome_json)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create verified action audit origin index: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_action_audits_state_updated
|
||||
ON action_audits(state, updated_at ASC, created_at ASC)
|
||||
|
|
@ -2432,6 +2448,58 @@ func (s *SQLiteResourceStore) GetLatestActionAuditsByOperationalRecords(
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) GetVerifiedActionAuditsByOrigins(
|
||||
surfaces []string,
|
||||
limit int,
|
||||
) ([]ActionAuditRecord, error) {
|
||||
surfaces = uniqueStrings(surfaces)
|
||||
if len(surfaces) == 0 {
|
||||
return []ActionAuditRecord{}, nil
|
||||
}
|
||||
if limit < 1 || limit > 50 {
|
||||
return nil, fmt.Errorf("verified action audit limit must be between 1 and 50")
|
||||
}
|
||||
placeholders := make([]string, len(surfaces))
|
||||
args := make([]any, 0, len(surfaces)+1)
|
||||
for index, surface := range surfaces {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, surface)
|
||||
}
|
||||
args = append(args, string(VerificationVerified), limit)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json
|
||||
FROM action_audits
|
||||
WHERE state = 'completed'
|
||||
AND json_valid(origin_json)
|
||||
AND json_extract(origin_json, '$.surface') IN (`+strings.Join(placeholders, ",")+`)
|
||||
AND json_valid(verification_outcome_json)
|
||||
AND json_extract(verification_outcome_json, '$.status') = ?
|
||||
ORDER BY updated_at DESC, created_at DESC, id ASC
|
||||
LIMIT ?
|
||||
`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query verified action audits by origin: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
records := make([]ActionAuditRecord, 0, limit)
|
||||
for rows.Next() {
|
||||
record, scanErr := scanActionAuditRecord(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan verified action audit by origin: %w", scanErr)
|
||||
}
|
||||
truth := CanonicalActionResultV2(record)
|
||||
if truth.Execution.Status != ActionExecutionSucceeded ||
|
||||
truth.Verification.Status != ActionVerificationConfirmed {
|
||||
continue
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate verified action audits by origin: %w", err)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
|
|
@ -3750,6 +3818,53 @@ func (m *MemoryStore) GetLatestActionAuditsByOperationalRecords(
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetVerifiedActionAuditsByOrigins(
|
||||
surfaces []string,
|
||||
limit int,
|
||||
) ([]ActionAuditRecord, error) {
|
||||
surfaces = uniqueStrings(surfaces)
|
||||
if len(surfaces) == 0 {
|
||||
return []ActionAuditRecord{}, nil
|
||||
}
|
||||
if limit < 1 || limit > 50 {
|
||||
return nil, fmt.Errorf("verified action audit limit must be between 1 and 50")
|
||||
}
|
||||
wanted := make(map[string]struct{}, len(surfaces))
|
||||
for _, surface := range surfaces {
|
||||
wanted[surface] = struct{}{}
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
records := make([]ActionAuditRecord, 0, limit)
|
||||
for _, record := range m.actionAudits {
|
||||
if record.State != ActionStateCompleted || record.Origin == nil {
|
||||
continue
|
||||
}
|
||||
if _, found := wanted[strings.TrimSpace(record.Origin.Surface)]; !found {
|
||||
continue
|
||||
}
|
||||
truth := CanonicalActionResultV2(record)
|
||||
if truth.Execution.Status != ActionExecutionSucceeded ||
|
||||
truth.Verification.Status != ActionVerificationConfirmed {
|
||||
continue
|
||||
}
|
||||
records = append(records, cloneActionAuditRecordForRead(record))
|
||||
}
|
||||
sort.SliceStable(records, func(i, j int) bool {
|
||||
if !records[i].UpdatedAt.Equal(records[j].UpdatedAt) {
|
||||
return records[i].UpdatedAt.After(records[j].UpdatedAt)
|
||||
}
|
||||
if !records[i].CreatedAt.Equal(records[j].CreatedAt) {
|
||||
return records[i].CreatedAt.After(records[j].CreatedAt)
|
||||
}
|
||||
return records[i].ID < records[j].ID
|
||||
})
|
||||
if len(records) > limit {
|
||||
records = records[:limit]
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
|
|
|||
|
|
@ -3145,6 +3145,10 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("%T does not implement OperationalActionAuditOriginBatchReader", store)
|
||||
}
|
||||
verifiedReader, ok := store.(VerifiedActionAuditOriginReader)
|
||||
if !ok {
|
||||
t.Fatalf("%T does not implement VerifiedActionAuditOriginReader", store)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, record := range []ActionAuditRecord{
|
||||
{
|
||||
|
|
@ -3166,6 +3170,25 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) {
|
|||
ProposalID: "prop-new", OperationalRecordID: "operational-1",
|
||||
EvidenceIDs: []string{"evidence-c"},
|
||||
},
|
||||
Result: &ExecutionResult{
|
||||
Success: true,
|
||||
Verification: &ActionVerificationResult{
|
||||
Ran: true, Success: true, RanAt: now,
|
||||
},
|
||||
},
|
||||
VerificationOutcome: VerificationOutcome{Status: VerificationVerified},
|
||||
},
|
||||
{
|
||||
ID: "act-unrelated", CreatedAt: now.Add(time.Second), UpdatedAt: now.Add(time.Second), State: ActionStateCompleted,
|
||||
Request: ActionRequest{RequestID: "prop-unrelated", ResourceID: "vm:99", CapabilityName: "restart", RequestedBy: "operator"},
|
||||
Plan: ActionPlan{ActionID: "act-unrelated", RequestID: "prop-unrelated", Allowed: true},
|
||||
Origin: &ActionOrigin{Surface: "assistant"},
|
||||
Result: &ExecutionResult{
|
||||
Success: true,
|
||||
Verification: &ActionVerificationResult{
|
||||
Ran: true, Success: true, RanAt: now.Add(time.Second),
|
||||
},
|
||||
},
|
||||
VerificationOutcome: VerificationOutcome{Status: VerificationVerified},
|
||||
},
|
||||
} {
|
||||
|
|
@ -3194,6 +3217,13 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) {
|
|||
if err != nil || len(batch) != 1 || batch["operational-1"].ID != "act-new" {
|
||||
t.Fatalf("GetLatestActionAuditsByOperationalRecords: batch=%#v err=%v", batch, err)
|
||||
}
|
||||
verified, err := verifiedReader.GetVerifiedActionAuditsByOrigins(
|
||||
[]string{"operational_trust_attention", "patrol"},
|
||||
6,
|
||||
)
|
||||
if err != nil || len(verified) != 1 || verified[0].ID != "act-new" {
|
||||
t.Fatalf("GetVerifiedActionAuditsByOrigins: records=%#v err=%v", verified, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue