Harden Patrol and Assistant action boundaries

This commit is contained in:
rcourtman
2026-05-12 12:06:27 +01:00
parent 5ac0484e06
commit 1726cf47b4
23 changed files with 1042 additions and 358 deletions
@@ -144,6 +144,14 @@ the API-owned action audit records `executing` before dispatch and the
terminal execution result afterward. Dry-run-only plans remain planning evidence
only; lifecycle surfaces must not present them as executable, dispatch them
through agent-local command paths, or bypass the API fail-closed execution gate.
Agent lifecycle consumers of `/api/agent/events` and
`/api/agent/resource-context/{id}` must also honor the shared API command
payload boundary: API tokens with monitoring/read scope receive
`commandRedacted:true` instead of raw approval, action, or verification command
text unless they also hold action execution scope. Lifecycle UI and agents may
use those redacted events as doorbells or status summaries, but they must fetch
governed detail through the approval/action surfaces and must not treat a
monitoring-readable event stream as command disclosure or execution authority.
The node setup modal boundary must keep guided setup and manual credential
submission separate. For new PVE/PBS setup, API Inventory and Host Telemetry
@@ -66,6 +66,15 @@ runtime cost control, and shared AI transport surfaces.
logged server-side or attached as redacted internal Patrol evidence where
governed, but they must not be returned through the browser provider-test
contract.
Patrol findings history transport must stay bounded when resolved findings
are included: `/api/ai/patrol/findings?include_resolved=1` defaults to a
200-finding limit and caps explicit limits at 500, and the frontend Patrol
client/store must send the same bounded history request once the Resolved or
All view has made expanded history sticky. Per-finding suppression creation
is similarly narrow by default: the browser helper must require a concrete
resource ID and category, while backend broad/wildcard suppression scopes
require an explicit `allow_broad_scope` request from a dedicated rule
management surface.
5. Add or change AI usage/cost dashboard presentation through `frontend-modern/src/components/AI/AICostDashboard.tsx` and `frontend-modern/src/utils/aiCostPresentation.ts`
6. Add or change AI provider, control-level, chat/session, or explore-state presentation through `frontend-modern/src/components/AI/Chat/`, `frontend-modern/src/utils/aiProviderPresentation.ts`, `frontend-modern/src/utils/aiProviderHealthPresentation.ts`, `frontend-modern/src/utils/aiControlLevelPresentation.ts`, `frontend-modern/src/utils/aiChatPresentation.ts`, `frontend-modern/src/utils/aiSessionDiffPresentation.ts`, and `frontend-modern/src/utils/aiExplorePresentation.ts`
7. Keep AI chat presentation helpers aligned through `frontend-modern/src/components/AI/Chat/` and the shared `frontend-modern/src/utils/textPresentation.ts`
@@ -1361,9 +1361,12 @@ notifications: `finding.created` when a new finding is raised
(suppressed when the finding was auto-dismissed by operator-state),
`approval.pending` when a remediation request enters
`StatusPending` and is waiting on operator decision (carries
`approvalId`, `resourceId`, target tuple, `command`, `riskLevel`,
`requestedBy`, `requestedAt`, `expiresAt` — full detail stays
behind `/api/approvals/{id}`, the event is a doorbell),
`approvalId`, `resourceId`, target tuple, `riskLevel`,
`requestedBy`, `requestedAt`, `expiresAt`, plus `command` only for
session callers or API tokens that also carry `ai:execute`; plain
`monitoring:read` tokens receive `commandRedacted:true` — full
detail stays behind `/api/approvals/{id}`, the event is a
doorbell),
`action.completed` when an action audit reaches a terminal state —
Completed, runtime-Failed, or refused-before-dispatch (refusals
carry the stable error-token prefix `plan_drift:` or
@@ -1373,6 +1376,8 @@ dispatches carry a `verification` block — the agent-stable
projection of the broker's read-after-write probe — with `ran`,
`success`, `command`, `note`, `ranAt` so agents close the
"did it actually work?" loop without a follow-up audit fetch;
raw action and verification commands follow the same `ai:execute`
redaction rule on the stream;
refused dispatches omit `verification` because the probe never
runs) — and `heartbeat` every 15 seconds so an idle connection
can confirm the stream is alive. Each event carries a monotonic ID so agents
@@ -1550,13 +1555,17 @@ scoped to this resource as the `AgentResourceApprovalSummary`
projection (id, command, riskLevel, requestedBy, requestedAt,
expiresAt — same vocabulary as `approval.pending` SSE events so
"what's pending right now" and "what just became pending" agree
on shape), and recent action audits including refused dispatches
on shape, with `commandRedacted:true` replacing raw command text
for plain `monitoring:read` API tokens), and recent action audits
including refused dispatches
with their stable token prefixes (`resource_remediation_locked:`,
`plan_drift:`) preserved verbatim and the same agent-stable
`verification` block the SSE `action.completed` payload carries
(shared `AgentResourceActionVerification` projection, shared
`projectAgentResourceVerification` helper) so the bundle's depth
view and the doorbell speak the same vocabulary on probe outcomes.
view and the doorbell speak the same vocabulary on probe outcomes;
action and verification command text follows the same `ai:execute`
redaction rule.
The shape is intentionally narrower than the full internal types
so agents see a stable agent-paradigm contract, decoupled from
internal type evolution.
@@ -292,8 +292,22 @@ Patrol-specific presentation helpers.
reassuringly emphasized, medium is neutral, low is a soft amber.
Findings without an investigation record (or without a recorded
confidence) must show no confidence badge rather than defaulting to
one, mirroring the impact rule that absent metadata is not fabricated. The
TS API client mirrors (`UnifiedFindingRecord.impact`,
one, mirroring the impact rule that absent metadata is not fabricated.
Expanded Patrol finding cards must keep action density product-grade: one
primary Assistant intent button is visible inline (`Investigate`, `Verify
fix`, or `Explain` based on current finding state), while secondary
Assistant intents and operator management controls live behind compact
in-flow `Assistant` and `Manage` menus. Those menus must render inside the
expanded finding detail rather than as clipped floating panels, and the
per-finding "Create rule from this" action must remain disabled unless the
finding has both a concrete resource and category for a scoped suppression
rule.
The Patrol store owns the sticky expanded-history state for Resolved/All
views and must keep that data bounded: once resolved findings are requested,
subsequent Patrol finding loads continue to request include-resolved history
with the 200-item history limit instead of unbounded historical payloads or
a transient active-only refresh.
The TS API client mirrors (`UnifiedFindingRecord.impact`,
`Finding.impact`) and the store normalizers
(`normalizeUnifiedFindingRecord`, `normalizePatrolFindingRecord`) must
carry impact through alongside description and recommendation rather
@@ -236,6 +236,13 @@ bypass the API fail-closed execution gate.
`internal/api/router.go` Finding to UnifiedFinding conversion: storage and
recovery surfaces may render them as adjacent finding context but must not
reinterpret them as backup, restore, or storage remediation authority.
Shared agent event and resource-context transport follows the same adjacent
context boundary: monitoring/read API tokens receive redacted approval,
action, and verification command payloads (`commandRedacted:true`) unless
they also hold action execution scope. Storage and recovery consumers may
display those redacted records as status or evidence, but must not derive
backup, restore, storage remediation, or execution authority from the event
stream or resource-context bundle.
The `previous_resolved_fix_summary` operational-memory field carried on
findings across regressions follows the same scope: storage and recovery
surfaces may render it as adjacent finding context but must not
@@ -11,6 +11,7 @@ import {
getPatrolRunHistory,
getPatrolRunHistoryWithToolCalls,
getPatrolRunWithToolCalls,
createSuppressionRuleFromFinding,
resolveFinding,
type Finding as PatrolFinding,
} from '@/api/patrol';
@@ -224,6 +225,23 @@ describe('patrol api', () => {
});
});
it('bounds include-resolved Patrol finding queries', async () => {
await getPatrolFindings({ includeResolved: true });
expect(apiFetchJSONMock).toHaveBeenCalledWith(
'/api/ai/patrol/findings?include_resolved=1&limit=200',
);
await getPatrolFindings({ includeResolved: true, limit: 25.9 });
expect(apiFetchJSONMock).toHaveBeenCalledWith(
'/api/ai/patrol/findings?include_resolved=1&limit=25',
);
await getPatrolFindings({ includeResolved: true, limit: 9999 });
expect(apiFetchJSONMock).toHaveBeenCalledWith(
'/api/ai/patrol/findings?include_resolved=1&limit=500',
);
});
it('normalizes single patrol run payloads', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
id: 'run-2',
@@ -328,6 +346,53 @@ describe('patrol api', () => {
});
});
it('refuses to create broad suppression rules from a finding shortcut', async () => {
await expect(
createSuppressionRuleFromFinding({
resourceId: '',
resourceName: 'Any resource',
category: 'capacity',
description: 'Known pattern',
}),
).rejects.toThrow('resource and category');
expect(apiFetchJSONMock).not.toHaveBeenCalled();
await expect(
createSuppressionRuleFromFinding({
resourceId: 'resource-1',
resourceName: 'resource-1',
category: '',
description: 'Known pattern',
}),
).rejects.toThrow('resource and category');
expect(apiFetchJSONMock).not.toHaveBeenCalled();
});
it('trims scoped suppression rules created from findings', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
success: true,
message: 'ok',
rule: { id: 'rule-1' },
} as any);
await createSuppressionRuleFromFinding({
resourceId: ' resource-1 ',
resourceName: ' node-1 ',
category: ' backup ',
description: ' Known backup exception ',
});
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/patrol/suppressions', {
method: 'POST',
body: JSON.stringify({
resource_id: 'resource-1',
resource_name: 'node-1',
category: 'backup',
description: 'Known backup exception',
}),
});
});
it('round-trips remind_at on dismissed-as-will_fix_later patrol findings', async () => {
// The backend treats will_fix_later as an operator commitment with a
// wake-up deadline (Finding.RemindAt, default 7 days). The TS API client
+49 -13
View File
@@ -236,12 +236,36 @@ export async function getPatrolStatus(): Promise<PatrolStatus> {
return apiFetchJSON<PatrolStatus>('/api/ai/patrol/status');
}
export async function getPatrolFindings(
options?: { includeResolved?: boolean },
): Promise<Finding[]> {
const path = options?.includeResolved
? '/api/ai/patrol/findings?include_resolved=1'
: '/api/ai/patrol/findings';
const DEFAULT_PATROL_FINDINGS_HISTORY_LIMIT = 200;
const MAX_PATROL_FINDINGS_HISTORY_LIMIT = 500;
function normalizePatrolFindingsLimit(limit: number | undefined): number {
if (limit === undefined || !Number.isFinite(limit)) {
return DEFAULT_PATROL_FINDINGS_HISTORY_LIMIT;
}
const normalized = Math.floor(limit);
if (normalized < 1) {
return DEFAULT_PATROL_FINDINGS_HISTORY_LIMIT;
}
if (normalized > MAX_PATROL_FINDINGS_HISTORY_LIMIT) {
return MAX_PATROL_FINDINGS_HISTORY_LIMIT;
}
return normalized;
}
export async function getPatrolFindings(options?: {
includeResolved?: boolean;
limit?: number;
}): Promise<Finding[]> {
const search = new URLSearchParams();
if (options?.includeResolved) {
search.set('include_resolved', '1');
search.set('limit', String(normalizePatrolFindingsLimit(options.limit)));
} else if (options?.limit !== undefined) {
search.set('limit', String(normalizePatrolFindingsLimit(options.limit)));
}
const query = search.toString();
const path = query ? `/api/ai/patrol/findings?${query}` : '/api/ai/patrol/findings';
const findings = await apiFetchJSON<Finding[]>(path);
return arrayOrEmpty<Finding>(findings).map((finding) =>
promoteLegacyAlertIdentifier(finding as Finding & { alert_identifier?: string }),
@@ -326,9 +350,10 @@ export async function dismissFinding(
*
* The description is required by the backend it's the operator's
* stated reason, surfaced when the rule is later listed or audited.
* Pass an empty resourceId or category to broaden the rule's scope
* (e.g. all categories on this resource, or this category on any
* resource).
* This per-finding helper is intentionally narrow: it refuses empty
* resource/category values so a noisy finding cannot accidentally create
* a wildcard suppression rule. Broader rules must come from an explicit
* rule-management surface.
*/
export async function createSuppressionRuleFromFinding(input: {
resourceId: string;
@@ -336,13 +361,24 @@ export async function createSuppressionRuleFromFinding(input: {
category: string;
description: string;
}): Promise<{ success: boolean; message: string; rule: { id: string } }> {
const resourceId = input.resourceId.trim();
const resourceName = input.resourceName.trim() || resourceId;
const category = input.category.trim();
const description = input.description.trim();
if (!resourceId || !category) {
throw new Error('Suppression rules created from a finding require a resource and category');
}
if (!description) {
throw new Error('Suppression rules require a description');
}
return apiFetchJSON('/api/ai/patrol/suppressions', {
method: 'POST',
body: JSON.stringify({
resource_id: input.resourceId,
resource_name: input.resourceName,
category: input.category,
description: input.description,
resource_id: resourceId,
resource_name: resourceName,
category,
description,
}),
});
}
@@ -103,6 +103,45 @@ interface FindingsPanelProps {
>;
}
type FindingAssistantIntent = 'discuss' | 'explain' | 'investigate' | 'why' | 'verify_fix';
function getPrimaryAssistantFindingAction(finding: UnifiedFinding): {
intent: FindingAssistantIntent;
label: string;
title: string;
} {
if (findingHasAppliedFix(finding) && finding.status === 'active') {
return {
intent: 'verify_fix',
label: 'Verify fix',
title: 'Ask Pulse Assistant to verify whether the applied fix cleared the condition',
};
}
if (finding.status === 'active') {
return {
intent: 'investigate',
label: 'Investigate',
title: 'Ask Pulse Assistant to investigate this finding with the attached evidence',
};
}
return {
intent: 'explain',
label: 'Explain',
title: 'Ask Pulse Assistant to explain what happened and why it matters',
};
}
function getFindingSuppressionRuleScope(finding: UnifiedFinding) {
const resourceId = finding.resourceId.trim();
const category = finding.category.trim();
return {
resourceId,
resourceName: finding.resourceName.trim() || resourceId || 'this resource',
category,
canCreate: Boolean(resourceId && category),
};
}
export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
const location = useLocation();
const { get: getResource } = useResources();
@@ -534,7 +573,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
const openFindingInAssistant = async (
finding: UnifiedFinding,
intent: 'discuss' | 'explain' | 'investigate' | 'why' | 'verify_fix',
intent: FindingAssistantIntent,
) => {
await aiIntelligenceStore.loadPendingApprovals();
const subject = getFindingSubjectPresentation(finding).label;
@@ -649,6 +688,13 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
const handleStartCreateRule = (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
const scope = getFindingSuppressionRuleScope(finding);
if (!scope.canCreate) {
notificationStore.error(
'This finding is missing the resource or category needed for a scoped rule',
);
return;
}
setCreatingRuleForId(finding.id);
setCreateRuleDescription(finding.userNote || '');
setExpandedId(finding.id);
@@ -666,16 +712,23 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
notificationStore.error('A reason for the rule is required');
return;
}
const scope = getFindingSuppressionRuleScope(finding);
if (!scope.canCreate) {
notificationStore.error(
'This finding is missing the resource or category needed for a scoped rule',
);
return;
}
setActionLoading(finding.id);
try {
await createSuppressionRuleFromFinding({
resourceId: finding.resourceId,
resourceName: finding.resourceName,
category: finding.category || '',
resourceId: scope.resourceId,
resourceName: scope.resourceName,
category: scope.category,
description,
});
notificationStore.success(
`Rule created: future ${finding.category || 'matching'} findings on ${finding.resourceName} will auto-dismiss`,
`Rule created: future ${scope.category} findings on ${scope.resourceName} will auto-dismiss`,
);
setCreatingRuleForId(null);
// Refresh so the operator sees the finding update (typically the
@@ -967,11 +1020,15 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
class="ml-2 text-blue-600 dark:text-blue-400"
title="Auto-suppressed by Pulse based on operator-set state for this resource."
>
{' · '}auto: {formatOperatorStateDismissCauseLabel(getOperatorStateDismissCause(finding))}
{' · '}auto:{' '}
{formatOperatorStateDismissCauseLabel(getOperatorStateDismissCause(finding))}
</span>
</Show>
<Show when={finding.dismissedReason === 'will_fix_later' && finding.remindAt}>
<span class="ml-2 text-amber-600 dark:text-amber-400" title="Pulse will surface this finding again on this date if it is still tripping.">
<span
class="ml-2 text-amber-600 dark:text-amber-400"
title="Pulse will surface this finding again on this date if it is still tripping."
>
{' · '}Reminding {formatTime(finding.remindAt!)}
</span>
</Show>
@@ -1259,243 +1316,192 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</div>
</Show>
{/* Action area, grouped by intent. Each cluster sits in its own
flex container so the eye reads them as semantically distinct:
- Ask Pulse Assistant: action-style AI handoffs (Explain,
Investigate, Why, optional Verify fix) plus the
open-ended Discuss entry.
- Operator memory + share: Add Note and Copy summary.
The two clusters are separated by a faint vertical divider
(border-l) so the row reads as two intents rather than
ten flat buttons. */}
<div class="mt-3 flex flex-wrap items-start gap-x-4 gap-y-2 text-xs">
<div class="flex flex-wrap gap-1.5">
<button
type="button"
onClick={(e) => handleExplainFinding(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover flex items-center gap-1"
title="Ask Pulse Assistant to explain what we know about this finding using the attached evidence"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Explain
</button>
<Show when={finding.status === 'active'}>
<button
type="button"
onClick={(e) => handleInvestigateFinding(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover flex items-center gap-1"
title="Ask Pulse Assistant to actively investigate using metrics, alerts, and state — not just summarize"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
Investigate
</button>
</Show>
<button
type="button"
onClick={(e) => handleWhyFinding(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover flex items-center gap-1"
title="Ask Pulse Assistant to explain the cause using recent changes, correlations, and incident history"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Why
</button>
<Show when={findingHasAppliedFix(finding) && finding.status === 'active'}>
<button
type="button"
onClick={(e) => handleVerifyFixFinding(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover flex items-center gap-1"
title="Ask Pulse Assistant to verify whether the applied fix actually resolved the underlying condition"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
Verify fix
</button>
</Show>
<button
type="button"
onClick={(e) => handleDiscussWithAssistant(finding, e)}
class="px-2 py-1 rounded bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900 flex items-center gap-1 transition-colors"
title="Open Pulse Assistant with this finding's context, no auto-prompt — drive the conversation yourself"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
Discuss
</button>
</div>
<div class="flex flex-wrap gap-1.5 sm:border-l sm:border-border-subtle sm:pl-4">
<Show when={editingNoteId() !== finding.id && !finding.userNote}>
<button
type="button"
onClick={(e) => handleStartEditNote(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover flex items-center gap-1"
title="Add an operator note that Patrol will see on future runs of this finding"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
Add Note
</button>
</Show>
<button
type="button"
onClick={(e) => handleCopyFindingSummary(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover flex items-center gap-1"
title="Copy a Markdown summary of this finding for sharing with a teammate"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
Copy summary
</button>
</div>
</div>
<div
class="mt-3 flex flex-wrap items-start gap-2 text-xs"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={(e) => {
const action = getPrimaryAssistantFindingAction(finding);
e.stopPropagation();
void openFindingInAssistant(finding, action.intent);
}}
class="inline-flex items-center gap-1 rounded bg-blue-600 px-3 py-1.5 font-semibold text-white transition-colors hover:bg-blue-700"
title={getPrimaryAssistantFindingAction(finding).title}
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{getPrimaryAssistantFindingAction(finding).label}
</button>
<Show when={finding.status === 'active'}>
{/* Lifecycle decisions, grouped by consequence:
- Decide: Acknowledge (mark aware), Mark resolved (close out)
- Delay: Snooze 1h / 24h / 7d (re-surface later)
- Dismiss/promote: Not an issue / Remember as expected /
Later / Create rule (permanent pattern)
Visual dividers (border-l) separate the clusters so the
operator reads three distinct intents rather than one
undifferentiated row of 9. */}
<div class="mt-3 flex flex-wrap items-start gap-x-4 gap-y-2 text-xs">
<Show when={manualControls.acknowledge}>
<div class="flex flex-wrap gap-1.5">
<Show when={!finding.acknowledgedAt}>
<details onClick={(e) => e.stopPropagation()}>
<summary class="list-none cursor-pointer rounded border border-border bg-surface px-3 py-1.5 font-medium text-base-content hover:bg-surface-hover">
Assistant
</summary>
<div class="mt-1 flex min-w-40 flex-col gap-1 rounded border border-border bg-surface p-1 shadow-sm">
<button
type="button"
onClick={(e) => handleExplainFinding(finding, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
>
Explain
</button>
<Show when={finding.status === 'active'}>
<button
type="button"
onClick={(e) => handleInvestigateFinding(finding, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
>
Investigate
</button>
</Show>
<button
type="button"
onClick={(e) => handleWhyFinding(finding, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
>
Why
</button>
<Show when={findingHasAppliedFix(finding) && finding.status === 'active'}>
<button
type="button"
onClick={(e) => handleVerifyFixFinding(finding, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
>
Verify fix
</button>
</Show>
<button
type="button"
onClick={(e) => handleDiscussWithAssistant(finding, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
>
Discuss
</button>
</div>
</details>
<details onClick={(e) => e.stopPropagation()}>
<summary class="list-none cursor-pointer rounded border border-border bg-surface px-3 py-1.5 font-medium text-base-content hover:bg-surface-hover">
Manage
</summary>
<div class="mt-1 flex min-w-48 flex-col gap-1 rounded border border-border bg-surface p-1 shadow-sm">
<Show when={editingNoteId() !== finding.id && !finding.userNote}>
<button
type="button"
onClick={(e) => handleStartEditNote(finding, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
>
Add Note
</button>
</Show>
<button
type="button"
onClick={(e) => handleCopyFindingSummary(finding, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
>
Copy summary
</button>
<Show when={finding.status === 'active'}>
<Show when={manualControls.acknowledge && !finding.acknowledgedAt}>
<button
type="button"
onClick={(e) => handleAcknowledge(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover"
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
disabled={actionLoading() === finding.id}
>
Acknowledge
</button>
</Show>
<Show when={manualControls.acknowledge && finding.status === 'active'}>
<Show when={manualControls.acknowledge}>
<button
type="button"
onClick={(e) => handleResolve(finding, e)}
class="px-2 py-1 rounded border border-emerald-200 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-300 dark:hover:bg-emerald-900"
title="Mark this finding as resolved. Use when you have fixed the issue out-of-band."
class="rounded px-2 py-1 text-left text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 dark:text-emerald-300 dark:hover:bg-emerald-900"
disabled={actionLoading() === finding.id}
>
Mark resolved
</button>
</Show>
</div>
</Show>
<Show when={manualControls.snooze}>
<div class="flex flex-wrap gap-1.5 sm:border-l sm:border-border-subtle sm:pl-4">
<button
type="button"
onClick={(e) => handleSnooze(finding, 1, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover"
disabled={actionLoading() === finding.id}
>
Snooze 1h
</button>
<button
type="button"
onClick={(e) => handleSnooze(finding, 24, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover"
disabled={actionLoading() === finding.id}
>
Snooze 24h
</button>
<button
type="button"
onClick={(e) => handleSnooze(finding, 168, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover"
disabled={actionLoading() === finding.id}
>
Snooze 7d
</button>
</div>
</Show>
<Show when={manualControls.dismiss}>
<div class="flex flex-wrap gap-1.5 sm:border-l sm:border-border-subtle sm:pl-4">
<button
type="button"
onClick={(e) => handleStartDismiss(finding, 'not_an_issue', e)}
class="px-2 py-1 rounded border border-border text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900"
disabled={actionLoading() === finding.id}
>
Dismiss: Not an issue
</button>
<button
type="button"
onClick={(e) => handleStartDismiss(finding, 'expected_behavior', e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover"
disabled={actionLoading() === finding.id}
title="Tell Pulse this finding represents an expected state for this resource — it will stay quiet about it but surface again on severity escalation"
>
Remember as expected
</button>
<button
type="button"
onClick={(e) => handleStartDismiss(finding, 'will_fix_later', e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover"
disabled={actionLoading() === finding.id}
>
Dismiss: Later
</button>
<button
type="button"
onClick={(e) => handleStartCreateRule(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover"
disabled={actionLoading() === finding.id}
title="Promote this dismissal into a permanent rule — future findings on this resource for this category will auto-dismiss without surfacing"
>
Create rule from this
</button>
</div>
</Show>
</div>
</Show>
<Show when={manualControls.snooze}>
<button
type="button"
onClick={(e) => handleSnooze(finding, 1, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
disabled={actionLoading() === finding.id}
>
Snooze 1h
</button>
<button
type="button"
onClick={(e) => handleSnooze(finding, 24, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
disabled={actionLoading() === finding.id}
>
Snooze 24h
</button>
<button
type="button"
onClick={(e) => handleSnooze(finding, 168, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
disabled={actionLoading() === finding.id}
>
Snooze 7d
</button>
</Show>
<Show when={manualControls.dismiss}>
<button
type="button"
onClick={(e) => handleStartDismiss(finding, 'not_an_issue', e)}
class="rounded px-2 py-1 text-left text-red-600 hover:bg-red-50 disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-900"
disabled={actionLoading() === finding.id}
>
Dismiss: Not an issue
</button>
<button
type="button"
onClick={(e) => handleStartDismiss(finding, 'expected_behavior', e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
disabled={actionLoading() === finding.id}
>
Remember as expected
</button>
<button
type="button"
onClick={(e) => handleStartDismiss(finding, 'will_fix_later', e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
disabled={actionLoading() === finding.id}
>
Dismiss: Later
</button>
<button
type="button"
onClick={(e) => handleStartCreateRule(finding, e)}
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
disabled={
actionLoading() === finding.id ||
!getFindingSuppressionRuleScope(finding).canCreate
}
title={
getFindingSuppressionRuleScope(finding).canCreate
? 'Promote this dismissal into a permanent rule for this resource and category'
: 'This finding is missing the resource or category needed for a scoped rule'
}
>
Create rule from this
</button>
</Show>
</Show>
</div>
</details>
</div>
{/* Inline create-rule confirmation. Confirms scope (resource +
category) and requires a reason so the persisted rule has
audit context. Mirrors the dismiss-confirmation panel
@@ -1506,17 +1512,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
<div class="flex items-center gap-2 mb-1.5">
<span class="text-xs font-medium text-base-content">
Create suppression rule for{' '}
<span class="font-semibold">{finding.resourceName}</span>
<Show when={finding.category}>
{' '}({finding.category})
</Show>
<span class="font-semibold">
{getFindingSuppressionRuleScope(finding).resourceName}
</span>{' '}
({getFindingSuppressionRuleScope(finding).category})
</span>
</div>
<p class="text-[11px] text-muted mb-1.5">
Future findings matching this resource and category will be
auto-dismissed by Patrol without surfacing as new findings.
You can list or remove rules later from the suppressions
management surface.
Future findings matching this resource and category will be auto-dismissed by Patrol
without surfacing as new findings. You can list or remove rules later from the
suppressions management surface.
</p>
<textarea
class="w-full text-xs px-2 py-1.5 rounded border border-border bg-surface text-base-content resize-none focus:outline-none focus:ring-1 focus:ring-blue-400"
@@ -1561,21 +1566,21 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
<Show when={dismissReason() === 'will_fix_later'}>
<p class="text-[11px] text-amber-700 dark:text-amber-300 mb-1.5">
Pulse will stay quiet on this for 7 days, then surface it again on{' '}
<span class="font-semibold">{formatWillFixLaterRemindDate()}</span>{' '}
if it is still happening.
<span class="font-semibold">{formatWillFixLaterRemindDate()}</span> if it is still
happening.
</p>
</Show>
<Show when={dismissReason() === 'expected_behavior'}>
<p class="text-[11px] text-muted mb-1.5">
Pulse will keep this finding visible as acknowledged and remember
that this state is expected on this resource. It won't re-notify
you for it, but severity escalation will still wake it.
Pulse will keep this finding visible as acknowledged and remember that this state is
expected on this resource. It won't re-notify you for it, but severity escalation
will still wake it.
</p>
</Show>
<Show when={dismissReason() === 'not_an_issue'}>
<p class="text-[11px] text-muted mb-1.5">
Pulse will permanently suppress this and similar findings on this resource.
Use "Expected" or "Later" if the detection itself is correct.
Pulse will permanently suppress this and similar findings on this resource. Use
"Expected" or "Later" if the detection itself is correct.
</p>
</Show>
{/* Recurrence hint: if a finding has regressed multiple times, the
@@ -1586,14 +1591,13 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
<Show
when={
(finding.regressionCount || 0) > 1 &&
(dismissReason() === 'not_an_issue' ||
dismissReason() === 'expected_behavior')
(dismissReason() === 'not_an_issue' || dismissReason() === 'expected_behavior')
}
>
<p class="text-[11px] text-amber-700 dark:text-amber-300 mb-1.5 italic">
Heads up: this finding has regressed {finding.regressionCount} times
before. If you intend to fix it eventually, "Later" sets a
7-day reminder instead of going silent permanently.
Heads up: this finding has regressed {finding.regressionCount} times before. If you
intend to fix it eventually, "Later" sets a 7-day reminder instead of going silent
permanently.
</p>
</Show>
<textarea
@@ -144,6 +144,14 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toMatch(/>\s*Copy summary\s*</);
});
it('keeps expanded finding actions behind a primary action and compact menus', () => {
expect(findingsPanelSource).toContain('getPrimaryAssistantFindingAction');
expect(findingsPanelSource).toContain('Assistant');
expect(findingsPanelSource).toContain('Manage');
expect(findingsPanelSource).toContain('min-w-40');
expect(findingsPanelSource).toContain('min-w-48');
});
it('exposes a manual Mark resolved action that goes through the patrol resolve store action', () => {
// The /api/ai/patrol/resolve endpoint exists server-side but had no
// operator path on the canonical Patrol surface. An operator fixing
@@ -155,10 +163,24 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain('handleResolve');
expect(findingsPanelSource).toContain('aiIntelligenceStore.resolveFinding(finding.id)');
expect(findingsPanelSource).toContain('Mark resolved');
expect(findingsPanelSource).toContain("manualControls.acknowledge && finding.status === 'active'");
expect(findingsPanelSource).toContain("finding.status === 'active'");
expect(findingsPanelSource).toContain('Show when={manualControls.acknowledge}');
expect(findingsPanelSource).toContain('Finding marked resolved');
});
it('fails closed before creating a per-finding suppression rule without concrete scope', () => {
// Empty resource/category fields are backend wildcards. The per-finding
// shortcut must not turn missing metadata into a broad suppression rule.
expect(findingsPanelSource).toContain('getFindingSuppressionRuleScope');
expect(findingsPanelSource).toContain('canCreate: Boolean(resourceId && category)');
expect(findingsPanelSource).toContain('!scope.canCreate');
expect(findingsPanelSource).toContain('resourceId: scope.resourceId');
expect(findingsPanelSource).toContain('category: scope.category');
expect(findingsPanelSource).toContain(
'missing the resource or category needed for a scoped rule',
);
});
it('surfaces regressionCount as a pill on the collapsed finding row', () => {
// regressionCount is the strongest "this is not a one-off" signal Pulse
// can give an operator scanning a list. The pill must appear in the
@@ -192,7 +214,7 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain('"Later" sets a');
});
it("badges dismissed-as-will_fix_later rows with their pending remind-at deadline", () => {
it('badges dismissed-as-will_fix_later rows with their pending remind-at deadline', () => {
// Once a finding is dismissed as will_fix_later, the row must surface the
// pending reminder so the operator knows the commitment exists; otherwise
// the only place the deadline lives is the lifecycle log. The amber tone
@@ -1120,7 +1142,11 @@ describe('aiFindingPresentation', () => {
expect(
getOperatorStateDismissCause({
lifecycle: [
{ at: '2026-04-01T00:00:00Z', type: 'dismissed', metadata: { reason: 'expected_behavior' } },
{
at: '2026-04-01T00:00:00Z',
type: 'dismissed',
metadata: { reason: 'expected_behavior' },
},
{ at: '2026-04-02T00:00:00Z', type: 'undismissed' },
{
at: '2026-04-03T00:00:00Z',
@@ -1146,7 +1172,11 @@ describe('aiFindingPresentation', () => {
metadata: { operator_state_cause: 'maintenance_window' },
},
{ at: '2026-04-02T00:00:00Z', type: 'undismissed' },
{ at: '2026-04-03T00:00:00Z', type: 'dismissed', metadata: { reason: 'expected_behavior' } },
{
at: '2026-04-03T00:00:00Z',
type: 'dismissed',
metadata: { reason: 'expected_behavior' },
},
],
} as never),
).toBe('');
@@ -1178,14 +1208,17 @@ describe('aiFindingPresentation', () => {
// helper, not by re-implementing the lifecycle scan inline.
expect(findingsPanelSource).toContain('getOperatorStateDismissCause(finding)');
expect(findingsPanelSource).toContain('formatOperatorStateDismissCauseLabel(');
expect(findingsPanelSource).toMatch(/auto: \{formatOperatorStateDismissCauseLabel/);
expect(findingsPanelSource).toContain('auto:');
expect(findingsPanelSource).toContain(
'formatOperatorStateDismissCauseLabel(getOperatorStateDismissCause(finding))',
);
// The badge sits next to the existing dismissed-reason badge in
// source order so the two pieces of information read together
// (the reason and the attribution).
const dismissedReasonIndex = findingsPanelSource.indexOf(
'({formatIdentifierLabel(finding.dismissedReason)})',
);
const autoBadgeIndex = findingsPanelSource.indexOf('auto: {formatOperatorStateDismissCauseLabel');
const autoBadgeIndex = findingsPanelSource.indexOf('auto:');
expect(dismissedReasonIndex).toBeGreaterThan(0);
expect(autoBadgeIndex).toBeGreaterThan(0);
expect(autoBadgeIndex).toBeGreaterThan(dismissedReasonIndex);
@@ -824,4 +824,20 @@ describe('aiIntelligenceStore', () => {
'finding-later',
]);
});
it('keeps resolved Patrol history bounded when the expanded findings set is sticky', async () => {
vi.mocked(getPatrolFindings).mockResolvedValue([]);
await aiIntelligenceStore.loadPatrolFindings({ includeResolved: true });
await aiIntelligenceStore.loadPatrolFindings();
expect(getPatrolFindings).toHaveBeenNthCalledWith(1, {
includeResolved: true,
limit: 200,
});
expect(getPatrolFindings).toHaveBeenNthCalledWith(2, {
includeResolved: true,
limit: 200,
});
});
});
+4 -1
View File
@@ -209,6 +209,7 @@ const [patrolFindingsError, setPatrolFindingsError] = createSignal<string | null
// expanded set (no current call site does this; FindingsPanel's effect
// would set it back to true the moment Resolved/All becomes active).
let patrolFindingsIncludeResolvedPreference = false;
const PATROL_FINDINGS_HISTORY_LIMIT = 200;
function normalizeUnifiedFindingRecord(item: UnifiedFindingRecord, now: number): UnifiedFinding {
const alertIdentifier = item.alertIdentifier;
@@ -466,7 +467,9 @@ export const aiIntelligenceStore = {
setPatrolFindingsError(null);
try {
const findings = await getPatrolFindings(
includeResolved ? { includeResolved: true } : undefined,
includeResolved
? { includeResolved: true, limit: PATROL_FINDINGS_HISTORY_LIMIT }
: undefined,
);
const now = Date.now();
setPatrolFindings(findings.map((item) => normalizePatrolFindingRecord(item, now)));
+2 -2
View File
@@ -93,7 +93,7 @@ var agentCapabilitiesManifest = AgentCapabilitiesManifest{
Capabilities: []AgentCapability{
{
Name: "get_resource_context",
Description: "Return the situated picture of a resource — identity, operator-set state with maintenance-window-active flag, active findings, pending approvals scoped to this resource, recent actions including refused dispatches.",
Description: "Return the situated picture of a resource — identity, operator-set state with maintenance-window-active flag, active findings, pending approvals scoped to this resource, recent actions including refused dispatches. Command fields are redacted for monitoring-read API tokens unless the token also has ai:execute.",
Category: "context",
Method: http.MethodGet,
Path: "/api/agent/resource-context/{resourceId}",
@@ -141,7 +141,7 @@ var agentCapabilitiesManifest = AgentCapabilitiesManifest{
},
{
Name: "subscribe_events",
Description: "Subscribe to the SSE event stream for real-time notifications: finding.created when a new finding is raised, approval.pending when a remediation request enters StatusPending and waits on operator decision, action.completed when an action audit reaches a terminal state (Completed or Failed, including refused-before-dispatch failures with stable error-token prefixes; carries a verification block with the read-after-write probe outcome so agents close the certainty loop without polling /api/actions/{id}), heartbeat every 15s. Long-lived connection; agents listen instead of polling.",
Description: "Subscribe to the SSE event stream for real-time notifications: finding.created when a new finding is raised, approval.pending when a remediation request enters StatusPending and waits on operator decision, action.completed when an action audit reaches a terminal state (Completed or Failed, including refused-before-dispatch failures with stable error-token prefixes; carries a verification block with the read-after-write probe outcome so agents close the certainty loop without polling /api/actions/{id}), heartbeat every 15s. Command fields are redacted for monitoring-read API tokens unless the token also has ai:execute. Long-lived connection; agents listen instead of polling.",
Category: "context",
Method: http.MethodGet,
Path: "/api/agent/events",
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"net/http"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func requestCanReadAgentCommandPayloads(r *http.Request) bool {
token := getAPITokenRecordFromRequest(r)
if token == nil {
return true
}
return token.HasScope(config.ScopeAIExecute)
}
func redactAgentEventCommandsForRequest(event AgentEvent, r *http.Request) AgentEvent {
if requestCanReadAgentCommandPayloads(r) {
return event
}
switch payload := event.Payload.(type) {
case AgentEventApprovalPendingPayload:
event.Payload = redactAgentEventApprovalPendingCommand(payload)
case *AgentEventApprovalPendingPayload:
if payload != nil {
redacted := redactAgentEventApprovalPendingCommand(*payload)
event.Payload = &redacted
}
case AgentEventActionCompletedPayload:
event.Payload = redactAgentEventActionCompletedCommand(payload)
case *AgentEventActionCompletedPayload:
if payload != nil {
redacted := redactAgentEventActionCompletedCommand(*payload)
event.Payload = &redacted
}
}
return event
}
func redactAgentResourceContextCommandsForRequest(bundle *AgentResourceContext, r *http.Request) {
if bundle == nil || requestCanReadAgentCommandPayloads(r) {
return
}
for i := range bundle.PendingApprovals {
bundle.PendingApprovals[i] = redactAgentResourceApprovalCommand(bundle.PendingApprovals[i])
}
for i := range bundle.RecentActions {
bundle.RecentActions[i] = redactAgentResourceActionCommand(bundle.RecentActions[i])
}
}
func redactAgentEventApprovalPendingCommand(payload AgentEventApprovalPendingPayload) AgentEventApprovalPendingPayload {
if payload.Command != "" {
payload.Command = ""
payload.CommandRedacted = true
}
return payload
}
func redactAgentEventActionCompletedCommand(payload AgentEventActionCompletedPayload) AgentEventActionCompletedPayload {
if payload.Command != "" {
payload.Command = ""
payload.CommandRedacted = true
}
if payload.Verification != nil {
verification := *payload.Verification
redactAgentResourceVerificationCommand(&verification)
payload.Verification = &verification
}
return payload
}
func redactAgentResourceApprovalCommand(summary AgentResourceApprovalSummary) AgentResourceApprovalSummary {
if summary.Command != "" {
summary.Command = ""
summary.CommandRedacted = true
}
return summary
}
func redactAgentResourceActionCommand(summary AgentResourceActionSummary) AgentResourceActionSummary {
if summary.Command != "" {
summary.Command = ""
summary.CommandRedacted = true
}
if summary.Verification != nil {
verification := *summary.Verification
redactAgentResourceVerificationCommand(&verification)
summary.Verification = &verification
}
return summary
}
func redactAgentResourceVerificationCommand(verification *AgentResourceActionVerification) {
if verification == nil || verification.Command == "" {
return
}
verification.Command = ""
verification.CommandRedacted = true
}
+39 -34
View File
@@ -28,20 +28,22 @@ const (
// AgentEventApprovalPending fires when a remediation request enters
// StatusPending and is waiting on an operator (or operator-acting
// agent) to approve, reject, or cancel. Payload carries the
// approval id, the resource it targets, the command, the assessed
// risk level, who requested it, and when it expires — enough for
// an agent that holds approval authority to decide whether to act,
// fetch full context via the approval endpoints, or escalate.
// approval id, the resource it targets, risk, who requested it,
// and when it expires. Raw command text is included only for
// action-capable API tokens; monitoring-read subscribers receive
// a redacted doorbell and can fetch governed detail through the
// approval/action surfaces when authorized.
AgentEventApprovalPending AgentEventKind = "approval.pending"
// AgentEventActionCompleted fires when an action audit reaches a
// terminal state — Completed (executed and verified) or Failed
// (refused before dispatch with a stable error-token prefix, or
// errored during execution). Payload carries the canonical action
// id, the resource it targeted, the capability and command, the
// outcome (success bool + optional error message), who acted,
// and the completion timestamp — enough for an agent to close
// the dispatch loop without polling the audit endpoint.
// id, the resource it targeted, the capability, the
// redacted-or-full command, the outcome (success bool + optional
// error message), who acted, and the completion timestamp —
// enough for an agent to close the dispatch loop without polling
// the audit endpoint.
AgentEventActionCompleted AgentEventKind = "action.completed"
// AgentEventHeartbeat is a keepalive that fires at a fixed
@@ -77,22 +79,24 @@ type AgentEventFindingCreatedPayload struct {
// AgentEventApprovalPendingPayload is the payload shape for
// approval.pending events. Carries the agent-decision-relevant
// fields: which approval, against which resource, what command,
// what risk, who requested it, and when it expires. Full request
// detail (preflight, plan, raw context) stays behind the existing
// /api/approvals/{id} endpoint — the event is a doorbell, not the
// approval itself.
// fields: which approval, against which resource, what risk, who
// requested it, and when it expires. Command is present only when
// the caller's token also has action execution authority; otherwise
// CommandRedacted is true. Full request detail (preflight, plan,
// raw context) stays behind the existing /api/approvals/{id}
// endpoint — the event is a doorbell, not the approval itself.
type AgentEventApprovalPendingPayload struct {
ApprovalID string `json:"approvalId"`
ResourceID string `json:"resourceId,omitempty"`
TargetType string `json:"targetType,omitempty"`
TargetID string `json:"targetId,omitempty"`
TargetName string `json:"targetName,omitempty"`
Command string `json:"command"`
RiskLevel string `json:"riskLevel"`
RequestedBy string `json:"requestedBy,omitempty"`
RequestedAt time.Time `json:"requestedAt"`
ExpiresAt time.Time `json:"expiresAt"`
ApprovalID string `json:"approvalId"`
ResourceID string `json:"resourceId,omitempty"`
TargetType string `json:"targetType,omitempty"`
TargetID string `json:"targetId,omitempty"`
TargetName string `json:"targetName,omitempty"`
Command string `json:"command,omitempty"`
CommandRedacted bool `json:"commandRedacted,omitempty"`
RiskLevel string `json:"riskLevel"`
RequestedBy string `json:"requestedBy,omitempty"`
RequestedAt time.Time `json:"requestedAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
// AgentEventActionCompletedPayload is the payload shape for
@@ -110,16 +114,17 @@ type AgentEventApprovalPendingPayload struct {
// Full audit detail (lifecycle events, preflight, verification
// stdout) stays behind the existing /api/actions/{id} endpoint.
type AgentEventActionCompletedPayload struct {
ActionID string `json:"actionId"`
ResourceID string `json:"resourceId,omitempty"`
CapabilityName string `json:"capabilityName,omitempty"`
Command string `json:"command,omitempty"`
State string `json:"state"`
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Verification *AgentResourceActionVerification `json:"verification,omitempty"`
RequestedBy string `json:"requestedBy,omitempty"`
CompletedAt time.Time `json:"completedAt"`
ActionID string `json:"actionId"`
ResourceID string `json:"resourceId,omitempty"`
CapabilityName string `json:"capabilityName,omitempty"`
Command string `json:"command,omitempty"`
CommandRedacted bool `json:"commandRedacted,omitempty"`
State string `json:"state"`
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Verification *AgentResourceActionVerification `json:"verification,omitempty"`
RequestedBy string `json:"requestedBy,omitempty"`
CompletedAt time.Time `json:"completedAt"`
}
// AgentEventBroadcaster is a thread-safe pub/sub for AgentEvents. A
@@ -264,7 +269,7 @@ func (b *AgentEventBroadcaster) HandleAgentEvents(w http.ResponseWriter, r *http
if !open {
return
}
writeAgentSSEEvent(w, event)
writeAgentSSEEvent(w, redactAgentEventCommandsForRequest(event, r))
flusher.Flush()
case <-heartbeat.C:
b.Publish(AgentEvent{Kind: AgentEventHeartbeat})
+71
View File
@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestAgentEventBroadcaster_SubscribeReceivesPublishedEvents(t *testing.T) {
@@ -330,6 +332,75 @@ func TestAgentEventApprovalPendingKindIsStable(t *testing.T) {
}
}
func TestAgentEventCommandRedactionForMonitoringReadTokens(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/agent/events", nil)
attachAPITokenRecord(req, &config.APITokenRecord{Scopes: []string{config.ScopeMonitoringRead}})
event := redactAgentEventCommandsForRequest(AgentEvent{
Kind: AgentEventActionCompleted,
Payload: AgentEventActionCompletedPayload{
ActionID: "action-1",
Command: "systemctl restart nginx",
Verification: &AgentResourceActionVerification{
Ran: true,
Success: true,
Command: "systemctl is-active nginx",
},
},
}, req)
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
if !ok {
t.Fatalf("payload type: got %T", event.Payload)
}
if payload.Command != "" || !payload.CommandRedacted {
t.Fatalf("action command must be redacted for monitoring-read token; got %+v", payload)
}
if payload.Verification == nil {
t.Fatal("verification must remain present")
}
if payload.Verification.Command != "" || !payload.Verification.CommandRedacted {
t.Fatalf("verification command must be redacted for monitoring-read token; got %+v", payload.Verification)
}
approval := redactAgentEventCommandsForRequest(AgentEvent{
Kind: AgentEventApprovalPending,
Payload: AgentEventApprovalPendingPayload{
ApprovalID: "appr-1",
Command: "systemctl restart nginx",
},
}, req)
approvalPayload, ok := approval.Payload.(AgentEventApprovalPendingPayload)
if !ok {
t.Fatalf("payload type: got %T", approval.Payload)
}
if approvalPayload.Command != "" || !approvalPayload.CommandRedacted {
t.Fatalf("approval command must be redacted for monitoring-read token; got %+v", approvalPayload)
}
}
func TestAgentEventCommandRedactionKeepsCommandsForActionTokens(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/agent/events", nil)
attachAPITokenRecord(req, &config.APITokenRecord{
Scopes: []string{config.ScopeMonitoringRead, config.ScopeAIExecute},
})
event := redactAgentEventCommandsForRequest(AgentEvent{
Kind: AgentEventApprovalPending,
Payload: AgentEventApprovalPendingPayload{
ApprovalID: "appr-1",
Command: "systemctl restart nginx",
},
}, req)
payload, ok := event.Payload.(AgentEventApprovalPendingPayload)
if !ok {
t.Fatalf("payload type: got %T", event.Payload)
}
if payload.Command != "systemctl restart nginx" || payload.CommandRedacted {
t.Fatalf("action-capable token should keep command; got %+v", payload)
}
}
func TestAgentEventBroadcaster_PublishActionCompletedDelivers(t *testing.T) {
// action.completed is the third event kind the broadcaster
// publishes (alongside finding.created and approval.pending).
+36 -26
View File
@@ -41,46 +41,54 @@ type AgentResourceFindingSnapshot struct {
// no derivable check, or the dispatch failed before verification
// could run). Output is intentionally omitted from the projection
// — verification stdout can be large; agents that need it follow
// up via the audit endpoint.
// up via the audit endpoint. Verification command follows the same
// redaction rule as action commands.
type AgentResourceActionVerification struct {
Ran bool `json:"ran"`
Success bool `json:"success"`
Command string `json:"command,omitempty"`
Note string `json:"note,omitempty"`
RanAt time.Time `json:"ranAt,omitempty"`
Ran bool `json:"ran"`
Success bool `json:"success"`
Command string `json:"command,omitempty"`
CommandRedacted bool `json:"commandRedacted,omitempty"`
Note string `json:"note,omitempty"`
RanAt time.Time `json:"ranAt,omitempty"`
}
// AgentResourceActionSummary is the agent-consumable projection of an
// action audit record. Includes the refusal-reason prefix tokens
// (resource_remediation_locked:, plan_drift:) verbatim so agents can
// branch on them without parsing the human message.
// branch on them without parsing the human message. Command is present
// only for session callers or API tokens with action execution scope;
// monitoring-read tokens receive commandRedacted instead.
type AgentResourceActionSummary struct {
ID string `json:"id"`
CapabilityName string `json:"capabilityName"`
Command string `json:"command,omitempty"`
State string `json:"state"`
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Verification *AgentResourceActionVerification `json:"verification,omitempty"`
RequestedBy string `json:"requestedBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
CapabilityName string `json:"capabilityName"`
Command string `json:"command,omitempty"`
CommandRedacted bool `json:"commandRedacted,omitempty"`
State string `json:"state"`
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Verification *AgentResourceActionVerification `json:"verification,omitempty"`
RequestedBy string `json:"requestedBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// AgentResourceApprovalSummary is the agent-consumable projection of
// a pending approval request scoped to a specific resource. Carries
// just enough for an agent that holds approval authority to decide
// whether to act, fetch full context via /api/approvals/{id}, or
// escalate. Mirrors the shape of approval.pending SSE events so
// "what's pending right now" (this bundle) and "what just became
// pending" (the SSE stream) speak the same vocabulary.
// escalate. Command is present only for session callers or API tokens
// with action execution scope; monitoring-read tokens receive
// commandRedacted instead. Mirrors the shape of approval.pending SSE
// events so "what's pending right now" (this bundle) and "what just
// became pending" (the SSE stream) speak the same vocabulary.
type AgentResourceApprovalSummary struct {
ID string `json:"id"`
Command string `json:"command"`
RiskLevel string `json:"riskLevel"`
RequestedBy string `json:"requestedBy,omitempty"`
RequestedAt time.Time `json:"requestedAt"`
ExpiresAt time.Time `json:"expiresAt"`
ID string `json:"id"`
Command string `json:"command,omitempty"`
CommandRedacted bool `json:"commandRedacted,omitempty"`
RiskLevel string `json:"riskLevel"`
RequestedBy string `json:"requestedBy,omitempty"`
RequestedAt time.Time `json:"requestedAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
// AgentFleetFindingCounts is the per-severity finding rollup carried
@@ -337,6 +345,8 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
}
}
redactAgentResourceContextCommandsForRequest(&bundle, r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bundle)
}
@@ -355,6 +355,88 @@ func TestHandleAgentResourceContext_PendingApprovalsAreScopedToResource(t *testi
}
}
func TestHandleAgentResourceContext_RedactsCommandsForMonitoringReadTokens(t *testing.T) {
h, _ := agentContextFixtureHandlers(t, "vm:101")
now := time.Now().UTC()
expires := now.Add(5 * time.Minute)
h.SetApprovalsProvider(staticApprovalsProvider{
byResource: map[string][]AgentResourceApprovalSummary{
"vm:101": {
{
ID: "appr-1",
Command: "systemctl restart nginx",
RiskLevel: "medium",
RequestedBy: "ai:patrol",
RequestedAt: now,
ExpiresAt: expires,
},
},
},
})
store, err := h.resources.getStore("default")
if err != nil {
t.Fatalf("getStore: %v", err)
}
if err := store.RecordActionAudit(unified.ActionAuditRecord{
ID: "act-1",
CreatedAt: now,
UpdatedAt: now,
State: unified.ActionStateCompleted,
Request: unified.ActionRequest{
RequestID: "req-1",
ResourceID: "vm:101",
CapabilityName: "pulse_control",
RequestedBy: "pulse_patrol",
Params: map[string]any{"command": "systemctl restart nginx"},
},
Result: &unified.ExecutionResult{
Success: true,
Verification: &unified.ActionVerificationResult{
Ran: true,
Success: true,
Command: "systemctl is-active nginx",
RanAt: now,
},
},
}); err != nil {
t.Fatalf("RecordActionAudit: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/vm:101", nil)
token := &config.APITokenRecord{Scopes: []string{config.ScopeMonitoringRead}}
attachAPITokenRecord(req, token)
h.HandleResourceContext(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
}
var bundle AgentResourceContext
if err := json.NewDecoder(rec.Body).Decode(&bundle); err != nil {
t.Fatalf("decode: %v", err)
}
if len(bundle.PendingApprovals) != 1 {
t.Fatalf("PendingApprovals len = %d; want 1", len(bundle.PendingApprovals))
}
if got := bundle.PendingApprovals[0]; got.Command != "" || !got.CommandRedacted {
t.Fatalf("pending approval command must be redacted for monitoring-read token; got %+v", got)
}
if len(bundle.RecentActions) != 1 {
t.Fatalf("RecentActions len = %d; want 1", len(bundle.RecentActions))
}
action := bundle.RecentActions[0]
if action.Command != "" || !action.CommandRedacted {
t.Fatalf("recent action command must be redacted for monitoring-read token; got %+v", action)
}
if action.Verification == nil {
t.Fatal("expected verification to remain present")
}
if action.Verification.Command != "" || !action.Verification.CommandRedacted {
t.Fatalf("verification command must be redacted for monitoring-read token; got %+v", action.Verification)
}
}
func TestHandleAgentResourceContext_PendingApprovalsEmptyArrayWhenNone(t *testing.T) {
// Absent or empty must surface as an empty array, not as a
// missing field — agents iterate without nil-checking. This
+38 -9
View File
@@ -5044,6 +5044,9 @@ const (
patrolReadinessReady = ai.PatrolReadinessReady
patrolReadinessWarning = ai.PatrolReadinessWarning
patrolReadinessNotReady = ai.PatrolReadinessNotReady
defaultResolvedPatrolFindingsLimit = 200
maxPatrolFindingsLimit = 500
)
func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService *ai.Service, patrolAvailable bool) PatrolReadinessResponse {
@@ -5605,11 +5608,9 @@ func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *ht
findings = patrol.GetAllFindings()
}
// Optional limit parameter (for relay proxy clients with body size constraints)
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit < len(findings) {
findings = findings[:limit]
}
limit := patrolFindingsLimitFromQuery(r.URL.Query(), includeResolved)
if limit > 0 && limit < len(findings) {
findings = findings[:limit]
}
if err := utils.WriteJSONResponse(w, findings); err != nil {
@@ -5617,6 +5618,24 @@ func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *ht
}
}
func patrolFindingsLimitFromQuery(query url.Values, includeResolved bool) int {
limit := 0
if includeResolved {
limit = defaultResolvedPatrolFindingsLimit
}
if limitStr := query.Get("limit"); limitStr != "" {
parsed, err := strconv.Atoi(limitStr)
if err != nil || parsed <= 0 {
return limit
}
if parsed > maxPatrolFindingsLimit {
return maxPatrolFindingsLimit
}
return parsed
}
return limit
}
// HandleForcePatrol triggers an immediate patrol run (POST /api/ai/patrol/run)
func (h *AISettingsHandler) HandleForcePatrol(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -6819,20 +6838,30 @@ func (h *AISettingsHandler) HandleAddSuppressionRule(w http.ResponseWriter, r *h
}
var req struct {
ResourceID string `json:"resource_id"` // Can be empty for "any resource"
ResourceName string `json:"resource_name"` // Human-readable name
Category string `json:"category"` // Can be empty for "any category"
Description string `json:"description"` // Required - user's reason
ResourceID string `json:"resource_id"` // Can be empty for "any resource" when AllowBroadScope is true
ResourceName string `json:"resource_name"` // Human-readable name
Category string `json:"category"` // Can be empty for "any category" when AllowBroadScope is true
Description string `json:"description"` // Required - user's reason
AllowBroadScope bool `json:"allow_broad_scope"` // Required when resource or category is intentionally wildcarded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.ResourceID = strings.TrimSpace(req.ResourceID)
req.ResourceName = strings.TrimSpace(req.ResourceName)
req.Category = strings.TrimSpace(req.Category)
req.Description = strings.TrimSpace(req.Description)
if req.Description == "" {
http.Error(w, "description is required", http.StatusBadRequest)
return
}
if (req.ResourceID == "" || req.Category == "") && !req.AllowBroadScope {
http.Error(w, "broad suppression scope requires allow_broad_scope", http.StatusBadRequest)
return
}
// Convert category string to FindingCategory
var category ai.FindingCategory
@@ -281,6 +281,50 @@ func TestPatrolActionHandlers_NoAIService_ReturnStructuredServiceUnavailable(t *
}
}
func TestHandleAddSuppressionRuleRejectsImplicitBroadScope(t *testing.T) {
handler, patrol, _, _ := setupAIHandlerWithPatrol(t)
req := newLoopbackRequest(
http.MethodPost,
"/api/ai/patrol/suppressions",
strings.NewReader(`{"resource_id":"","resource_name":"Any","category":"capacity","description":"too broad"}`),
)
rec := httptest.NewRecorder()
handler.HandleAddSuppressionRule(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if rules := patrol.GetFindings().GetSuppressionRules(); len(rules) != 0 {
t.Fatalf("expected no suppression rules, got %d", len(rules))
}
}
func TestHandleAddSuppressionRuleAllowsExplicitBroadScope(t *testing.T) {
handler, patrol, _, _ := setupAIHandlerWithPatrol(t)
req := newLoopbackRequest(
http.MethodPost,
"/api/ai/patrol/suppressions",
strings.NewReader(`{"resource_id":"","resource_name":"Any","category":"capacity","description":"known capacity noise","allow_broad_scope":true}`),
)
rec := httptest.NewRecorder()
handler.HandleAddSuppressionRule(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
rules := patrol.GetFindings().GetSuppressionRules()
if len(rules) != 1 {
t.Fatalf("rules = %d, want 1", len(rules))
}
if rules[0].ResourceID != "" || rules[0].Category != ai.FindingCategoryCapacity {
t.Fatalf("unexpected rule scope: resource=%q category=%q", rules[0].ResourceID, rules[0].Category)
}
}
func TestHandleAcknowledgeFinding_PatrolAndUnified(t *testing.T) {
handler, patrol, unifiedStore, learningStore := setupAIHandlerWithPatrol(t)
+21
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@@ -1986,6 +1987,26 @@ func TestHandleAddSuppressionRule_MethodNotAllowed(t *testing.T) {
}
}
func TestPatrolFindingsLimitFromQuery(t *testing.T) {
t.Parallel()
if got := patrolFindingsLimitFromQuery(url.Values{}, false); got != 0 {
t.Fatalf("active-only default limit = %d, want 0", got)
}
if got := patrolFindingsLimitFromQuery(url.Values{}, true); got != defaultResolvedPatrolFindingsLimit {
t.Fatalf("include-resolved default limit = %d, want %d", got, defaultResolvedPatrolFindingsLimit)
}
if got := patrolFindingsLimitFromQuery(url.Values{"limit": []string{"25"}}, true); got != 25 {
t.Fatalf("explicit limit = %d, want 25", got)
}
if got := patrolFindingsLimitFromQuery(url.Values{"limit": []string{"9999"}}, true); got != maxPatrolFindingsLimit {
t.Fatalf("oversized limit = %d, want %d", got, maxPatrolFindingsLimit)
}
if got := patrolFindingsLimitFromQuery(url.Values{"limit": []string{"0"}}, true); got != defaultResolvedPatrolFindingsLimit {
t.Fatalf("invalid include-resolved limit = %d, want %d", got, defaultResolvedPatrolFindingsLimit)
}
}
// ========================================
// HandleDeleteSuppressionRule tests
// ========================================
+22 -2
View File
@@ -13675,6 +13675,26 @@ func TestContract_GetResourceContextCapabilityListsPendingApprovals(t *testing.T
}
}
func TestContract_AgentCommandPayloadsRequireActionScope(t *testing.T) {
source, err := os.ReadFile("agent_command_redaction.go")
if err != nil {
t.Fatalf("read agent_command_redaction.go: %v", err)
}
src := string(source)
if !strings.Contains(src, "requestCanReadAgentCommandPayloads") {
t.Error("agent command redaction must stay behind a named request-scope helper")
}
if !strings.Contains(src, "token.HasScope(config.ScopeAIExecute)") {
t.Error("agent command payloads must require ai:execute on API-token callers, not only monitoring:read")
}
if !strings.Contains(src, "redactAgentEventCommandsForRequest") {
t.Error("agent SSE events must pass through command redaction before serialization")
}
if !strings.Contains(src, "redactAgentResourceContextCommandsForRequest") {
t.Error("agent resource-context bundles must pass through command redaction before serialization")
}
}
// TestContract_AgentFleetContextEndpointSurfacesStableShape pins the
// agent-paradigm fleet view contract: the endpoint must return a
// `resources` array (never null) so agents iterate without
@@ -13725,7 +13745,7 @@ func TestContract_ActionCompletedPayloadCarriesVerification(t *testing.T) {
t.Fatalf("read agent_events.go: %v", err)
}
src := string(source)
if !strings.Contains(src, "Verification *AgentResourceActionVerification `json:\"verification,omitempty\"`") {
if !strings.Contains(src, "Verification *AgentResourceActionVerification `json:\"verification,omitempty\"`") {
t.Error("AgentEventActionCompletedPayload must carry Verification as a *AgentResourceActionVerification — agents close the certainty loop on this field")
}
}
@@ -13763,7 +13783,7 @@ func TestContract_AgentResourceActionSummaryCarriesVerification(t *testing.T) {
t.Fatalf("read agent_resource_context.go: %v", err)
}
src := string(source)
if !strings.Contains(src, "Verification *AgentResourceActionVerification `json:\"verification,omitempty\"`") {
if !strings.Contains(src, "Verification *AgentResourceActionVerification `json:\"verification,omitempty\"`") {
t.Error("AgentResourceActionSummary must carry Verification so the bundle's recent-actions and the action.completed SSE payload speak the same vocabulary")
}
if !strings.Contains(src, "func projectAgentResourceVerification(v *unified.ActionVerificationResult)") {
+10 -11
View File
@@ -168,12 +168,9 @@ func (e *ReportEngine) Generate(req MetricReportRequest) (data []byte, contentTy
return nil, "", fmt.Errorf("failed to query metrics: %w", err)
}
// Build narrative interpretation. If req.Narrator is supplied (typically
// an LLM-backed implementation) it is invoked with a bounded timeout;
// nil/error/timeout falls back to the heuristic narrator. The prior
// period is queried with the same window length offset back so deltas
// can be expressed when the narrator wants them.
e.attachNarrative(reportData, req)
if reportFormatNeedsNarrative(req.Format) {
e.attachNarrative(reportData, req)
}
log.Debug().
Str("resourceType", reportData.ResourceType).
@@ -429,11 +426,9 @@ func (e *ReportEngine) GenerateMulti(req MultiReportRequest) (data []byte, conte
return nil, "", fmt.Errorf("all resources failed to query metrics")
}
// Build fleet-level narrative. If req.FleetNarrator is supplied
// (typically AI-backed) it is invoked with a bounded timeout;
// nil/error/timeout falls back to the heuristic fleet narrator so
// the fleet PDF always has narrative content.
e.attachFleetNarrative(multiData, req)
if reportFormatNeedsNarrative(req.Format) {
e.attachFleetNarrative(multiData, req)
}
log.Debug().
Int("resources", successCount).
@@ -542,6 +537,10 @@ func narrativeSource(data *ReportData) string {
return data.Narrative.Source
}
func reportFormatNeedsNarrative(format ReportFormat) bool {
return format == FormatPDF
}
// attachFleetNarrative populates multiData.FleetNarrative using
// req.FleetNarrator (when supplied). Always populates so the renderer
// has a single source of truth for the fleet summary section.
+99
View File
@@ -249,6 +249,92 @@ func TestEngineGenerate_FindingsProviderInvoked(t *testing.T) {
}
}
func TestEngineGenerate_CSVSkipsNarratorAndFindingsProvider(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
nodeID := "node-csv-narrative-skip"
for i := 0; i < 6; i++ {
ts := now.Add(time.Duration(-30+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 50.0, ts)
}
store.Flush()
provider := &stubFindingsProvider{
findings: []FindingSummary{{Severity: "warning", Title: "Should not load"}},
}
stub := &capturingNarrator{out: Narrative{HealthStatus: "HEALTHY"}}
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MetricReportRequest{
ResourceType: "node",
ResourceID: nodeID,
Start: now.Add(-1 * time.Hour),
End: now.Add(time.Minute),
Format: FormatCSV,
Narrator: stub,
FindingsProvider: provider,
}
csv, contentType, err := engine.Generate(req)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(csv) == 0 {
t.Fatal("expected non-empty CSV")
}
if contentType != "text/csv" {
t.Fatalf("contentType = %q, want text/csv", contentType)
}
if stub.called {
t.Fatal("CSV generation invoked narrator")
}
if provider.called {
t.Fatal("CSV generation invoked findings provider")
}
}
func TestEngineGenerateMulti_CSVSkipsFleetNarrator(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
for _, nodeID := range []string{"node-csv-fleet-a", "node-csv-fleet-b"} {
for i := 0; i < 6; i++ {
ts := now.Add(time.Duration(-30+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 60.0, ts)
}
}
store.Flush()
stub := &capturingFleetNarrator{out: FleetNarrative{HealthStatus: "HEALTHY"}}
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MultiReportRequest{
Title: "Fleet CSV narrative skip",
Start: now.Add(-1 * time.Hour),
End: now.Add(time.Minute),
Format: FormatCSV,
Resources: []MetricReportRequest{
{ResourceType: "node", ResourceID: "node-csv-fleet-a"},
{ResourceType: "node", ResourceID: "node-csv-fleet-b"},
},
FleetNarrator: stub,
}
csv, contentType, err := engine.GenerateMulti(req)
if err != nil {
t.Fatalf("GenerateMulti: %v", err)
}
if len(csv) == 0 {
t.Fatal("expected non-empty CSV")
}
if contentType != "text/csv" {
t.Fatalf("contentType = %q, want text/csv", contentType)
}
if stub.called {
t.Fatal("CSV generation invoked fleet narrator")
}
}
// TestEngineNarrativeFor_ReturnsStructuredNarrativeWithoutRendering
// verifies the non-rendering entry point used by Pulse Assistant tools:
// it must produce a Narrative grounded in the queried metrics without
@@ -382,6 +468,19 @@ func (c *capturingNarrator) Narrate(_ context.Context, in NarrativeInput) (Narra
return c.out, c.err
}
type capturingFleetNarrator struct {
out FleetNarrative
err error
called bool
seen FleetNarrativeInput
}
func (c *capturingFleetNarrator) NarrateFleet(_ context.Context, in FleetNarrativeInput) (FleetNarrative, error) {
c.called = true
c.seen = in
return c.out, c.err
}
func newReportingMetricsStore(t *testing.T) *metrics.Store {
t.Helper()
dir := t.TempDir()