mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Harden Patrol and Assistant action boundaries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
// ========================================
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user