mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-20 10:18:25 +00:00
Frame Patrol Assistant handoff for operators
This commit is contained in:
@@ -785,7 +785,10 @@ the canonical monitored-system blocked payload.
|
||||
snoozed/dismissed/suppressed status, detection/last-seen/resolution
|
||||
timestamps, recurrence/regression facts, and recent lifecycle events, so API
|
||||
consumers do not reduce Assistant context to an outdated investigation
|
||||
summary. The briefing must carry the primary finding's current attention
|
||||
summary. The frontend store boundary must preserve those recurrence facts
|
||||
from the shared payload, including `times_raised`, so Patrol presentation and
|
||||
Assistant handoff helpers do not infer repeated findings from page-local
|
||||
state. The briefing must carry the primary finding's current attention
|
||||
reason, recency facts, bounded evidence snapshot, verification summary, and
|
||||
explicit operator decision framing before investigation guidance and may
|
||||
carry the latest lifecycle event as the current handoff state, while the
|
||||
@@ -2944,8 +2947,10 @@ canonical operator-facing frame for Assistant: it carries the finding summary,
|
||||
resource, priority, current attention reason, current recency facts, bounded
|
||||
evidence and verification summaries, investigation confidence, recommended next
|
||||
step, and operator-decision framing plus approval/proposed-fix posture without
|
||||
raw command text, and the downstream chat service then hydrates live resource
|
||||
state, timeline, and action audit context around that same handoff.
|
||||
raw command text. Patrol's frontend Assistant drawer briefing must use that same
|
||||
operator frame for visible handoffs from findings, while the downstream chat
|
||||
service hydrates live resource state, timeline, and action audit context around
|
||||
that same handoff.
|
||||
Patrol run-history serialization and persistence must also preserve full field
|
||||
parity across API responses and restart boundaries, including
|
||||
`pmg_checked`, `rejected_findings`, `triage_flags`, `triage_skipped_llm`, and
|
||||
|
||||
@@ -811,8 +811,11 @@ frontend primitive boundary.
|
||||
so shared drawer primitives stay shell-owned rather than becoming a
|
||||
Patrol-specific prompt formatter. The drawer may render a generic
|
||||
context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but
|
||||
feature-owned helpers must provide the source labels, evidence summaries,
|
||||
action copy, and safety note.
|
||||
feature-owned helpers must provide the source labels, attention reason,
|
||||
evidence summaries, operator-decision copy, action copy, and safety note.
|
||||
Patrol finding handoffs should still provide that briefing from current
|
||||
finding facts when a durable Patrol investigation record is not attached
|
||||
yet, rather than opening the shared drawer as empty generic chat.
|
||||
11. Keep shared filter primitives coherent with route-owned option hydration.
|
||||
Feature shells such as `frontend-modern/src/features/infrastructure/`
|
||||
must keep a route-owned canonical option visible in shared selects like
|
||||
@@ -2169,7 +2172,8 @@ or polling lifecycle. The Patrol feature is the current reference shape:
|
||||
`frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` stays the
|
||||
feature shell, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`
|
||||
owns the runtime state machine, `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
|
||||
owns the pure investigation-context summary derivation,
|
||||
owns the pure investigation-context summary and Patrol-to-Assistant operator
|
||||
briefing derivation,
|
||||
`frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owns canonical AI
|
||||
summary normalization at the shared store boundary, and the Patrol-owned
|
||||
header/banner/summary/workspace section files under
|
||||
|
||||
@@ -46,6 +46,7 @@ Patrol-specific presentation helpers.
|
||||
23. `frontend-modern/src/utils/patrolSummaryPresentation.ts`
|
||||
24. `frontend-modern/src/utils/patrolRuntimePresentation.ts`
|
||||
25. `frontend-modern/src/utils/textPresentation.ts`
|
||||
26. `tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -134,7 +135,17 @@ Patrol-specific presentation helpers.
|
||||
attention reason, recency, evidence snapshot, verification summary,
|
||||
conclusion, latest lifecycle event, recommended next step, explicit operator
|
||||
decision framing, and governed approval/proposed-fix posture instead of
|
||||
behaving like a generic chat over a pasted incident dump. The assembled handoff must still pass
|
||||
behaving like a generic chat over a pasted incident dump. The visible
|
||||
Assistant drawer briefing opened from a Patrol finding must mirror that same
|
||||
Patrol-owned operator frame, including current severity/status, recurrence or
|
||||
regression, loop state, approval/proposed-fix posture, and the explicit
|
||||
operator decision being requested. That visible briefing remains a summary
|
||||
surface only: proposed-fix commands stay summarized by count and destructive
|
||||
action copy must point back to governed approval/remediation context. When a
|
||||
structured investigation record is not available yet, the same Patrol-owned
|
||||
helper must still brief the operator from current finding facts such as
|
||||
active status, severity, recurrence, and loop state instead of opening a
|
||||
generic empty Assistant drawer. The assembled handoff must still pass
|
||||
through the Assistant runtime's
|
||||
resource-policy sanitizer before prompt injection, so Patrol-owned prose
|
||||
cannot leak governed resource names, IDs, aliases, nodes, paths, or
|
||||
@@ -150,7 +161,10 @@ Patrol-specific presentation helpers.
|
||||
and explicitly separated from approval/execution authority. Patrol must keep
|
||||
the visible finding and drawer briefing tied to the shared investigation
|
||||
payload rather than forking a Patrol-local lifecycle, policy, topology, or
|
||||
timeline summary.
|
||||
timeline summary. The inline Patrol investigation surface must also treat a
|
||||
structured `investigationRecord` as investigation data, even when the legacy
|
||||
investigation-detail endpoint has no separate session payload, so it must not
|
||||
render empty-state copy above a durable Patrol record.
|
||||
|
||||
## Current State
|
||||
|
||||
|
||||
@@ -430,6 +430,13 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
const briefing = buildPatrolAssistantFindingBriefing({
|
||||
title,
|
||||
subject,
|
||||
severity: finding.severity,
|
||||
findingStatus: finding.status,
|
||||
loopState: finding.loopState,
|
||||
timesRaised: finding.timesRaised,
|
||||
regressionCount: finding.regressionCount,
|
||||
lastRegressionAt: finding.lastRegressionAt,
|
||||
remediationId: finding.remediationPlanId,
|
||||
investigationRecord: finding.investigationRecord,
|
||||
});
|
||||
aiChatStore.openWithPrompt(prompt, {
|
||||
|
||||
@@ -57,6 +57,12 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
}
|
||||
},
|
||||
);
|
||||
const hasInvestigationContext = createMemo(
|
||||
() => Boolean(investigation()) || investigationRecord().hasRecord,
|
||||
);
|
||||
const investigationSectionState = createMemo(() =>
|
||||
getInvestigationSectionState(investigation.loading, hasInvestigationContext()),
|
||||
);
|
||||
|
||||
// Auto-poll while investigation is active
|
||||
createEffect(() => {
|
||||
@@ -149,23 +155,16 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
<Show
|
||||
when={
|
||||
!getInvestigationSectionState(investigation.loading, !!investigation()).empty &&
|
||||
getInvestigationSectionState(investigation.loading, !!investigation()).text
|
||||
}
|
||||
>
|
||||
<Show when={!investigationSectionState().empty && investigationSectionState().text}>
|
||||
<div class="flex items-center gap-2 text-xs text-muted py-2">
|
||||
<span class="h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
{getInvestigationSectionState(investigation.loading, !!investigation()).text}
|
||||
{investigationSectionState().text}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* No investigation data */}
|
||||
<Show when={getInvestigationSectionState(investigation.loading, !!investigation()).empty}>
|
||||
<p class="text-xs text-muted py-1">
|
||||
{getInvestigationSectionState(investigation.loading, !!investigation()).text}
|
||||
</p>
|
||||
<Show when={investigationSectionState().empty}>
|
||||
<p class="text-xs text-muted py-1">{investigationSectionState().text}</p>
|
||||
</Show>
|
||||
|
||||
<Show when={investigationRecord().hasRecord}>
|
||||
|
||||
@@ -122,6 +122,7 @@ describe('InvestigationSection', () => {
|
||||
expect(screen.getByText('Backup job saturated CPU.')).toBeInTheDocument();
|
||||
expect(screen.getByText('CPU stayed above 95% for 10 minutes')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 command recorded for approval context')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No investigation data available/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('systemctl restart workload.service')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+40
-3
@@ -161,6 +161,13 @@ describe('patrolInvestigationContextModel', () => {
|
||||
const briefing = buildPatrolAssistantFindingBriefing({
|
||||
title: 'High CPU usage',
|
||||
subject: 'web-server',
|
||||
severity: 'critical',
|
||||
findingStatus: 'active',
|
||||
loopState: 'awaiting_approval',
|
||||
timesRaised: 4,
|
||||
regressionCount: 2,
|
||||
lastRegressionAt: '2026-05-06T12:06:00Z',
|
||||
remediationId: 'remediation-1',
|
||||
investigationRecord: {
|
||||
id: 'record-1',
|
||||
finding_id: 'finding-1',
|
||||
@@ -177,28 +184,58 @@ describe('patrolInvestigationContextModel', () => {
|
||||
description: 'Restart the workload service',
|
||||
commands: ['systemctl restart workload.service'],
|
||||
risk_level: 'medium',
|
||||
destructive: false,
|
||||
destructive: true,
|
||||
},
|
||||
verification: ['CPU returned below 50%'],
|
||||
tools_used: [],
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
approval_id: 'approval-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(briefing).toEqual({
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Investigation record attached',
|
||||
title: 'Operator briefing attached',
|
||||
subject: 'High CPU usage on web-server',
|
||||
statusLabel: 'Completed · Fix Queued · High confidence',
|
||||
detailLines: [
|
||||
'Attention: active critical finding; regressed 2 times; last regression 2026-05-06T12:06:00Z; loop awaiting approval; approval approval-1; destructive proposed fix; fix queued for governed review',
|
||||
'Backup job saturated CPU.',
|
||||
'Approve a controlled restart after the backup completes.',
|
||||
'Decision: review governed approval approval-1 before execution; proposed fix fix-1; risk medium; destructive true',
|
||||
],
|
||||
evidence: ['CPU stayed above 95% for 10 minutes', 'Verified: CPU returned below 50%'],
|
||||
actionLabel: 'Restart the workload service',
|
||||
commandSummary: '1 command recorded for approval context',
|
||||
safetyNote: 'Command details stay in approval context.',
|
||||
safetyNote:
|
||||
'Command details stay in approval context; destructive actions require governed approval.',
|
||||
});
|
||||
expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service');
|
||||
});
|
||||
|
||||
it('builds an operator briefing from current finding facts before a Patrol record exists', () => {
|
||||
expect(
|
||||
buildPatrolAssistantFindingBriefing({
|
||||
title: 'High CPU usage',
|
||||
subject: 'web-server',
|
||||
severity: 'warning',
|
||||
findingStatus: 'active',
|
||||
loopState: 'investigating',
|
||||
timesRaised: 3,
|
||||
}),
|
||||
).toEqual({
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Operator briefing attached',
|
||||
subject: 'High CPU usage on web-server',
|
||||
statusLabel: undefined,
|
||||
detailLines: [
|
||||
'Attention: active warning finding; raised 3 times; loop investigating',
|
||||
'Decision: Wait for Patrol to finish the investigation before approving remediation.',
|
||||
],
|
||||
evidence: [],
|
||||
actionLabel: undefined,
|
||||
commandSummary: undefined,
|
||||
safetyNote: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,13 @@ export interface PatrolAssistantFindingPromptInput {
|
||||
export interface PatrolAssistantFindingBriefingInput {
|
||||
title: string;
|
||||
subject: string;
|
||||
severity?: string | null;
|
||||
findingStatus?: string | null;
|
||||
loopState?: string | null;
|
||||
timesRaised?: number | null;
|
||||
regressionCount?: number | null;
|
||||
lastRegressionAt?: string | null;
|
||||
remediationId?: string | null;
|
||||
investigationRecord?: InvestigationRecord | null;
|
||||
}
|
||||
|
||||
@@ -157,37 +164,217 @@ export function buildPatrolAssistantFindingBriefing(
|
||||
input: PatrolAssistantFindingBriefingInput,
|
||||
): AIChatContextBriefing | undefined {
|
||||
const record = buildPatrolInvestigationRecordPresentation(input.investigationRecord);
|
||||
if (!record.hasRecord) {
|
||||
const title = normalizeText(input.title) || 'Patrol finding';
|
||||
const subject = normalizeText(input.subject) || 'affected resource';
|
||||
const statusParts = [record.statusLabel, record.outcomeLabel, record.confidenceLabel].filter(
|
||||
isNonEmptyString,
|
||||
);
|
||||
const attentionReason = buildPatrolAssistantAttentionReason(input, record);
|
||||
const operatorDecision = buildPatrolAssistantOperatorDecision(input);
|
||||
if (!record.hasRecord && !attentionReason && !operatorDecision) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const title = normalizeText(input.title) || 'Patrol finding';
|
||||
const subject = normalizeText(input.subject) || 'affected resource';
|
||||
const statusParts = [
|
||||
record.statusLabel,
|
||||
record.outcomeLabel,
|
||||
record.confidenceLabel,
|
||||
].filter(isNonEmptyString);
|
||||
const detailLines = [record.conclusion, record.recommendedAction]
|
||||
const detailLines = [
|
||||
attentionReason ? `Attention: ${attentionReason}` : undefined,
|
||||
record.conclusion,
|
||||
record.recommendedAction,
|
||||
operatorDecision ? `Decision: ${operatorDecision}` : undefined,
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.slice(0, 2);
|
||||
.slice(0, 4);
|
||||
const verificationLines = record.verificationSummaries.map((summary) => `Verified: ${summary}`);
|
||||
|
||||
return {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Investigation record attached',
|
||||
title: 'Operator briefing attached',
|
||||
subject: `${title} on ${subject}`,
|
||||
statusLabel: statusParts.join(' · ') || undefined,
|
||||
detailLines,
|
||||
evidence: [...record.evidenceSummaries, ...verificationLines].slice(0, 4),
|
||||
actionLabel: record.proposedFix?.description,
|
||||
commandSummary: record.proposedFix?.commandSummary,
|
||||
safetyNote: record.proposedFix?.commandSummary
|
||||
? 'Command details stay in approval context.'
|
||||
: undefined,
|
||||
safetyNote: buildPatrolAssistantSafetyNote(record),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPatrolAssistantAttentionReason(
|
||||
input: PatrolAssistantFindingBriefingInput,
|
||||
record: PatrolInvestigationRecordPresentation,
|
||||
): string | undefined {
|
||||
const parts: string[] = [];
|
||||
const status = normalizeText(input.findingStatus).toLowerCase();
|
||||
const severity = normalizeText(input.severity).toLowerCase();
|
||||
|
||||
switch (status) {
|
||||
case 'active':
|
||||
parts.push(severity ? `active ${severity} finding` : 'active finding');
|
||||
break;
|
||||
case 'resolved':
|
||||
parts.push(
|
||||
normalizeNonNegativeCount(input.regressionCount) > 0
|
||||
? 'resolved after prior regression'
|
||||
: 'resolved finding',
|
||||
);
|
||||
break;
|
||||
case 'snoozed':
|
||||
parts.push('snoozed finding');
|
||||
break;
|
||||
case 'dismissed':
|
||||
parts.push('dismissed finding');
|
||||
break;
|
||||
}
|
||||
|
||||
const regressionCount = normalizeNonNegativeCount(input.regressionCount);
|
||||
const timesRaised = normalizeNonNegativeCount(input.timesRaised);
|
||||
if (regressionCount > 0) {
|
||||
parts.push(`regressed ${regressionCount} time${regressionCount === 1 ? '' : 's'}`);
|
||||
} else if (timesRaised > 1) {
|
||||
parts.push(`raised ${timesRaised} times`);
|
||||
}
|
||||
|
||||
const lastRegressionAt = normalizeText(input.lastRegressionAt);
|
||||
if (lastRegressionAt) {
|
||||
parts.push(`last regression ${lastRegressionAt}`);
|
||||
}
|
||||
|
||||
const loopState = formatIdentifierLabel(input.loopState)?.toLowerCase();
|
||||
if (loopState) {
|
||||
parts.push(`loop ${loopState}`);
|
||||
}
|
||||
|
||||
const rawRecord = input.investigationRecord;
|
||||
const approvalId = normalizeText(rawRecord?.approval_id);
|
||||
if (approvalId) {
|
||||
parts.push(`approval ${approvalId}`);
|
||||
}
|
||||
if (record.proposedFix?.destructive) {
|
||||
parts.push('destructive proposed fix');
|
||||
}
|
||||
|
||||
switch (normalizeText(rawRecord?.outcome)) {
|
||||
case 'fix_queued':
|
||||
parts.push('fix queued for governed review');
|
||||
break;
|
||||
case 'fix_executed':
|
||||
parts.push('fix executed awaiting verification');
|
||||
break;
|
||||
case 'fix_failed':
|
||||
parts.push('fix failed');
|
||||
break;
|
||||
case 'fix_verification_failed':
|
||||
parts.push('verification failed');
|
||||
break;
|
||||
case 'fix_verification_unknown':
|
||||
parts.push('verification inconclusive');
|
||||
break;
|
||||
case 'needs_attention':
|
||||
parts.push('needs operator attention');
|
||||
break;
|
||||
case 'cannot_fix':
|
||||
parts.push('Patrol cannot safely fix');
|
||||
break;
|
||||
case 'timed_out':
|
||||
parts.push('Patrol timed out');
|
||||
break;
|
||||
}
|
||||
|
||||
return formatBriefingStringList(parts, 8, 'attention facts');
|
||||
}
|
||||
|
||||
function buildPatrolAssistantOperatorDecision(
|
||||
input: PatrolAssistantFindingBriefingInput,
|
||||
): string | undefined {
|
||||
if (normalizeText(input.findingStatus).toLowerCase() === 'resolved') {
|
||||
return 'Finding is resolved; explain the resolution and monitoring follow-up without proposing execution.';
|
||||
}
|
||||
|
||||
const record = input.investigationRecord;
|
||||
if (record) {
|
||||
const approvalId = normalizeText(record.approval_id);
|
||||
if (approvalId) {
|
||||
const parts = [`review governed approval ${approvalId} before execution`];
|
||||
if (record.proposed_fix) {
|
||||
const fixId = normalizeText(record.proposed_fix.id);
|
||||
if (fixId) {
|
||||
parts.push(`proposed fix ${fixId}`);
|
||||
} else if (normalizeText(record.proposed_fix.description)) {
|
||||
parts.push('proposed fix recorded');
|
||||
}
|
||||
const risk = normalizeText(record.proposed_fix.risk_level);
|
||||
if (risk) {
|
||||
parts.push(`risk ${risk}`);
|
||||
}
|
||||
if (record.proposed_fix.destructive) {
|
||||
parts.push('destructive true');
|
||||
}
|
||||
}
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
switch (normalizeText(record.outcome)) {
|
||||
case 'fix_queued':
|
||||
return 'Review the proposed fix in the governed approval or remediation flow before execution.';
|
||||
case 'fix_executed':
|
||||
return 'Verify the execution result before closing or resolving the finding.';
|
||||
case 'fix_failed':
|
||||
case 'fix_verification_failed':
|
||||
return 'Review failed remediation evidence before retrying or escalating.';
|
||||
case 'fix_verification_unknown':
|
||||
return 'Gather verification evidence before closing or retrying remediation.';
|
||||
case 'needs_attention':
|
||||
case 'cannot_fix':
|
||||
return 'Operator intervention is required; use the evidence to choose the next manual step.';
|
||||
case 'timed_out':
|
||||
return 'Patrol timed out; rerun investigation or gather more evidence before remediation.';
|
||||
}
|
||||
|
||||
switch (normalizeText(record.status)) {
|
||||
case 'pending':
|
||||
case 'running':
|
||||
return 'Wait for Patrol to finish the investigation before approving remediation.';
|
||||
case 'failed':
|
||||
return 'Review the Patrol investigation failure and gather evidence before remediation.';
|
||||
case 'needs_attention':
|
||||
return 'Operator intervention is required; use the evidence to choose the next manual step.';
|
||||
}
|
||||
}
|
||||
|
||||
const remediationId = normalizeText(input.remediationId);
|
||||
if (remediationId) {
|
||||
return `Review governed remediation ${remediationId} before execution.`;
|
||||
}
|
||||
|
||||
const loopState = normalizeText(input.loopState).toLowerCase();
|
||||
if (loopState.includes('approval')) {
|
||||
return 'Review the governed approval flow before execution.';
|
||||
}
|
||||
if (loopState.includes('investigat')) {
|
||||
return 'Wait for Patrol to finish the investigation before approving remediation.';
|
||||
}
|
||||
if (normalizeText(input.findingStatus).toLowerCase() === 'active') {
|
||||
return 'Continue investigation or monitoring; no governed action reference is ready.';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPatrolAssistantSafetyNote(
|
||||
record: PatrolInvestigationRecordPresentation,
|
||||
): string | undefined {
|
||||
const hasCommands = Boolean(record.proposedFix?.commandSummary);
|
||||
const isDestructive = Boolean(record.proposedFix?.destructive);
|
||||
if (hasCommands && isDestructive) {
|
||||
return 'Command details stay in approval context; destructive actions require governed approval.';
|
||||
}
|
||||
if (hasCommands) {
|
||||
return 'Command details stay in approval context.';
|
||||
}
|
||||
if (isDestructive) {
|
||||
return 'Destructive actions require governed approval.';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeCorrelationCount(correlations?: CorrelationsResponse | null): number {
|
||||
if (!correlations) return 0;
|
||||
if (typeof correlations.count === 'number' && Number.isFinite(correlations.count)) {
|
||||
@@ -213,6 +400,30 @@ function formatCommandSummary(count: number): string | undefined {
|
||||
: `${count} commands recorded for approval context`;
|
||||
}
|
||||
|
||||
function formatBriefingStringList(
|
||||
values: Array<string | undefined>,
|
||||
limit: number,
|
||||
itemName: string,
|
||||
): string | undefined {
|
||||
if (limit <= 0 || values.length === 0) return undefined;
|
||||
const parts: string[] = [];
|
||||
let total = 0;
|
||||
for (const value of values) {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) continue;
|
||||
total += 1;
|
||||
if (parts.length < limit) {
|
||||
parts.push(normalized);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) return undefined;
|
||||
const remaining = total - parts.length;
|
||||
if (remaining > 0) {
|
||||
parts.push(`${remaining} more ${itemName || 'items'}`);
|
||||
}
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function formatIdentifierLabel(value?: string | null): string | undefined {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) return undefined;
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('aiIntelligenceStore', () => {
|
||||
description: 'CPU usage is high',
|
||||
detected_at: '2026-03-01T00:00:00Z',
|
||||
last_seen_at: '2026-03-05T00:00:00Z',
|
||||
times_raised: 5,
|
||||
alertIdentifier: 'instance:node:100::metric/cpu',
|
||||
investigation_record: {
|
||||
id: 'investigation-1',
|
||||
@@ -92,6 +93,7 @@ describe('aiIntelligenceStore', () => {
|
||||
expect(aiIntelligenceStore.findings[0]).toMatchObject({
|
||||
alertIdentifier: 'instance:node:100::metric/cpu',
|
||||
lastSeenAt: '2026-03-05T00:00:00Z',
|
||||
timesRaised: 5,
|
||||
investigationRecord: {
|
||||
id: 'investigation-1',
|
||||
finding_id: 'finding-1',
|
||||
|
||||
@@ -148,6 +148,7 @@ export interface UnifiedFinding {
|
||||
to?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}>;
|
||||
timesRaised?: number;
|
||||
regressionCount?: number;
|
||||
lastRegressionAt?: string;
|
||||
}
|
||||
@@ -318,6 +319,7 @@ export const aiIntelligenceStore = {
|
||||
investigationRecord: item.investigation_record,
|
||||
loopState: item.loop_state || undefined,
|
||||
lifecycle: item.lifecycle || [],
|
||||
timesRaised: item.times_raised || 0,
|
||||
regressionCount: item.regression_count || 0,
|
||||
lastRegressionAt: item.last_regression_at || undefined,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test as base } from "@playwright/test";
|
||||
|
||||
import { createAuthenticatedStorageState } from "./helpers";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
type WorkerFixtures = {
|
||||
authStorageStatePath: string;
|
||||
};
|
||||
|
||||
const SCREENSHOT_PATH = "/tmp/patrol-assistant-operator-briefing.png";
|
||||
|
||||
const test = base.extend<{}, WorkerFixtures>({
|
||||
storageState: async ({ authStorageStatePath }, use) => {
|
||||
await use(authStorageStatePath);
|
||||
},
|
||||
authStorageStatePath: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const storageStatePath = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"tmp",
|
||||
"playwright-auth",
|
||||
`patrol-assistant-operator-briefing-${workerInfo.project.name}.json`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
|
||||
await createAuthenticatedStorageState(browser, storageStatePath);
|
||||
try {
|
||||
await use(storageStatePath);
|
||||
} finally {
|
||||
fs.rmSync(storageStatePath, { force: true });
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
test.describe("Patrol Assistant operator briefing", () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test("shows attention and operator-decision context in the Assistant drawer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/resources**", async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
if (requestUrl.pathname !== "/api/resources") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "host:web-server",
|
||||
type: "host",
|
||||
name: "web-server",
|
||||
displayName: "web-server",
|
||||
status: "online",
|
||||
lastSeen: "2026-05-06T12:10:00Z",
|
||||
canonicalIdentity: {
|
||||
displayName: "web-server",
|
||||
hostname: "web-server",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ running: true, engine: "test" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/sessions", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/models", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
models: [{ id: "openai:gpt-4o-mini", name: "GPT-4o mini" }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/ai", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
model: "openai:gpt-4o-mini",
|
||||
chat_model: "",
|
||||
control_level: "read_only",
|
||||
discovery_enabled: true,
|
||||
patrol_enabled: true,
|
||||
patrol_interval_minutes: 360,
|
||||
patrol_model: "",
|
||||
alert_triggered_analysis: true,
|
||||
patrol_alert_triggers_enabled: true,
|
||||
patrol_anomaly_triggers_enabled: true,
|
||||
patrol_event_triggers_enabled: true,
|
||||
patrol_auto_fix: false,
|
||||
auto_fix_model: "",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/patrol/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
runtime_state: "active",
|
||||
running: false,
|
||||
enabled: true,
|
||||
last_patrol_at: "2026-05-06T12:00:00Z",
|
||||
last_activity_at: "2026-05-06T12:06:00Z",
|
||||
next_patrol_at: "2026-05-06T18:00:00Z",
|
||||
last_duration_ms: 180000,
|
||||
resources_checked: 12,
|
||||
findings_count: 1,
|
||||
error_count: 0,
|
||||
healthy: false,
|
||||
interval_ms: 21600000,
|
||||
fixed_count: 0,
|
||||
blocked_reason: "",
|
||||
blocked_at: "",
|
||||
license_required: false,
|
||||
license_status: "active",
|
||||
summary: {
|
||||
critical: 1,
|
||||
warning: 0,
|
||||
watch: 0,
|
||||
info: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/patrol/runs*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "run-operator-briefing",
|
||||
started_at: "2026-05-06T12:00:00Z",
|
||||
completed_at: "2026-05-06T12:03:00Z",
|
||||
duration_ms: 180000,
|
||||
type: "full",
|
||||
trigger_reason: "scheduled",
|
||||
resources_checked: 12,
|
||||
findings_summary: "1 finding",
|
||||
finding_ids: ["finding-operator-briefing"],
|
||||
error_count: 0,
|
||||
status: "warning",
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/patrol/autonomy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
autonomy_level: "monitor",
|
||||
full_mode_unlocked: false,
|
||||
investigation_budget: 15,
|
||||
investigation_timeout_sec: 300,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/unified/findings*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
findings: [
|
||||
{
|
||||
id: "finding-operator-briefing",
|
||||
source: "ai-patrol",
|
||||
severity: "critical",
|
||||
category: "performance",
|
||||
resource_id: "host:web-server",
|
||||
resource_name: "web-server",
|
||||
resource_type: "host",
|
||||
title: "High CPU usage",
|
||||
description: "CPU stayed above 95%.",
|
||||
detected_at: "2026-05-06T12:00:00Z",
|
||||
last_seen_at: "2026-05-06T12:06:00Z",
|
||||
status: "active",
|
||||
times_raised: 4,
|
||||
regression_count: 2,
|
||||
last_regression_at: "2026-05-06T12:06:00Z",
|
||||
loop_state: "awaiting_approval",
|
||||
remediation_id: "remediation-1",
|
||||
investigation_status: "completed",
|
||||
investigation_outcome: "fix_queued",
|
||||
investigation_attempts: 1,
|
||||
investigation_record: {
|
||||
id: "record-1",
|
||||
finding_id: "finding-operator-briefing",
|
||||
subject: {
|
||||
resource_id: "host:web-server",
|
||||
resource_name: "web-server",
|
||||
resource_type: "host",
|
||||
},
|
||||
trigger: {
|
||||
detected_at: "2026-05-06T12:00:00Z",
|
||||
title: "High CPU usage",
|
||||
},
|
||||
status: "completed",
|
||||
outcome: "fix_queued",
|
||||
confidence: "high",
|
||||
conclusion: "Backup job saturated CPU.",
|
||||
recommended_action:
|
||||
"Approve a controlled restart after the backup completes.",
|
||||
evidence: [
|
||||
{
|
||||
kind: "metrics",
|
||||
summary: "CPU stayed above 95% for 10 minutes",
|
||||
},
|
||||
],
|
||||
proposed_fix: {
|
||||
id: "fix-1",
|
||||
description: "Restart the workload service",
|
||||
commands: ["systemctl restart workload.service"],
|
||||
risk_level: "medium",
|
||||
destructive: true,
|
||||
},
|
||||
verification: ["CPU returned below 50%"],
|
||||
tools_used: [],
|
||||
started_at: "2026-05-06T12:00:00Z",
|
||||
approval_id: "approval-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
active_count: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/intelligence/correlations*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ correlations: [], count: 0 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/intelligence", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
timestamp: "2026-05-06T12:06:00Z",
|
||||
overall_health: {
|
||||
score: 62,
|
||||
grade: "D",
|
||||
trend: "degrading",
|
||||
factors: [],
|
||||
prediction: "web-server needs operator attention.",
|
||||
},
|
||||
findings_count: {
|
||||
critical: 1,
|
||||
warning: 0,
|
||||
watch: 0,
|
||||
info: 0,
|
||||
total: 1,
|
||||
},
|
||||
predictions_count: 0,
|
||||
recent_changes_count: 0,
|
||||
recent_changes: [],
|
||||
learning: {
|
||||
resources_with_knowledge: 0,
|
||||
total_notes: 0,
|
||||
resources_with_baselines: 0,
|
||||
patterns_detected: 0,
|
||||
correlations_learned: 0,
|
||||
incidents_tracked: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/circuit/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
state: "closed",
|
||||
can_patrol: true,
|
||||
consecutive_failures: 0,
|
||||
total_successes: 42,
|
||||
total_failures: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/approvals", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ approvals: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/ai/remediation/plans", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ plans: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();
|
||||
|
||||
await page.getByText("High CPU usage").click();
|
||||
const finding = page.locator("#finding-finding-operator-briefing");
|
||||
await finding
|
||||
.getByRole("button", { name: "Discuss with Assistant" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const assistantContext = page.getByLabel("Assistant context");
|
||||
await expect(assistantContext).toBeVisible();
|
||||
await expect(assistantContext).toContainText("Operator briefing attached");
|
||||
await expect(assistantContext).toContainText(
|
||||
"Attention: active critical finding; regressed 2 times; last regression 2026-05-06T12:06:00Z; loop awaiting approval; approval approval-1; destructive proposed fix; fix queued for governed review",
|
||||
);
|
||||
await expect(assistantContext).toContainText(
|
||||
"Decision: review governed approval approval-1 before execution; proposed fix fix-1; risk medium; destructive true",
|
||||
);
|
||||
await expect(assistantContext).toContainText(
|
||||
"Command details stay in approval context; destructive actions require governed approval.",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("systemctl restart workload.service"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user