Improve Pulse Assistant approval continuity

This commit is contained in:
rcourtman 2026-04-24 09:13:39 +01:00
parent b328ac297b
commit bd138beeca
53 changed files with 1813 additions and 704 deletions

View file

@ -156,6 +156,11 @@ an add-only capacity posture.
only authority for Patrol quickstart bootstrap: self-hosted installs use the
shared installation-scoped `activation.enc`, while entitled hosted lanes use
the signed entitlement lease already carried in canonical billing state.
Per-request `/api/ai/chat` execution-mode overrides follow that same
boundary: lifecycle-adjacent consumers may rely on Assistant approval
semantics, but scoped `autonomous_mode:false` chat requests must not be
reinterpreted as agent registration, assignment, installer, or connection
lifecycle state.
Lifecycle flows must not reintroduce anonymous bootstrap identity,
tenant-local commercial-owner surrogates, or fake activation records when
they traverse those shared handlers. They also must not infer tenant

View file

@ -889,6 +889,12 @@ recovery, and alert summaries and must pass those facts as structured context
when the operator asks Assistant to continue. Future server-generated dashboard
briefs must keep that structured fact contract and policy boundary rather than
letting an unbounded prompt become the dashboard's source of truth.
The dashboard-to-Assistant handoff must also keep its execution mode scoped to
the request. When a Pulse Brief opens Assistant from the dashboard, the drawer
may prefill the governed dashboard prompt and context, but the submitted chat
request must set `autonomous_mode:false`, preserve the operator's persistent
Assistant control-level setting, and disclose the temporary approval-required
mode in the drawer instead of showing the generic Autonomous warning.
Those backend AI and Patrol change summaries should derive their canonical
labels and provenance fragments from
`internal/unifiedresources/change_presentation.go`, so the resource-model

View file

@ -1401,6 +1401,12 @@ structured mention payloads for canonical `agent`, `vm`, `storage`, and
types, so VMware-backed reads stay on `/api/ai/*` and `/api/resources*`
instead of introducing VMware-only mention payloads or provider-local
inventory reads under `/api/vmware/*`.
That same `/api/ai/chat` payload boundary owns per-request execution-mode
overrides. Dashboard Pulse Brief and other scoped handoffs may include
`autonomous_mode:false` on the chat request to force approval-required command
execution for that exchange, but the transport must treat the field as a
request override only and must not mutate the user's persistent AI control
setting.
That same backend API boundary now also owns the negative space around
assistant control. Wiring native TrueNAS app actions into
`internal/api/router.go`, `internal/api/ai_handler.go`, or adjacent backend

View file

@ -573,6 +573,12 @@ work extends shared components instead of creating new local variants.
IDs into setup payloads. The shared settings shell should let the backend
resolve the effective BYOK model and then render that returned state rather
than guessing a model in the modal.
Scoped Assistant handoffs must keep request-local execution overrides in
drawer context. Dashboard and other route-owned entry points may open the
Assistant drawer with a pre-filled prompt, context, and
`autonomousMode:false`, but they must not mutate persistent AI control-level
settings or trigger background Assistant settings/model bootstrap before
the drawer is open.
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

View file

@ -174,6 +174,13 @@ That same shared policy now also owns Patrol approval polling posture.
empty queue from the shared store boundary itself, so dashboard and Patrol
shells do not probe `/api/ai/approvals` after the read-only demo policy has
resolved.
That shared store may retain all pending approvals for dashboard and Assistant
surfaces, but Patrol-owned presentation must consume only Patrol-scoped
approval selectors. `frontend-modern/src/components/AI/FindingsPanel.tsx` and
`frontend-modern/src/components/patrol/` must not count or render generic
Assistant command approvals as Patrol finding approvals, and dashboard
action-required affordances must use generic approval actions when the pending
request is not tied to a Patrol finding.
That same store-owned demo boundary also covers remediation artifacts.
`frontend-modern/src/stores/aiIntelligence.ts` must fail
`loadRemediationPlans()` closed in public demo mode and

View file

@ -167,6 +167,11 @@ regression protection.
and the already-loaded compact dashboard summary fallback; it must not
introduce a new `useUnifiedResources()` subscription, platform-specific
fetch, or chart/history request just to make the dashboard feel oriented.
Dashboard Pulse Brief handoff belongs to that existing route state too:
`frontend-modern/src/pages/Dashboard.tsx` may open Assistant with the
already-derived compact prompt and a per-request approval-required override,
but it must not introduce extra dashboard polling, model/settings fetches,
or chart/history reads before the Assistant drawer is actually used.
6. Normalize dashboard workload view-mode aliases through `frontend-modern/src/utils/workloads.ts` instead of keeping local URL/storage parsing in `frontend-modern/src/components/Dashboard/Dashboard.tsx`
7. Deduplicate dashboard workload rows by canonical workload ID from `frontend-modern/src/utils/workloads.ts` rather than via local pass-through wrappers in `frontend-modern/src/components/Dashboard/Dashboard.tsx`
8. Render dashboard row identity directly from the shared canonical workload helper so row selection, hover, and fallback metadata lookup stay aligned with the same workload contract

View file

@ -85,7 +85,9 @@ querying, and the operator-facing storage health presentation layer.
but must not redirect into storage or recovery ownership. Optional dashboard
Pulse Brief copy may summarize storage and recovery facts that are already on
the route, but it must not become the owner of storage capacity, storage
health, protected-item, or recovery-outcome readiness claims.
health, protected-item, or recovery-outcome readiness claims. The Assistant
handoff safety mode for that brief remains an AI runtime/API contract concern
and must not move storage or recovery readiness truth into model prose.
4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes
That same adjacent API boundary also owns TrueNAS feature-default semantics for
provider-backed recovery: storage and recovery must treat `truenas_disabled`

View file

@ -74,4 +74,39 @@ describe('AIChatAPI', () => {
expect(onEvent).toHaveBeenCalledWith({ type: 'done' });
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it('includes a per-request autonomous override when supplied', async () => {
const read = vi.fn().mockResolvedValueOnce({ done: true, value: undefined });
const releaseLock = vi.fn();
apiFetchMock.mockResolvedValueOnce({
ok: true,
body: {
getReader: () => ({ read, releaseLock }),
},
} as unknown as Response);
await AIChatAPI.chat(
'summarize dashboard',
'session-1',
undefined,
vi.fn(),
undefined,
undefined,
undefined,
false,
);
expect(apiFetchMock).toHaveBeenCalledWith(
'/api/ai/chat',
expect.objectContaining({
body: JSON.stringify({
prompt: 'summarize dashboard',
session_id: 'session-1',
model: undefined,
autonomous_mode: false,
}),
}),
);
});
});

View file

@ -113,7 +113,9 @@ export class AIAPI {
return {
...response,
findings: arrayOrEmpty<UnifiedFindingRecord>(response.findings).map((finding) =>
promoteLegacyAlertIdentifier(finding as UnifiedFindingRecord & { alert_identifier?: string }),
promoteLegacyAlertIdentifier(
finding as UnifiedFindingRecord & { alert_identifier?: string },
),
),
};
}
@ -303,23 +305,30 @@ export class AIAPI {
// Approve and execute an investigation fix
static async approveInvestigationFix(approvalId: string): Promise<ApprovalExecutionResult> {
return apiFetchJSON(
`${this.baseUrl}/ai/approvals/${encodeURIComponent(approvalId)}/approve`,
{
method: 'POST',
},
) as Promise<ApprovalExecutionResult>;
return apiFetchJSON(`${this.baseUrl}/ai/approvals/${encodeURIComponent(approvalId)}/approve`, {
method: 'POST',
}) as Promise<ApprovalExecutionResult>;
}
static async approvePendingApproval(approvalId: string): Promise<ApprovalDecisionResult> {
return apiFetchJSON(`${this.baseUrl}/ai/approvals/${encodeURIComponent(approvalId)}/approve`, {
method: 'POST',
}) as Promise<ApprovalDecisionResult>;
}
// Deny an investigation fix
static async denyInvestigationFix(approvalId: string, reason?: string): Promise<ApprovalRequest> {
return apiFetchJSON(
`${this.baseUrl}/ai/approvals/${encodeURIComponent(approvalId)}/deny`,
{
method: 'POST',
body: JSON.stringify({ reason: reason || 'User declined' }),
},
) as Promise<ApprovalRequest>;
return apiFetchJSON(`${this.baseUrl}/ai/approvals/${encodeURIComponent(approvalId)}/deny`, {
method: 'POST',
body: JSON.stringify({ reason: reason || 'User declined' }),
}) as Promise<ApprovalRequest>;
}
static async denyPendingApproval(approvalId: string, reason?: string): Promise<ApprovalRequest> {
return apiFetchJSON(`${this.baseUrl}/ai/approvals/${encodeURIComponent(approvalId)}/deny`, {
method: 'POST',
body: JSON.stringify({ reason: reason || 'User declined' }),
}) as Promise<ApprovalRequest>;
}
// Get investigation details for a finding (includes proposed fix)
@ -334,12 +343,9 @@ export class AIAPI {
static async reapproveInvestigationFix(
findingId: string,
): Promise<{ approval_id: string; message: string }> {
return apiFetchJSON(
`${this.baseUrl}/ai/findings/${encodeURIComponent(findingId)}/reapprove`,
{
method: 'POST',
},
) as Promise<{ approval_id: string; message: string }>;
return apiFetchJSON(`${this.baseUrl}/ai/findings/${encodeURIComponent(findingId)}/reapprove`, {
method: 'POST',
}) as Promise<{ approval_id: string; message: string }>;
}
}
@ -483,8 +489,40 @@ export interface CircuitBreakerStatus {
export type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'expired';
export type RiskLevel = 'low' | 'medium' | 'high';
export interface ApprovalActionPlan {
actionId?: string;
requestId?: string;
summary?: string;
message?: string;
requiresApproval?: boolean;
approvalPolicy?: string;
blastRadius?: string;
predictedBlastRadius?: string[];
rollbackAvailable?: boolean;
planHash?: string;
expiresAt?: string;
}
export interface ApprovalContextConfidence {
level?: string;
summary?: string;
evidence?: string[];
}
export interface ApprovalActionPreflight {
target?: string;
currentState?: string;
intendedChange?: string;
dryRunAvailable: boolean;
dryRunSummary?: string;
safetyChecks?: string[];
verificationSteps?: string[];
generatedAt?: string;
}
export interface ApprovalRequest {
id: string;
orgId?: string;
executionId?: string;
toolId: string; // "investigation_fix" for patrol findings
command: string;
@ -499,6 +537,11 @@ export interface ApprovalRequest {
decidedAt?: string;
decidedBy?: string;
denyReason?: string;
commandHash?: string;
consumed?: boolean;
plan?: ApprovalActionPlan;
contextConfidence?: ApprovalContextConfidence;
preflight?: ApprovalActionPreflight;
}
export interface ApprovalExecutionResult {
@ -512,6 +555,18 @@ export interface ApprovalExecutionResult {
message: string;
}
export type ApprovalDecisionResult =
| ApprovalExecutionResult
| {
approved: boolean;
executed?: boolean;
success?: boolean;
message?: string;
error?: string;
approval_id?: string;
request?: ApprovalRequest;
};
// ============================================
// Investigation Session Types
// ============================================

View file

@ -199,6 +199,7 @@ export class AIChatAPI {
signal?: AbortSignal,
mentions?: ChatMention[],
findingId?: string,
autonomousMode?: boolean,
): Promise<void> {
logger.debug('[AI Chat] Starting chat stream', { prompt: prompt.substring(0, 50) });
@ -213,6 +214,9 @@ export class AIChatAPI {
if (findingId) {
body.finding_id = findingId;
}
if (typeof autonomousMode === 'boolean') {
body.autonomous_mode = autonomousMode;
}
const response = await apiFetch(`${this.baseUrl}/chat`, {
method: 'POST',

View file

@ -23,6 +23,7 @@ export interface ApprovalNeededData {
audit_id?: string;
plan?: ApprovalPlanData;
context_confidence?: ApprovalContextConfidenceData;
preflight?: ApprovalPreflightData;
}
export interface ApprovalPlanData {
@ -37,6 +38,17 @@ export interface ApprovalPlanData {
expires_at?: string;
}
export interface ApprovalPreflightData {
target?: string;
current_state?: string;
intended_change?: string;
dry_run_available: boolean;
dry_run_summary?: string;
safety_checks?: string[];
verification_steps?: string[];
generated_at?: string;
}
export interface ContentData {
text: string;
}
@ -98,10 +110,18 @@ export interface ToolStartData {
raw_input?: string;
}
export interface WorkflowStateData {
phase: string;
message: string;
state?: string;
tool?: string;
}
export type AIChatStreamEvent =
| { type: 'content'; data: ContentData }
| { type: 'thinking'; data: ThinkingData }
| { type: 'explore_status'; data: ExploreStatusData }
| { type: 'workflow_state'; data: WorkflowStateData }
| { type: 'tool_start'; data: ToolStartData }
| { type: 'tool_end'; data: ToolEndData }
| { type: 'approval_needed'; data: ApprovalNeededData }

View file

@ -110,6 +110,7 @@ export const ApprovalCard: Component<ApprovalCardProps> = (props) => {
when={
props.approval.plan ||
props.approval.contextConfidence ||
props.approval.preflight ||
props.approval.auditId ||
props.approval.targetType ||
props.approval.targetId
@ -162,6 +163,81 @@ export const ApprovalCard: Component<ApprovalCardProps> = (props) => {
</Show>
</div>
<Show when={props.approval.preflight}>
<div class="pt-2 border-t border-amber-200 dark:border-amber-700">
<div class="flex items-center gap-2 mb-1">
<ShieldCheckIcon class="w-3.5 h-3.5 text-amber-700 dark:text-amber-300" />
<span class="text-[11px] font-semibold uppercase text-amber-700 dark:text-amber-300">
Preflight
</span>
<span class="px-1.5 py-0.5 rounded bg-base-200 text-[10px] font-bold uppercase text-base-content">
{props.approval.preflight?.dry_run_available ? 'Dry run' : 'No dry run'}
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[11px]">
<Show when={props.approval.preflight?.target}>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">
Target
</div>
<div class="text-base-content break-words">
{props.approval.preflight?.target}
</div>
</div>
</Show>
<Show when={props.approval.preflight?.intended_change}>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">
Intended change
</div>
<div class="text-base-content break-words">
{props.approval.preflight?.intended_change}
</div>
</div>
</Show>
</div>
<Show when={props.approval.preflight?.current_state}>
<p class="mt-1.5 leading-relaxed text-base-content">
{props.approval.preflight?.current_state}
</p>
</Show>
<Show when={props.approval.preflight?.dry_run_summary}>
<p class="mt-1.5 leading-relaxed text-base-content">
{props.approval.preflight?.dry_run_summary}
</p>
</Show>
<Show when={(props.approval.preflight?.safety_checks || []).length > 0}>
<div class="mt-2">
<div class="text-[11px] font-semibold uppercase text-amber-700 dark:text-amber-300">
Safety checks
</div>
<ul class="mt-1 space-y-1 text-[11px] text-base-content/80">
<For each={props.approval.preflight?.safety_checks || []}>
{(item) => <li class="break-words">{item}</li>}
</For>
</ul>
</div>
</Show>
<Show when={(props.approval.preflight?.verification_steps || []).length > 0}>
<div class="mt-2">
<div class="text-[11px] font-semibold uppercase text-amber-700 dark:text-amber-300">
Verification
</div>
<ul class="mt-1 space-y-1 text-[11px] text-base-content/80">
<For each={props.approval.preflight?.verification_steps || []}>
{(item) => <li class="break-words">{item}</li>}
</For>
</ul>
</div>
</Show>
</div>
</Show>
<Show when={props.approval.contextConfidence}>
<div class="pt-2 border-t border-amber-200 dark:border-amber-700">
<div class="flex items-center gap-2 mb-1">

View file

@ -2,6 +2,7 @@ import { Component, Show, For, Switch, Match, createMemo } from 'solid-js';
import { renderMarkdown } from '../aiChatUtils';
import { ThinkingBlock } from './ThinkingBlock';
import { ExploreStatusBlock } from './ExploreStatusBlock';
import { WorkflowStatusBlock } from './WorkflowStatusBlock';
import { ToolExecutionBlock } from './ToolExecutionBlock';
import { ApprovalCard } from './ApprovalCard';
import { QuestionCard } from './QuestionCard';
@ -53,6 +54,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
continue;
}
// Workflow status events are kept separate
if (evt.type === 'workflow') {
grouped.push(evt);
continue;
}
// Tool events are kept separate
if (evt.type === 'tool') {
grouped.push(evt);
@ -171,6 +178,10 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
<ExploreStatusBlock status={evt.exploreStatus!} />
</Match>
<Match when={evt.type === 'workflow' && evt.workflow}>
<WorkflowStatusBlock status={evt.workflow!} />
</Match>
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
<></>
</Match>

View file

@ -0,0 +1,94 @@
import { type Component } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import CircleCheckIcon from 'lucide-solid/icons/circle-check';
import CircleDashedIcon from 'lucide-solid/icons/circle-dashed';
import ClipboardCheckIcon from 'lucide-solid/icons/clipboard-check';
import HelpCircleIcon from 'lucide-solid/icons/help-circle';
import PlayIcon from 'lucide-solid/icons/play';
import SearchIcon from 'lucide-solid/icons/search';
import ShieldCheckIcon from 'lucide-solid/icons/shield-check';
import type { WorkflowStatus } from './types';
import { formatIdentifierLabel } from '@/utils/textPresentation';
interface WorkflowStatusBlockProps {
status: WorkflowStatus;
}
const phasePresentation = (phase: string) => {
switch (phase) {
case 'investigate':
return {
label: 'Investigating',
classes:
'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200',
Icon: SearchIcon,
};
case 'clarify':
return {
label: 'Clarifying',
classes:
'border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-800 dark:bg-sky-950/40 dark:text-sky-200',
Icon: HelpCircleIcon,
};
case 'plan':
return {
label: 'Planning',
classes:
'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200',
Icon: ClipboardCheckIcon,
};
case 'approve':
return {
label: 'Awaiting Approval',
classes:
'border-orange-200 bg-orange-50 text-orange-800 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-200',
Icon: ShieldCheckIcon,
};
case 'execute':
return {
label: 'Executing',
classes:
'border-indigo-200 bg-indigo-50 text-indigo-800 dark:border-indigo-800 dark:bg-indigo-950/40 dark:text-indigo-200',
Icon: PlayIcon,
};
case 'verify':
return {
label: 'Verifying',
classes:
'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200',
Icon: CircleDashedIcon,
};
case 'complete':
return {
label: 'Complete',
classes:
'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200',
Icon: CircleCheckIcon,
};
default:
return {
label: formatIdentifierLabel(phase || 'workflow'),
classes:
'border-border-subtle bg-surface text-base-content dark:border-border-subtle dark:bg-surface',
Icon: CircleDashedIcon,
};
}
};
export const WorkflowStatusBlock: Component<WorkflowStatusBlockProps> = (props) => {
const presentation = () => phasePresentation(props.status.phase);
const toolLabel = () =>
props.status.tool ? formatIdentifierLabel(props.status.tool, { stripPrefix: 'pulse_' }) : '';
return (
<div class={`my-2 rounded-md border px-3 py-2 text-xs ${presentation().classes}`}>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<Dynamic component={presentation().Icon} class="h-3.5 w-3.5" />
<span class="font-semibold uppercase">{presentation().label}</span>
{toolLabel() && <span class="font-mono opacity-80">{toolLabel()}</span>}
{props.status.state && <span class="font-mono opacity-75">state={props.status.state}</span>}
</div>
<p class="mt-1 leading-relaxed">{props.status.message}</p>
</div>
);
};

View file

@ -77,6 +77,7 @@ const {
context: {
initialPrompt: undefined as string | undefined,
findingId: undefined as string | undefined,
autonomousMode: undefined as boolean | undefined,
},
clearInitialPrompt: vi.fn(),
clearFindingId: vi.fn(),
@ -218,7 +219,11 @@ beforeEach(() => {
setViewportWidth(1440);
resetAIRuntimeState();
mockAiChatStore.isOpenSignal.mockReturnValue(true);
mockAiChatStore.context = { initialPrompt: undefined, findingId: undefined };
mockAiChatStore.context = {
initialPrompt: undefined,
findingId: undefined,
autonomousMode: undefined,
};
mockChat.messages.mockReturnValue([]);
mockChat.isLoading.mockReturnValue(false);
mockChat.sessionId.mockReturnValue('');
@ -943,6 +948,39 @@ describe('AIChat', () => {
expect(screen.getByText('Switch to Approval')).toBeInTheDocument();
});
});
it('keeps scoped dashboard handoffs approval-required without showing the autonomous warning', async () => {
mockAIAPI.getSettings.mockResolvedValue({
model: 'gpt-4',
chat_model: '',
control_level: 'autonomous',
autonomous_mode: true,
discovery_enabled: true,
});
mockAiChatStore.context = {
initialPrompt: undefined,
findingId: undefined,
autonomousMode: false,
};
renderChat();
await waitFor(() => {
expect(screen.getByText(/Approval required for this dashboard brief/)).toBeInTheDocument();
});
expect(screen.queryByText('Commands execute without approval.')).not.toBeInTheDocument();
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
fireEvent.input(textarea, { target: { value: 'summarize this dashboard' } });
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(mockChat.sendMessage).toHaveBeenCalledWith(
'summarize this dashboard',
undefined,
undefined,
{ autonomousMode: false },
);
});
});
// ── Discovery hint ───────────────────────────────────────────────────
@ -1028,7 +1066,11 @@ describe('AIChat', () => {
describe('finding ID context', () => {
it('passes findingId from store context on first message', () => {
mockAiChatStore.context = { initialPrompt: undefined, findingId: 'finding-123' };
mockAiChatStore.context = {
initialPrompt: undefined,
findingId: 'finding-123',
autonomousMode: undefined,
};
renderChat();
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
fireEvent.input(textarea, { target: { value: 'investigate this' } });

View file

@ -240,6 +240,16 @@ describe('ApprovalCard', () => {
summary: 'Target was resolved to a concrete resource before approval.',
evidence: ['Target identifier bound to agent-1.'],
},
preflight: {
target: 'agent:web1 (agent-1)',
current_state: 'Resolved approval target: agent:web1 (agent-1).',
intended_change: 'Restart nginx',
dry_run_available: false,
dry_run_summary: 'No provider-supported dry run is available for this action.',
safety_checks: ['Approval is scoped to this organization.'],
verification_steps: ['Read back the target state after execution.'],
generated_at: '2026-04-23T12:29:00Z',
},
})}
onApprove={vi.fn()}
onSkip={vi.fn()}
@ -251,6 +261,11 @@ describe('ApprovalCard', () => {
expect(screen.getByText('service interruption on target')).toBeInTheDocument();
expect(screen.getByText('VERIFIED')).toBeInTheDocument();
expect(screen.getByText('Target identifier bound to agent-1.')).toBeInTheDocument();
expect(screen.getByText('Preflight')).toBeInTheDocument();
expect(screen.getByText('No dry run')).toBeInTheDocument();
expect(screen.getByText('Restart nginx')).toBeInTheDocument();
expect(screen.getByText('Approval is scoped to this organization.')).toBeInTheDocument();
expect(screen.getByText('Read back the target state after execution.')).toBeInTheDocument();
expect(screen.getByText(/Audit action-123/)).toBeInTheDocument();
});
});

View file

@ -18,6 +18,12 @@ vi.mock('../ExploreStatusBlock', () => ({
),
}));
vi.mock('../WorkflowStatusBlock', () => ({
WorkflowStatusBlock: (props: { status: { phase: string; message: string } }) => (
<div data-testid="workflow-status-block">{props.status.message}</div>
),
}));
vi.mock('../ToolExecutionBlock', () => ({
ToolExecutionBlock: (props: {
tool: { name: string; input: string; output: string; success: boolean };
@ -365,6 +371,32 @@ describe('MessageItem', () => {
expect(screen.getByText('Scanning infrastructure...')).toBeInTheDocument();
});
it('renders workflow status blocks', () => {
const events: StreamDisplayEvent[] = [
{
type: 'workflow',
workflow: {
phase: 'approve',
message: 'Waiting for approval before executing the planned action.',
state: 'VERIFYING',
tool: 'pulse_exec',
},
},
];
render(() => (
<MessageItem
message={makeMessage({ role: 'assistant', streamEvents: events })}
{...makeHandlers()}
/>
));
expect(screen.getByTestId('workflow-status-block')).toBeInTheDocument();
expect(
screen.getByText('Waiting for approval before executing the planned action.'),
).toBeInTheDocument();
});
it('renders tool execution blocks', () => {
const events: StreamDisplayEvent[] = [
{

View file

@ -216,6 +216,20 @@ describe('useChat', () => {
dispose();
});
it('passes a scoped autonomous-mode override to the API', async () => {
mockChat.mockResolvedValue(undefined);
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
await chat.sendMessage('summarize dashboard', undefined, undefined, {
autonomousMode: false,
});
const chatCall = mockChat.mock.calls[0];
expect(chatCall[0]).toBe('summarize dashboard');
expect(chatCall[7]).toBe(false);
dispose();
});
it('aborts current stream when sending mid-stream', async () => {
// First call: capture signal so we can verify it was aborted
let capturedSignal: AbortSignal | undefined;
@ -418,6 +432,35 @@ describe('useChat', () => {
dispose();
});
it('processes workflow_state events', async () => {
const { getFireEvent } = setupWithEventCapture();
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
await chat.sendMessage('hi');
const fire = getFireEvent();
fire({
type: 'workflow_state',
data: {
phase: 'plan',
message: 'Planning governed action and safety checks before execution.',
state: 'READING',
tool: 'pulse_exec',
},
});
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
const workflowEvents = assistant.streamEvents?.filter((e) => e.type === 'workflow') ?? [];
expect(workflowEvents).toHaveLength(1);
expect(workflowEvents[0].workflow).toEqual({
phase: 'plan',
message: 'Planning governed action and safety checks before execution.',
state: 'READING',
tool: 'pulse_exec',
});
dispose();
});
it('processes tool_start events', async () => {
const { getFireEvent } = setupWithEventCapture();
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
@ -572,6 +615,16 @@ describe('useChat', () => {
summary: 'Target was resolved to a concrete resource before approval.',
evidence: ['Target identifier bound to agent-1.'],
},
preflight: {
target: 'agent:web1 (agent-1)',
current_state: 'Resolved approval target: agent:web1 (agent-1).',
intended_change: 'Restart web service',
dry_run_available: false,
dry_run_summary: 'No provider-supported dry run is available for this action.',
safety_checks: ['Approval is scoped to this organization.'],
verification_steps: ['Read back the target state after execution.'],
generated_at: '2026-04-23T12:29:00Z',
},
approval_id: 'appr-5',
},
});
@ -605,6 +658,16 @@ describe('useChat', () => {
summary: 'Target was resolved to a concrete resource before approval.',
evidence: ['Target identifier bound to agent-1.'],
},
preflight: {
target: 'agent:web1 (agent-1)',
current_state: 'Resolved approval target: agent:web1 (agent-1).',
intended_change: 'Restart web service',
dry_run_available: false,
dry_run_summary: 'No provider-supported dry run is available for this action.',
safety_checks: ['Approval is scoped to this organization.'],
verification_steps: ['Read back the target state after execution.'],
generated_at: '2026-04-23T12:29:00Z',
},
isExecuting: false,
approvalId: 'appr-5',
});

View file

@ -20,6 +20,10 @@ export interface UseChatOptions {
onConversationChanged?: () => void | Promise<void>;
}
export interface SendMessageOptions {
autonomousMode?: boolean;
}
export function useChat(options: UseChatOptions = {}) {
// Core state
const [messages, setMessages] = createSignal<ChatMessage[]>([]);
@ -178,6 +182,26 @@ export function useChat(options: UseChatOptions = {}) {
});
}
case 'workflow_state': {
const data = (event.data || {}) as {
phase?: string;
message?: string;
state?: string;
tool?: string;
};
const message = typeof data.message === 'string' ? data.message.trim() : '';
if (!message) return msg;
return addStreamEvent(msg, {
type: 'workflow',
workflow: {
phase: data.phase || 'unknown',
message,
state: data.state,
tool: data.tool,
},
});
}
case 'tool_start': {
const data = (event.data || {}) as {
id?: string;
@ -344,6 +368,16 @@ export function useChat(options: UseChatOptions = {}) {
summary?: string;
evidence?: string[];
};
preflight?: {
target?: string;
current_state?: string;
intended_change?: string;
dry_run_available: boolean;
dry_run_summary?: string;
safety_checks?: string[];
verification_steps?: string[];
generated_at?: string;
};
approval_id?: string;
};
@ -373,6 +407,9 @@ export function useChat(options: UseChatOptions = {}) {
if (data.context_confidence && typeof data.context_confidence === 'object') {
approval.contextConfidence = data.context_confidence;
}
if (data.preflight && typeof data.preflight === 'object') {
approval.preflight = data.preflight;
}
// Add to streamEvents for chronological display
const updated = addStreamEvent(msg, { type: 'approval', approval });
@ -451,6 +488,7 @@ export function useChat(options: UseChatOptions = {}) {
prompt: string,
mentions?: ChatMention[],
findingId?: string,
sendOptions?: SendMessageOptions,
): Promise<boolean> => {
if (!prompt.trim()) return false;
@ -521,6 +559,7 @@ export function useChat(options: UseChatOptions = {}) {
abortControllerRef?.signal,
mentions,
findingId,
sendOptions?.autonomousMode,
);
await notifyConversationChanged();
return true;

View file

@ -310,6 +310,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
}
});
const hasScopedApprovalHandoff = createMemo(() => aiChatStore.context.autonomousMode === false);
// Compute current status for display
const currentStatus = createMemo(() => {
if (!chat.isLoading()) return null;
@ -651,7 +653,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
// Pass findingId from context on the first message, clear after success
const ctx = aiChatStore.context;
const findingId = ctx.findingId;
chat.sendMessage(prompt, mentionsForAPI, findingId).then((ok) => {
const sendOptions =
typeof ctx.autonomousMode === 'boolean' ? { autonomousMode: ctx.autonomousMode } : undefined;
const sendPromise = sendOptions
? chat.sendMessage(prompt, mentionsForAPI, findingId, sendOptions)
: chat.sendMessage(prompt, mentionsForAPI, findingId);
sendPromise.then((ok) => {
if (ok && findingId) {
aiChatStore.clearFindingId?.();
}
@ -1090,7 +1097,22 @@ export const AIChat: Component<AIChatProps> = (props) => {
</div>
</div>
<Show when={controlLevel() === 'autonomous' && !autonomousBannerDismissed()}>
<Show when={hasScopedApprovalHandoff() && controlLevel() === 'autonomous'}>
<div class="px-4 py-2 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 flex items-center gap-2 text-[11px] text-blue-700 dark:text-blue-200">
<span>
Approval required for this dashboard brief. Commands will ask before running; your
default Assistant mode is unchanged.
</span>
</div>
</Show>
<Show
when={
controlLevel() === 'autonomous' &&
!autonomousBannerDismissed() &&
!hasScopedApprovalHandoff()
}
>
<div class="px-4 py-2 border-b border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900 flex items-center justify-between gap-3 text-[11px] text-red-700 dark:text-red-200">
<span>Commands execute without approval.</span>
<div class="flex items-center gap-2">

View file

@ -27,6 +27,7 @@ export interface PendingApproval {
auditId?: string;
plan?: ApprovalPlan;
contextConfidence?: ApprovalContextConfidence;
preflight?: ApprovalPreflight;
isExecuting?: boolean;
approvalId?: string; // ID of the approval record for API calls
}
@ -49,6 +50,17 @@ export interface ApprovalContextConfidence {
evidence?: string[];
}
export interface ApprovalPreflight {
target?: string;
current_state?: string;
intended_change?: string;
dry_run_available: boolean;
dry_run_summary?: string;
safety_checks?: string[];
verification_steps?: string[];
generated_at?: string;
}
// Question from Pulse Assistant
export interface QuestionOption {
label: string;
@ -77,10 +89,18 @@ export interface ExploreStatus {
outcome?: string;
}
export interface WorkflowStatus {
phase: string;
message: string;
state?: string;
tool?: string;
}
// Unified event for chronological display
export type StreamEventType =
| 'thinking'
| 'explore_status'
| 'workflow'
| 'tool'
| 'content'
| 'pending_tool'
@ -91,6 +111,7 @@ export interface StreamDisplayEvent {
type: StreamEventType;
thinking?: string;
exploreStatus?: ExploreStatus;
workflow?: WorkflowStatus;
tool?: ToolExecution;
pendingTool?: PendingTool;
content?: string;

File diff suppressed because it is too large Load diff

View file

@ -119,6 +119,9 @@ vi.mock('@/stores/aiIntelligence', () => ({
get pendingApprovalCount() {
return 0;
},
get patrolPendingApprovalCount() {
return 0;
},
get remediationPlans() {
return [];
},
@ -136,7 +139,10 @@ describe('FindingsPanel resource links', () => {
if (typeof window.requestAnimationFrame !== 'function') {
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
window.setTimeout(
() => callback(performance.now()),
0,
)) as typeof window.requestAnimationFrame;
}
});
@ -149,10 +155,7 @@ describe('FindingsPanel resource links', () => {
expect(
screen.getByRole('link', { name: 'Open related infrastructure for Nextcloud' }),
).toHaveAttribute(
'href',
'/infrastructure?resource=app-container%3Atruenas-main%3Anextcloud',
);
).toHaveAttribute('href', '/infrastructure?resource=app-container%3Atruenas-main%3Anextcloud');
expect(
screen.getByRole('link', { name: 'Open related workloads for Nextcloud' }),
).toHaveAttribute(

View file

@ -25,7 +25,7 @@ export const ApprovalBanner: Component<ApprovalBannerProps> = (props) => {
const [actionLoading, setActionLoading] = createSignal<string | null>(null);
const [tick, setTick] = createSignal(Date.now());
const pending = createMemo(() => aiIntelligenceStore.pendingApprovals);
const pending = createMemo(() => aiIntelligenceStore.patrolPendingApprovals);
// Only tick when there are pending approvals to avoid unnecessary work
createEffect(() => {

View file

@ -32,10 +32,8 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
// Find the pending approval for this finding from the store
const pendingApproval = createMemo(() => {
return (
aiIntelligenceStore.pendingApprovals.find(
(a: ApprovalRequest) =>
a.toolId === 'investigation_fix' &&
a.targetId === props.findingId,
aiIntelligenceStore.patrolPendingApprovals.find(
(a: ApprovalRequest) => a.toolId === 'investigation_fix' && a.targetId === props.findingId,
) ?? null
);
});
@ -437,16 +435,16 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
{fix.commands![0]}
</div>
</Show>
<Show when={fix.target_host}>
<div class="text-xs text-muted">Target: {fix.target_host}</div>
</Show>
</div>
{renderRecoveryActions('Fix with Assistant', (e) =>
handleFixWithAssistant(null, fix, e),
)}
</>
);
})()}
<Show when={fix.target_host}>
<div class="text-xs text-muted">Target: {fix.target_host}</div>
</Show>
</div>
{renderRecoveryActions('Fix with Assistant', (e) =>
handleFixWithAssistant(null, fix, e),
)}
</>
);
})()}
</Show>
{/* Queued approval with missing detail payload - keep recovery path visible */}

View file

@ -14,6 +14,9 @@ vi.mock('@/stores/aiIntelligence', () => ({
get pendingApprovals() {
return state.pendingApprovals;
},
get patrolPendingApprovals() {
return state.pendingApprovals;
},
get findingsWithPendingApprovals() {
return state.findingsWithPendingApprovals;
},

View file

@ -36,6 +36,9 @@ vi.mock('@/stores/aiIntelligence', () => ({
get pendingApprovals() {
return state.pendingApprovals;
},
get patrolPendingApprovals() {
return state.pendingApprovals;
},
approveInvestigationFix: (...args: unknown[]) => approveInvestigationFixMock(...args),
denyInvestigationFix: (...args: unknown[]) => denyInvestigationFixMock(...args),
},

View file

@ -55,14 +55,16 @@ function PendingApprovalRows(props: { approvals: ApprovalRequest[] }) {
const handleApprove = async (approval: ApprovalRequest) => {
setActionLoading(approval.id);
try {
const result = await aiIntelligenceStore.approveInvestigationFix(approval.id);
if (result?.success) {
notificationStore.success('Fix executed successfully');
const result = await aiIntelligenceStore.approvePendingApproval(approval.id);
if (!result) {
notificationStore.error('Failed to approve action');
} else if (result.success === false || result.approved === false) {
notificationStore.error(result?.error || result?.message || 'Approval execution failed');
} else {
notificationStore.error(result?.error || 'Fix execution failed');
notificationStore.success(result?.message || 'Approval granted');
}
} catch (err) {
notificationStore.error((err as Error).message || 'Failed to execute fix');
notificationStore.error((err as Error).message || 'Failed to approve action');
} finally {
setActionLoading(null);
}
@ -71,19 +73,36 @@ function PendingApprovalRows(props: { approvals: ApprovalRequest[] }) {
const handleDeny = async (approval: ApprovalRequest) => {
setActionLoading(approval.id);
try {
const success = await aiIntelligenceStore.denyInvestigationFix(approval.id);
const success = await aiIntelligenceStore.denyPendingApproval(approval.id);
if (success) {
notificationStore.success('Fix denied');
notificationStore.success('Approval denied');
} else {
notificationStore.error('Failed to deny fix');
notificationStore.error('Failed to deny approval');
}
} catch (err) {
notificationStore.error((err as Error).message || 'Failed to deny fix');
notificationStore.error((err as Error).message || 'Failed to deny approval');
} finally {
setActionLoading(null);
}
};
const approvalKindLabel = (approval: ApprovalRequest) =>
approval.toolId === 'investigation_fix' ? 'Patrol fix' : 'Assistant action';
const approvalTitle = (approval: ApprovalRequest) =>
approval.context ||
approval.plan?.summary ||
approval.plan?.message ||
approval.preflight?.intendedChange ||
approval.command;
const approvalDetail = (approval: ApprovalRequest) =>
approval.preflight?.dryRunSummary ||
approval.plan?.message ||
approval.plan?.summary ||
approval.targetName ||
approval.command;
return (
<div class="space-y-1.5">
<p class="text-[11px] font-semibold uppercase tracking-wide text-muted">Pending Approvals</p>
@ -92,19 +111,34 @@ function PendingApprovalRows(props: { approvals: ApprovalRequest[] }) {
{(approval) => {
const approvalRisk = getApprovalRiskPresentation(approval.riskLevel);
return (
<li class="flex items-center gap-2 py-1.5 px-2 -mx-2 rounded hover:bg-surface-hover transition-colors">
<li class="flex items-start gap-2 py-1.5 px-2 -mx-2 rounded hover:bg-surface-hover transition-colors">
<span
class={`shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded ${approvalRisk.badgeClass}`}
class={`mt-0.5 shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded ${approvalRisk.badgeClass}`}
>
{approvalRisk.label}
</span>
<p
class="min-w-0 text-xs text-base-content truncate flex-1"
title={approval.context}
>
{approval.context || approval.command}
</p>
<span class="shrink-0 text-[10px] font-mono text-amber-600 dark:text-amber-400">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 rounded bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted">
{approvalKindLabel(approval)}
</span>
<p
class="min-w-0 truncate text-xs text-base-content"
title={approvalTitle(approval)}
>
{approvalTitle(approval)}
</p>
</div>
<Show when={approvalDetail(approval) !== approvalTitle(approval)}>
<p
class="mt-0.5 truncate text-[11px] text-muted"
title={approvalDetail(approval)}
>
{approvalDetail(approval)}
</p>
</Show>
</div>
<span class="mt-0.5 shrink-0 text-[10px] font-mono text-amber-600 dark:text-amber-400">
{timeRemaining(approval.expiresAt)}
</span>
<div class="shrink-0 flex items-center gap-1">
@ -276,7 +310,10 @@ function FindingsAttentionRows(props: { findings: UnifiedFinding[] }) {
return (
<li class="flex items-center gap-2 py-1.5 px-2 -mx-2 rounded hover:bg-surface-hover transition-colors">
<span class={compactBadge.badgeClasses}>{compactBadge.label}</span>
<p class="min-w-0 text-xs font-medium text-base-content truncate flex-1" title={title}>
<p
class="min-w-0 text-xs font-medium text-base-content truncate flex-1"
title={title}
>
{title}
</p>
<Show when={finding.investigationOutcome}>
@ -358,7 +395,7 @@ function FindingsAttentionRows(props: { findings: UnifiedFinding[] }) {
// ─── Main Panel ─────────────────────────────────────────────────────
export function ActionRequiredPanel(props: ActionRequiredPanelProps) {
const hasPatrol = () => hasFeature('ai_patrol');
const hasApprovals = () => hasPatrol() && props.pendingApprovals.length > 0;
const hasApprovals = () => props.pendingApprovals.length > 0;
const hasAlerts = () => props.unackedCriticalAlerts.length > 0;
const hasFindings = () => hasPatrol() && props.findingsNeedingAttention.length > 0;
const hasAny = () => hasApprovals() || hasAlerts() || hasFindings();

View file

@ -83,6 +83,7 @@ describe('dashboard Pulse Brief model', () => {
expect(brief.body).toContain('no pending approvals, active alerts, or Patrol findings');
expect(brief.evidence).toContain('No active dashboard issues');
expect(brief.assistantPrompt).toContain('Use only these dashboard facts');
expect(brief.assistantPrompt).toContain('do not run commands or change anything');
});
it('prioritizes concrete problem resources before lower-level context', () => {

View file

@ -147,7 +147,7 @@ function buildAssistantPrompt(input: DashboardPulseBriefInput, body: string): st
.filter((label): label is string => label !== null);
return [
'Summarize the current Pulse dashboard for an operator. Use only these dashboard facts unless you need to ask for more context.',
'Summarize the current Pulse dashboard for an operator. Use only these dashboard facts unless you need to ask for more context, and do not run commands or change anything unless the operator explicitly asks for a follow-up action.',
'',
`Current brief: ${body}`,
`Systems: ${input.estate.totalSystems} total, ${input.estate.healthySystems} healthy, ${input.estate.attentionSystems} needing attention.`,

View file

@ -29,11 +29,13 @@ export function useDashboardActions(
(typeof window === 'undefined' || window.location.pathname === DASHBOARD_PATH);
const hasPatrol = () => enabled() && hasFeature('ai_patrol');
// Load patrol data on mount when feature is enabled
// Load dashboard action data on mount when the dashboard is active.
createEffect(() => {
if (enabled()) {
void aiIntelligenceStore.loadPendingApprovals();
}
if (hasPatrol()) {
void aiIntelligenceStore.loadFindings();
void aiIntelligenceStore.loadPendingApprovals();
}
});
@ -44,7 +46,7 @@ export function useDashboardActions(
window.clearInterval(refreshInterval);
refreshInterval = undefined;
}
if (hasPatrol()) {
if (enabled()) {
refreshInterval = window.setInterval(() => {
void aiIntelligenceStore.loadPendingApprovals();
}, APPROVAL_REFRESH_INTERVAL_MS);
@ -55,7 +57,7 @@ export function useDashboardActions(
});
const pendingApprovals = createMemo(() => {
if (!hasPatrol()) return [];
if (!enabled()) return [];
return aiIntelligenceStore.pendingApprovals;
});

View file

@ -149,6 +149,7 @@ export default function Dashboard() {
targetId: 'pulse-brief',
initialPrompt: brief.assistantPrompt,
context: brief.assistantContext,
autonomousMode: false,
});
};

View file

@ -464,6 +464,7 @@ describe('Dashboard page module contract', () => {
expect.objectContaining({
targetType: 'dashboard',
targetId: 'pulse-brief',
autonomousMode: false,
initialPrompt: expect.stringContaining('Summarize the current Pulse dashboard'),
context: expect.objectContaining({
dashboardBrief: expect.stringContaining('Review Container 1 (Offline) first'),

View file

@ -101,6 +101,23 @@ describe('aiChatStore', () => {
expect(aiChatStore.context.targetId).toBe('vm-101');
});
it('preserves scoped autonomous-mode overrides for pre-filled prompts', () => {
aiChatStore.openWithPrompt('brief me', {
targetType: 'dashboard',
targetId: 'pulse-brief',
autonomousMode: false,
});
expect(aiChatStore.isOpen).toBe(true);
expect(aiChatStore.context.initialPrompt).toBe('brief me');
expect(aiChatStore.context.autonomousMode).toBe(false);
aiChatStore.clearInitialPrompt();
expect(aiChatStore.context.initialPrompt).toBeUndefined();
expect(aiChatStore.context.autonomousMode).toBe(false);
});
it('focusInput returns false when closed and true when open with a registered element', () => {
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);

View file

@ -147,8 +147,12 @@ describe('aiIntelligenceStore', () => {
});
expect(aiIntelligenceStore.intelligenceSummary?.recent_changes).toHaveLength(1);
expect(aiIntelligenceStore.intelligenceSummary?.learning.correlations_learned).toBe(1);
expect(aiIntelligenceStore.intelligenceSummary?.policy_posture?.sensitivity_counts?.public).toBe(1);
expect(aiIntelligenceStore.intelligenceSummary?.policy_posture?.routing_counts?.['local-only']).toBe(1);
expect(
aiIntelligenceStore.intelligenceSummary?.policy_posture?.sensitivity_counts?.public,
).toBe(1);
expect(
aiIntelligenceStore.intelligenceSummary?.policy_posture?.routing_counts?.['local-only'],
).toBe(1);
});
it('normalizes the canonical intelligence summary at the store boundary', async () => {
@ -339,7 +343,7 @@ describe('aiIntelligenceStore', () => {
]);
});
it('keeps Patrol approval state scoped to investigation_fix approvals', async () => {
it('keeps Assistant approvals resumable while Patrol views stay scoped to investigation_fix approvals', async () => {
vi.mocked(AIAPI.getPendingApprovals).mockResolvedValueOnce([
{
id: 'approval-chat',
@ -372,9 +376,14 @@ describe('aiIntelligenceStore', () => {
await aiIntelligenceStore.loadPendingApprovals();
expect(aiIntelligenceStore.pendingApprovals.map((approval) => approval.id)).toEqual([
'approval-chat',
'approval-fix',
]);
expect(aiIntelligenceStore.pendingApprovalCount).toBe(1);
expect(aiIntelligenceStore.patrolPendingApprovals.map((approval) => approval.id)).toEqual([
'approval-fix',
]);
expect(aiIntelligenceStore.pendingApprovalCount).toBe(2);
expect(aiIntelligenceStore.patrolPendingApprovalCount).toBe(1);
});
it('fails Patrol approval polling closed in public demo mode', async () => {

View file

@ -10,6 +10,8 @@ interface AIChatContext {
context?: Record<string, unknown>;
initialPrompt?: string;
findingId?: string; // If opened from AI Insights "Get Help", the finding ID to resolve on success
// Per-request execution mode override; false keeps scoped handoffs approval-required.
autonomousMode?: boolean;
}
// A single context item that can be accumulated

View file

@ -17,6 +17,7 @@ import type {
UnifiedFindingRecord,
ApprovalRequest,
ApprovalExecutionResult,
ApprovalDecisionResult,
} from '@/api/ai';
import {
doesFindingNeedAttention,
@ -27,10 +28,7 @@ import {
import { getApprovalExpiryTime, isLivePendingApproval } from '@/utils/approvalState';
import { sortPendingApprovalsByUrgency } from '@/utils/approvalRiskPresentation';
import { logger } from '@/utils/logger';
import type {
CorrelationsResponse,
IntelligenceSummary,
} from '@/types/aiIntelligence';
import type { CorrelationsResponse, IntelligenceSummary } from '@/types/aiIntelligence';
import { normalizeIntelligenceSummary } from './aiIntelligenceSummaryModel';
import { presentationPolicyIsDemoMode } from './sessionPresentationPolicy';
@ -203,9 +201,12 @@ function syncPendingApprovalExpiryTimer(approvals: ApprovalRequest[]) {
return;
}
pendingApprovalExpiryTimer = setTimeout(() => {
syncPendingApprovalExpiryTimer(pendingApprovals());
}, Math.max(0, nextExpiry - Date.now() + 1));
pendingApprovalExpiryTimer = setTimeout(
() => {
syncPendingApprovalExpiryTimer(pendingApprovals());
},
Math.max(0, nextExpiry - Date.now() + 1),
);
}
function setPendingApprovalsWithExpiryTracking(approvals: ApprovalRequest[]) {
@ -218,6 +219,10 @@ function getLivePendingApprovals() {
return pendingApprovals().filter((approval) => isLivePendingApproval(approval, now));
}
function getLivePatrolPendingApprovals() {
return getLivePendingApprovals().filter(isPatrolInvestigationFixApproval);
}
// ============================================
// Circuit Breaker
// ============================================
@ -443,6 +448,9 @@ export const aiIntelligenceStore = {
get pendingApprovals() {
return sortPendingApprovalsByUrgency(getLivePendingApprovals());
},
get patrolPendingApprovals() {
return sortPendingApprovalsByUrgency(getLivePatrolPendingApprovals());
},
get approvalsError() {
return approvalsError();
},
@ -451,9 +459,12 @@ export const aiIntelligenceStore = {
get pendingApprovalCount() {
return getLivePendingApprovals().length;
},
get patrolPendingApprovalCount() {
return getLivePatrolPendingApprovals().length;
},
get findingsWithPendingApprovals() {
const approvals = getLivePendingApprovals();
const approvals = getLivePatrolPendingApprovals();
const approvalOrder = new Map(
sortPendingApprovalsByUrgency(approvals).map((approval, index) => [approval.targetId, index]),
);
@ -467,7 +478,7 @@ export const aiIntelligenceStore = {
},
get findingsNeedingAttention() {
const approvals = getLivePendingApprovals();
const approvals = getLivePatrolPendingApprovals();
return sortFindingsForAttentionQueue(
unifiedFindings().filter((finding) => doesFindingNeedAttention(finding, approvals)),
);
@ -484,7 +495,7 @@ export const aiIntelligenceStore = {
return;
}
try {
const approvals = (await AIAPI.getPendingApprovals()).filter(isPatrolInvestigationFixApproval);
const approvals = await AIAPI.getPendingApprovals();
setPendingApprovalsWithExpiryTracking(approvals);
} catch (e) {
logger.error('Failed to load pending approvals:', e);
@ -493,8 +504,13 @@ export const aiIntelligenceStore = {
},
async approveInvestigationFix(approvalId: string): Promise<ApprovalExecutionResult | null> {
const result = await this.approvePendingApproval(approvalId);
return result as ApprovalExecutionResult | null;
},
async approvePendingApproval(approvalId: string): Promise<ApprovalDecisionResult | null> {
try {
const result = await AIAPI.approveInvestigationFix(approvalId);
const result = await AIAPI.approvePendingApproval(approvalId);
await this.loadPendingApprovals();
await this.loadFindings();
return result;
@ -505,8 +521,12 @@ export const aiIntelligenceStore = {
},
async denyInvestigationFix(approvalId: string, reason?: string) {
return this.denyPendingApproval(approvalId, reason);
},
async denyPendingApproval(approvalId: string, reason?: string) {
try {
await AIAPI.denyInvestigationFix(approvalId, reason);
await AIAPI.denyPendingApproval(approvalId, reason);
await this.loadPendingApprovals();
await this.loadFindings();
return true;
@ -579,10 +599,7 @@ export const aiIntelligenceStore = {
// Initialize - load all data
async initialize() {
await Promise.all([
this.loadDashboardData(),
this.loadRemediationPlans(),
]);
await Promise.all([this.loadDashboardData(), this.loadRemediationPlans()]);
},
// Refresh all data

View file

@ -173,6 +173,7 @@ export type AIStreamEventType =
| 'error'
| 'complete'
| 'approval_needed'
| 'workflow_state'
| 'processing';
export interface AIStreamToolStartData {
@ -214,6 +215,23 @@ export interface AIStreamApprovalNeededData {
summary?: string;
evidence?: string[];
};
preflight?: {
target?: string;
current_state?: string;
intended_change?: string;
dry_run_available: boolean;
dry_run_summary?: string;
safety_checks?: string[];
verification_steps?: string[];
generated_at?: string;
};
}
export interface AIStreamWorkflowStateData {
phase: string;
message: string;
state?: string;
tool?: string;
}
export interface AIStreamEvent {
@ -223,7 +241,8 @@ export interface AIStreamEvent {
| AIStreamToolStartData
| AIStreamToolEndData
| AIStreamCompleteData
| AIStreamApprovalNeededData;
| AIStreamApprovalNeededData
| AIStreamWorkflowStateData;
}
export interface AIStreamCompleteData {

View file

@ -59,6 +59,19 @@ type ContextConfidence struct {
Evidence []string `json:"evidence,omitempty"`
}
// ActionPreflight is the deterministic pre-execution readout shown before
// approval. It is intentionally explicit when no provider dry-run exists.
type ActionPreflight struct {
Target string `json:"target,omitempty"`
CurrentState string `json:"currentState,omitempty"`
IntendedChange string `json:"intendedChange,omitempty"`
DryRunAvailable bool `json:"dryRunAvailable"`
DryRunSummary string `json:"dryRunSummary,omitempty"`
SafetyChecks []string `json:"safetyChecks,omitempty"`
VerificationSteps []string `json:"verificationSteps,omitempty"`
GeneratedAt time.Time `json:"generatedAt,omitempty"`
}
// ApprovalRequest represents a pending command awaiting user approval.
type ApprovalRequest struct {
ID string `json:"id"`
@ -85,6 +98,8 @@ type ApprovalRequest struct {
Plan *unifiedresources.ActionPlan `json:"plan,omitempty"`
// ContextConfidence records how strongly the action target was resolved.
ContextConfidence *ContextConfidence `json:"contextConfidence,omitempty"`
// Preflight records the pre-execution dry-run boundary and safety checks.
Preflight *ActionPreflight `json:"preflight,omitempty"`
}
// NormalizeOrgID normalizes tenant IDs used in approval records.

View file

@ -146,6 +146,16 @@ func TestCreateApproval_PreservesActionPlanAndContextConfidence(t *testing.T) {
Summary: "Target was resolved to a concrete resource before approval.",
Evidence: []string{"Target identifier bound to agent-1."},
},
Preflight: &ActionPreflight{
Target: "agent:web1 (agent-1)",
CurrentState: "Resolved approval target: agent:web1 (agent-1).",
IntendedChange: "Restart web service",
DryRunAvailable: false,
DryRunSummary: "No provider-supported dry run is available for this action.",
SafetyChecks: []string{"Approval is scoped to this organization."},
VerificationSteps: []string{"Read back the target state after execution."},
GeneratedAt: time.Now().UTC(),
},
}
if err := store.CreateApproval(req); err != nil {
@ -171,6 +181,18 @@ func TestCreateApproval_PreservesActionPlanAndContextConfidence(t *testing.T) {
if got.ContextConfidence == nil || got.ContextConfidence.Level != ContextConfidenceVerified {
t.Fatalf("unexpected context confidence: %+v", got.ContextConfidence)
}
if got.Preflight == nil {
t.Fatal("preflight was not preserved")
}
if got.Preflight.Target != "agent:web1 (agent-1)" {
t.Fatalf("preflight target = %q, want agent:web1 (agent-1)", got.Preflight.Target)
}
if got.Preflight.DryRunAvailable {
t.Fatal("preflight dry run should remain false")
}
if len(got.Preflight.SafetyChecks) != 1 {
t.Fatalf("preflight safety checks = %+v, want one entry", got.Preflight.SafetyChecks)
}
}
func TestCreateApproval_RejectsUnsupportedHostTargetType(t *testing.T) {

View file

@ -65,6 +65,32 @@ func isRetryableProviderStreamError(err error) bool {
return false
}
func emitWorkflowState(callback StreamCallback, phase, message, state, tool string) {
if callback == nil {
return
}
jsonData, _ := json.Marshal(WorkflowStateData{
Phase: phase,
Message: message,
State: state,
Tool: tool,
})
callback(StreamEvent{Type: "workflow_state", Data: jsonData})
}
func sessionFSMState(fsm *SessionFSM) string {
if fsm == nil {
return ""
}
return string(fsm.State)
}
func (a *AgenticLoop) currentFSMState() string {
a.mu.Lock()
defer a.mu.Unlock()
return sessionFSMState(a.sessionFSM)
}
func fallbackProviderStreamErrorMessage(err error) string {
const defaultMessage = "AI response stream interrupted before completion. Please retry."
if err == nil {
@ -449,6 +475,9 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
Str("session_id", sessionID).
Int("system_prompt_len", len(systemPrompt)).
Msg("[AgenticLoop] Calling provider.ChatStream")
if turn == 0 {
emitWorkflowState(callback, "investigate", "Inspecting infrastructure context and deciding the next step.", a.currentFSMState(), "")
}
const maxProviderAttempts = 2
err := error(nil)
@ -776,6 +805,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
}
log.Debug().Msg("agentic loop complete - no tool calls")
emitWorkflowState(callback, "complete", "Assistant response is ready.", sessionFSMState(fsm), "")
resultMessages = a.ensureFinalTextResponse(ctx, sessionID, resultMessages, providerMessages, callback)
return resultMessages, nil
}
@ -819,6 +849,8 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
}
}
if hasPulseQuestion {
emitWorkflowState(callback, "clarify", "Waiting for your answer before continuing.", sessionFSMState(fsm), pulseQuestionToolName)
for _, tc := range toolCalls {
log.Debug().
Str("tool", tc.Name).
@ -1189,6 +1221,19 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
// Tool execution is stateless I/O — safe to parallelize.
// Cap concurrency at 4 to avoid overwhelming infrastructure.
execResults := make([]parallelToolResult, len(pendingExec))
if len(pendingExec) > 0 {
executeMessage := "Running infrastructure checks."
workflowTool := pendingExec[0].tc.Name
for _, pe := range pendingExec {
if pe.toolKind == ToolKindWrite {
executeMessage = "Running the planned action through governed execution."
workflowTool = pe.tc.Name
emitWorkflowState(callback, "plan", "Planning governed action and safety checks before execution.", sessionFSMState(fsm), workflowTool)
break
}
}
emitWorkflowState(callback, "execute", executeMessage, sessionFSMState(fsm), workflowTool)
}
if len(pendingExec) > 1 {
log.Info().
@ -1357,6 +1402,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
AuditID string `json:"audit_id"`
Plan *ApprovalPlanData `json:"plan"`
ContextConfidence *ApprovalContextConfidenceData `json:"context_confidence"`
Preflight *ApprovalPreflightData `json:"preflight"`
}
if err := json.Unmarshal([]byte(approvalJSON), &approvalData); err != nil {
log.Error().Err(err).Str("data", approvalJSON).Msg("failed to parse approval request")
@ -1380,7 +1426,9 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
AuditID: approvalData.AuditID,
Plan: approvalData.Plan,
ContextConfidence: approvalData.ContextConfidence,
Preflight: approvalData.Preflight,
})
emitWorkflowState(callback, "approve", "Waiting for approval before executing the planned action.", sessionFSMState(fsm), tc.Name)
callback(StreamEvent{Type: "approval_needed", Data: jsonData})
// In autonomous mode (investigations), don't wait for approval.
@ -1409,12 +1457,14 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
if a.executor != nil {
a.executor.RecordApprovalDecision(approvalData.ApprovalID, unifiedresources.ActionStateFailed, "pulse_assistant", waitErr.Error())
}
emitWorkflowState(callback, "complete", "Approval wait ended before execution.", sessionFSMState(fsm), tc.Name)
resultText = fmt.Sprintf("Approval timeout or error: %v", waitErr)
isError = true
} else if decision.Status == approval.StatusApproved {
if a.executor != nil {
a.executor.RecordApprovalDecision(approvalData.ApprovalID, unifiedresources.ActionStateApproved, decision.DecidedBy, "approval granted")
}
emitWorkflowState(callback, "execute", "Approval granted. Executing the approved action.", sessionFSMState(fsm), tc.Name)
// Re-execute the tool with approval granted
// Add approval_id to input so tool knows this is pre-approved
inputWithApproval := make(map[string]interface{})
@ -1434,6 +1484,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
if a.executor != nil {
a.executor.RecordApprovalDecision(approvalData.ApprovalID, unifiedresources.ActionStateRejected, decision.DecidedBy, firstNonEmptyTrimmed(decision.DenyReason, "approval denied"))
}
emitWorkflowState(callback, "complete", "Approval denied. No action was executed.", sessionFSMState(fsm), tc.Name)
resultText = fmt.Sprintf("Command denied: %s", decision.DenyReason)
isError = false
}
@ -1465,6 +1516,9 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
// === FSM STATE TRANSITION: Update FSM after successful tool execution ===
if fsm != nil && !isError {
fsm.OnToolSuccess(toolKind, tc.Name)
if toolKind == ToolKindWrite && fsm.State == StateVerifying {
emitWorkflowState(callback, "verify", "Verifying the write before the Assistant responds.", sessionFSMState(fsm), tc.Name)
}
// If we just completed verification (read after write in VERIFYING), transition to READING
// This allows subsequent writes to proceed without being blocked

View file

@ -125,6 +125,14 @@ type ExploreStatusData struct {
Outcome string `json:"outcome,omitempty"` // success | failed | skipped_no_model | skipped_no_tools
}
// WorkflowStateData is the data for "workflow_state" events.
type WorkflowStateData struct {
Phase string `json:"phase"` // investigate | clarify | plan | approve | execute | verify | complete
Message string `json:"message"` // Human-readable status text for the UI
State string `json:"state,omitempty"` // Backend workflow state, when available
Tool string `json:"tool,omitempty"` // Tool associated with this transition
}
// ToolStartData is the data for "tool_start" events
type ToolStartData struct {
ID string `json:"id"`
@ -164,6 +172,18 @@ type ApprovalContextConfidenceData struct {
Evidence []string `json:"evidence,omitempty"`
}
// ApprovalPreflightData describes the pre-execution dry-run and verification boundary.
type ApprovalPreflightData struct {
Target string `json:"target,omitempty"`
CurrentState string `json:"current_state,omitempty"`
IntendedChange string `json:"intended_change,omitempty"`
DryRunAvailable bool `json:"dry_run_available"`
DryRunSummary string `json:"dry_run_summary,omitempty"`
SafetyChecks []string `json:"safety_checks,omitempty"`
VerificationSteps []string `json:"verification_steps,omitempty"`
GeneratedAt string `json:"generated_at,omitempty"`
}
// ApprovalNeededData is the data for "approval_needed" events
type ApprovalNeededData struct {
ApprovalID string `json:"approval_id"`
@ -179,6 +199,7 @@ type ApprovalNeededData struct {
AuditID string `json:"audit_id,omitempty"`
Plan *ApprovalPlanData `json:"plan,omitempty"`
ContextConfidence *ApprovalContextConfidenceData `json:"context_confidence,omitempty"`
Preflight *ApprovalPreflightData `json:"preflight,omitempty"`
}
// QuestionData is the data for "question" events

View file

@ -38,6 +38,9 @@ func TestCreateApprovalRecord(t *testing.T) {
assert.Equal(t, "ctx", req.Plan.Message)
require.NotNil(t, req.ContextConfidence)
assert.Equal(t, approval.ContextConfidenceVerified, req.ContextConfidence.Level)
require.NotNil(t, req.Preflight)
assert.Contains(t, req.Preflight.Target, "host1")
assert.Contains(t, req.Preflight.DryRunSummary, "dry run")
}
func TestIsPreApproved(t *testing.T) {

View file

@ -127,6 +127,13 @@ func TestPulseToolExecutor_ExecuteRunCommand(t *testing.T) {
assert.Equal(t, unifiedresources.ApprovalAdmin, req.Plan.ApprovalPolicy)
require.NotNil(t, req.ContextConfidence)
assert.Equal(t, approval.ContextConfidenceVerified, req.ContextConfidence.Level)
require.NotNil(t, req.Preflight)
assert.Contains(t, req.Preflight.Target, "tower")
assert.False(t, req.Preflight.DryRunAvailable)
preflight, ok := payload["preflight"].(map[string]interface{})
require.True(t, ok, "approval payload should include preflight")
assert.Equal(t, false, preflight["dry_run_available"])
assert.Contains(t, preflight["target"].(string), "tower")
audits, err := actionStore.GetActionAudits("", time.Time{}, 10)
require.NoError(t, err)

View file

@ -1401,6 +1401,7 @@ func createApprovalRecordForOrgWithExecutor(e *PulseToolExecutor, orgID, command
Plan: &plan,
}
req.ContextConfidence = approvalContextConfidence(req)
req.Preflight = approvalPreflight(req)
if err := store.CreateApproval(req); err != nil {
log.Warn().Err(err).Msg("failed to create approval record")
@ -1487,6 +1488,69 @@ func approvalContextConfidence(req *approval.ApprovalRequest) *approval.ContextC
}
}
func approvalPreflight(req *approval.ApprovalRequest) *approval.ActionPreflight {
if req == nil {
return nil
}
target := approvalPreflightTarget(req)
intendedChange := strings.TrimSpace(req.Context)
if intendedChange == "" {
intendedChange = strings.TrimSpace(req.Command)
}
dryRunAvailable := approvalDryRunAvailable(req.TargetType, req.Command)
dryRunSummary := "No provider-supported dry run is available for this action; Pulse will hold execution until approval and validate the approval binding before dispatch."
if dryRunAvailable {
dryRunSummary = "Provider dry-run semantics are available for this action class before execution."
}
return &approval.ActionPreflight{
Target: target,
CurrentState: fmt.Sprintf("Resolved approval target: %s.", target),
IntendedChange: intendedChange,
DryRunAvailable: dryRunAvailable,
DryRunSummary: dryRunSummary,
SafetyChecks: []string{
"Approval is scoped to the current organization.",
"Command hash must match before execution.",
"Approval can be consumed only once.",
"Target type and identifier must match the planned action.",
},
VerificationSteps: []string{
"Persist unified action audit lifecycle.",
"Dispatch only after approval is granted.",
"Capture command result or execution error.",
"Require Assistant read-after-write verification before final response.",
},
GeneratedAt: time.Now().UTC(),
}
}
func approvalPreflightTarget(req *approval.ApprovalRequest) string {
if req == nil {
return "unknown target"
}
parts := make([]string, 0, 3)
if targetType := strings.TrimSpace(req.TargetType); targetType != "" {
parts = append(parts, targetType)
}
if targetName := strings.TrimSpace(req.TargetName); targetName != "" {
parts = append(parts, targetName)
}
if targetID := strings.TrimSpace(req.TargetID); targetID != "" {
parts = append(parts, targetID)
}
if len(parts) == 0 {
return "Pulse runtime"
}
return strings.Join(parts, " / ")
}
func approvalDryRunAvailable(targetType, command string) bool {
targetType = strings.ToLower(strings.TrimSpace(targetType))
commandLower := strings.ToLower(strings.TrimSpace(command))
return targetType == "kubernetes" && strings.Contains(commandLower, "--dry-run")
}
// isPreApproved checks if the args contain a valid, approved approval_id.
// This is used when the agentic loop re-executes a tool after user approval.
// DEPRECATED: Use consumeApprovalWithValidation instead for replay protection.
@ -1600,6 +1664,18 @@ func enrichApprovalRequiredPayload(payload map[string]interface{}, approvalID st
"evidence": append([]string(nil), req.ContextConfidence.Evidence...),
}
}
if req.Preflight != nil {
payload["preflight"] = map[string]interface{}{
"target": strings.TrimSpace(req.Preflight.Target),
"current_state": strings.TrimSpace(req.Preflight.CurrentState),
"intended_change": strings.TrimSpace(req.Preflight.IntendedChange),
"dry_run_available": req.Preflight.DryRunAvailable,
"dry_run_summary": strings.TrimSpace(req.Preflight.DryRunSummary),
"safety_checks": append([]string(nil), req.Preflight.SafetyChecks...),
"verification_steps": append([]string(nil), req.Preflight.VerificationSteps...),
"generated_at": req.Preflight.GeneratedAt.UTC().Format(time.RFC3339),
}
}
return payload
}

View file

@ -728,11 +728,12 @@ func canonicalizeChatMentionType(raw string) string {
// ChatRequest represents a chat request
type ChatRequest struct {
Prompt string `json:"prompt"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
Mentions []ChatMention `json:"mentions,omitempty"`
FindingID string `json:"finding_id,omitempty"`
Prompt string `json:"prompt"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
Mentions []ChatMention `json:"mentions,omitempty"`
FindingID string `json:"finding_id,omitempty"`
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
}
// HandleChat handles POST /api/ai/chat - streaming chat
@ -915,11 +916,12 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
// Stream from AI chat service
serviceSentDone := false
err := svc.ExecuteStream(ctx, chat.ExecuteRequest{
Prompt: prompt,
SessionID: req.SessionID,
Model: req.Model,
Mentions: chatMentions,
FindingID: req.FindingID,
Prompt: prompt,
SessionID: req.SessionID,
Model: req.Model,
Mentions: chatMentions,
FindingID: req.FindingID,
AutonomousMode: req.AutonomousMode,
}, func(event chat.StreamEvent) {
if event.Type == "done" {
serviceSentDone = true

View file

@ -520,6 +520,32 @@ func TestHandleChat_PreservesCanonicalMentionTypes(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_PassesAutonomousModeOverride(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.
On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
reqArg := args.Get(1).(chat.ExecuteRequest)
if assert.NotNil(t, reqArg.AutonomousMode) {
assert.False(t, *reqArg.AutonomousMode)
}
})
body := `{"prompt":"summarize dashboard","autonomous_mode":false}`
req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleChat(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_DropsLegacyMentionTypes(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)

View file

@ -6493,11 +6493,33 @@ func getAuthUsername(cfg *config.Config, r *http.Request) string {
}
// ============================================================================
// Approval Workflow Handlers (Pro Feature)
// Approval Workflow Handlers
// ============================================================================
// HandleListApprovals has been moved to enterprise.
// The route now delegates to aiAutoFixEndpoints.HandleListApprovals.
// HandleListApprovals returns pending approval requests for the current org.
// Pulse Assistant command approvals are a core chat workflow; investigation fix
// approval execution remains gated by the auto-fix endpoint when approved.
func (h *AISettingsHandler) HandleListApprovals(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
store := approval.GetStore()
if store == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil)
return
}
orgID := approval.NormalizeOrgID(GetOrgID(r.Context()))
response := map[string]interface{}{
"approvals": store.GetPendingApprovalsForOrg(orgID),
"stats": store.GetStatsForOrg(orgID),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleGetApproval returns a specific approval request.
func (h *AISettingsHandler) HandleGetApproval(w http.ResponseWriter, r *http.Request) {

View file

@ -2249,6 +2249,23 @@ func TestAISettingsHandler_Approvals(t *testing.T) {
assert.Equal(t, appID, resp.ID)
})
t.Run("HandleListApprovals", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals", nil)
rec := httptest.NewRecorder()
handler.HandleListApprovals(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Approvals []approval.ApprovalRequest `json:"approvals"`
Stats map[string]int `json:"stats"`
}
err := json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
require.Len(t, resp.Approvals, 1)
assert.Equal(t, appID, resp.Approvals[0].ID)
assert.Equal(t, 1, resp.Stats["pending"])
})
t.Run("HandleApproveCommand", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/ai/approvals/"+appID+"/approve", nil)
rec := httptest.NewRecorder()

View file

@ -9683,6 +9683,16 @@ func TestContract_ChatStreamEventJSONSnapshots(t *testing.T) {
}),
want: `{"type":"explore_status","data":{"phase":"started","message":"Explore pre-pass running (read-only context).","model":"openai:explore-fast"}}`,
},
{
name: "workflow_state",
event: mustStreamEvent(t, "workflow_state", chat.WorkflowStateData{
Phase: "plan",
Message: "Planning governed action and safety checks before execution.",
State: "READING",
Tool: "pulse_exec",
}),
want: `{"type":"workflow_state","data":{"phase":"plan","message":"Planning governed action and safety checks before execution.","state":"READING","tool":"pulse_exec"}}`,
},
{
name: "tool_start",
event: mustStreamEvent(t, "tool_start", chat.ToolStartData{
@ -9735,8 +9745,18 @@ func TestContract_ChatStreamEventJSONSnapshots(t *testing.T) {
Summary: "Target was resolved to a concrete resource before approval.",
Evidence: []string{"Target identifier bound to agent-1."},
},
Preflight: &chat.ApprovalPreflightData{
Target: "agent:node-1 (agent-1)",
CurrentState: "Resolved approval target: agent:node-1 (agent-1).",
IntendedChange: "Restart web service",
DryRunAvailable: false,
DryRunSummary: "No provider-supported dry run is available for this action.",
SafetyChecks: []string{"Approval is scoped to this organization."},
VerificationSteps: []string{"Read back the target state after execution."},
GeneratedAt: "2026-04-23T12:29:00Z",
},
}),
want: `{"type":"approval_needed","data":{"approval_id":"approval-1","tool_id":"tool-2","tool_name":"pulse_exec","command":"systemctl restart nginx","run_on_host":true,"target_host":"node-1","target_type":"agent","target_id":"agent-1","risk":"high","description":"Restart web service","audit_id":"action-1","plan":{"action_id":"action-1","request_id":"approval-1","summary":"Restart web service","requires_approval":true,"approval_policy":"admin","blast_radius":"service interruption on target","rollback_available":true,"plan_hash":"hash-1","expires_at":"2026-04-23T12:30:00Z"},"context_confidence":{"level":"verified","summary":"Target was resolved to a concrete resource before approval.","evidence":["Target identifier bound to agent-1."]}}}`,
want: `{"type":"approval_needed","data":{"approval_id":"approval-1","tool_id":"tool-2","tool_name":"pulse_exec","command":"systemctl restart nginx","run_on_host":true,"target_host":"node-1","target_type":"agent","target_id":"agent-1","risk":"high","description":"Restart web service","audit_id":"action-1","plan":{"action_id":"action-1","request_id":"approval-1","summary":"Restart web service","requires_approval":true,"approval_policy":"admin","blast_radius":"service interruption on target","rollback_available":true,"plan_hash":"hash-1","expires_at":"2026-04-23T12:30:00Z"},"context_confidence":{"level":"verified","summary":"Target was resolved to a concrete resource before approval.","evidence":["Target identifier bound to agent-1."]},"preflight":{"target":"agent:node-1 (agent-1)","current_state":"Resolved approval target: agent:node-1 (agent-1).","intended_change":"Restart web service","dry_run_available":false,"dry_run_summary":"No provider-supported dry run is available for this action.","safety_checks":["Approval is scoped to this organization."],"verification_steps":["Read back the target state after execution."],"generated_at":"2026-04-23T12:29:00Z"}}}`,
},
{
name: "question",

View file

@ -31,7 +31,7 @@ func (r *Router) registerAIRelayRoutesGroup() {
// the free adapters return 402 for all premium operations. Enterprise binders
// replace these with real handler implementations.
r.aiAutoFixEndpoints = resolveAIAutoFixEndpoints(
aiAutoFixFreeAdapter{},
aiAutoFixFreeAdapter{handler: r.aiSettingsHandler},
newAIAutoFixRuntime(r),
)
r.aiAlertAnalysisEndpoints = resolveAIAlertAnalysisEndpoints(
@ -218,7 +218,9 @@ func (r *Router) registerAIRelayRoutesGroup() {
// All methods return 402 "requires Pulse Pro". Enterprise binders replace this
// with real handler implementations.
type aiAutoFixFreeAdapter struct{}
type aiAutoFixFreeAdapter struct {
handler *AISettingsHandler
}
var _ extensions.AIAutoFixEndpoints = aiAutoFixFreeAdapter{}
@ -258,7 +260,11 @@ func (aiAutoFixFreeAdapter) HandleApproveInvestigationFix(w http.ResponseWriter,
WriteLicenseRequired(w, featureAIAutoFixKey, "Auto-Fix requires Pulse Pro")
}
func (aiAutoFixFreeAdapter) HandleListApprovals(w http.ResponseWriter, _ *http.Request) {
func (a aiAutoFixFreeAdapter) HandleListApprovals(w http.ResponseWriter, req *http.Request) {
if a.handler != nil {
a.handler.HandleListApprovals(w, req)
return
}
WriteLicenseRequired(w, featureAIAutoFixKey, "Approval management requires Pulse Pro")
}
@ -448,14 +454,17 @@ func (a *approvalStoreAdapter) CreateApproval(info *aicontracts.ApprovalInfo) er
return fmt.Errorf("approval store not initialized")
}
req := &approval.ApprovalRequest{
OrgID: info.OrgID,
ToolID: info.ToolID,
Command: info.Command,
TargetType: info.TargetType,
TargetID: info.TargetID,
TargetName: info.TargetName,
Context: info.Context,
RiskLevel: approval.RiskLevel(info.RiskLevel),
OrgID: info.OrgID,
ToolID: info.ToolID,
Command: info.Command,
TargetType: info.TargetType,
TargetID: info.TargetID,
TargetName: info.TargetName,
Context: info.Context,
RiskLevel: approval.RiskLevel(info.RiskLevel),
Plan: approvalPlanInfoToRequest(info.Plan),
ContextConfidence: contextConfidenceInfoToRequest(info.ContextConfidence),
Preflight: preflightInfoToRequest(info.Preflight),
}
if err := store.CreateApproval(req); err != nil {
return err
@ -496,24 +505,123 @@ func approvalRequestToInfo(req *approval.ApprovalRequest) *aicontracts.ApprovalI
return nil
}
return &aicontracts.ApprovalInfo{
ID: req.ID,
OrgID: req.OrgID,
ExecutionID: req.ExecutionID,
ToolID: req.ToolID,
Command: req.Command,
TargetType: req.TargetType,
TargetID: req.TargetID,
TargetName: req.TargetName,
Context: req.Context,
RiskLevel: string(req.RiskLevel),
Status: string(req.Status),
RequestedAt: req.RequestedAt,
ExpiresAt: req.ExpiresAt,
DecidedAt: req.DecidedAt,
DecidedBy: req.DecidedBy,
DenyReason: req.DenyReason,
CommandHash: req.CommandHash,
Consumed: req.Consumed,
ID: req.ID,
OrgID: req.OrgID,
ExecutionID: req.ExecutionID,
ToolID: req.ToolID,
Command: req.Command,
TargetType: req.TargetType,
TargetID: req.TargetID,
TargetName: req.TargetName,
Context: req.Context,
RiskLevel: string(req.RiskLevel),
Status: string(req.Status),
RequestedAt: req.RequestedAt,
ExpiresAt: req.ExpiresAt,
DecidedAt: req.DecidedAt,
DecidedBy: req.DecidedBy,
DenyReason: req.DenyReason,
CommandHash: req.CommandHash,
Consumed: req.Consumed,
Plan: approvalPlanRequestToInfo(req.Plan),
ContextConfidence: contextConfidenceRequestToInfo(req.ContextConfidence),
Preflight: preflightRequestToInfo(req.Preflight),
}
}
func approvalPlanRequestToInfo(plan *unifiedresources.ActionPlan) *aicontracts.ActionPlanInfo {
if plan == nil {
return nil
}
return &aicontracts.ActionPlanInfo{
ActionID: plan.ActionID,
RequestID: plan.RequestID,
Allowed: plan.Allowed,
RequiresApproval: plan.RequiresApproval,
ApprovalPolicy: string(plan.ApprovalPolicy),
PredictedBlastRadius: append([]string(nil), plan.PredictedBlastRadius...),
RollbackAvailable: plan.RollbackAvailable,
Message: plan.Message,
PlannedAt: plan.PlannedAt,
ExpiresAt: plan.ExpiresAt,
ResourceVersion: plan.ResourceVersion,
PolicyVersion: plan.PolicyVersion,
PlanHash: plan.PlanHash,
}
}
func approvalPlanInfoToRequest(plan *aicontracts.ActionPlanInfo) *unifiedresources.ActionPlan {
if plan == nil {
return nil
}
return &unifiedresources.ActionPlan{
ActionID: plan.ActionID,
RequestID: plan.RequestID,
Allowed: plan.Allowed,
RequiresApproval: plan.RequiresApproval,
ApprovalPolicy: unifiedresources.ActionApprovalLevel(plan.ApprovalPolicy),
PredictedBlastRadius: append([]string(nil), plan.PredictedBlastRadius...),
RollbackAvailable: plan.RollbackAvailable,
Message: plan.Message,
PlannedAt: plan.PlannedAt,
ExpiresAt: plan.ExpiresAt,
ResourceVersion: plan.ResourceVersion,
PolicyVersion: plan.PolicyVersion,
PlanHash: plan.PlanHash,
}
}
func contextConfidenceRequestToInfo(conf *approval.ContextConfidence) *aicontracts.ContextConfidenceInfo {
if conf == nil {
return nil
}
return &aicontracts.ContextConfidenceInfo{
Level: string(conf.Level),
Summary: conf.Summary,
Evidence: append([]string(nil), conf.Evidence...),
}
}
func contextConfidenceInfoToRequest(conf *aicontracts.ContextConfidenceInfo) *approval.ContextConfidence {
if conf == nil {
return nil
}
return &approval.ContextConfidence{
Level: approval.ContextConfidenceLevel(conf.Level),
Summary: conf.Summary,
Evidence: append([]string(nil), conf.Evidence...),
}
}
func preflightRequestToInfo(preflight *approval.ActionPreflight) *aicontracts.ActionPreflightInfo {
if preflight == nil {
return nil
}
return &aicontracts.ActionPreflightInfo{
Target: preflight.Target,
CurrentState: preflight.CurrentState,
IntendedChange: preflight.IntendedChange,
DryRunAvailable: preflight.DryRunAvailable,
DryRunSummary: preflight.DryRunSummary,
SafetyChecks: append([]string(nil), preflight.SafetyChecks...),
VerificationSteps: append([]string(nil), preflight.VerificationSteps...),
GeneratedAt: preflight.GeneratedAt,
}
}
func preflightInfoToRequest(preflight *aicontracts.ActionPreflightInfo) *approval.ActionPreflight {
if preflight == nil {
return nil
}
return &approval.ActionPreflight{
Target: preflight.Target,
CurrentState: preflight.CurrentState,
IntendedChange: preflight.IntendedChange,
DryRunAvailable: preflight.DryRunAvailable,
DryRunSummary: preflight.DryRunSummary,
SafetyChecks: append([]string(nil), preflight.SafetyChecks...),
VerificationSteps: append([]string(nil), preflight.VerificationSteps...),
GeneratedAt: preflight.GeneratedAt,
}
}

View file

@ -15,24 +15,60 @@ import (
// OSS/enterprise boundary. It mirrors the core approval.ApprovalRequest
// fields needed by enterprise handlers and the frontend UI.
type ApprovalInfo struct {
ID string `json:"id"`
OrgID string `json:"orgId,omitempty"`
ExecutionID string `json:"executionId"`
ToolID string `json:"toolId"`
Command string `json:"command"`
TargetType string `json:"targetType"`
TargetID string `json:"targetId"`
TargetName string `json:"targetName"`
Context string `json:"context"`
RiskLevel string `json:"riskLevel"`
Status string `json:"status"`
RequestedAt time.Time `json:"requestedAt"`
ExpiresAt time.Time `json:"expiresAt"`
DecidedAt *time.Time `json:"decidedAt,omitempty"`
DecidedBy string `json:"decidedBy,omitempty"`
DenyReason string `json:"denyReason,omitempty"`
CommandHash string `json:"commandHash,omitempty"`
Consumed bool `json:"consumed,omitempty"`
ID string `json:"id"`
OrgID string `json:"orgId,omitempty"`
ExecutionID string `json:"executionId"`
ToolID string `json:"toolId"`
Command string `json:"command"`
TargetType string `json:"targetType"`
TargetID string `json:"targetId"`
TargetName string `json:"targetName"`
Context string `json:"context"`
RiskLevel string `json:"riskLevel"`
Status string `json:"status"`
RequestedAt time.Time `json:"requestedAt"`
ExpiresAt time.Time `json:"expiresAt"`
DecidedAt *time.Time `json:"decidedAt,omitempty"`
DecidedBy string `json:"decidedBy,omitempty"`
DenyReason string `json:"denyReason,omitempty"`
CommandHash string `json:"commandHash,omitempty"`
Consumed bool `json:"consumed,omitempty"`
Plan *ActionPlanInfo `json:"plan,omitempty"`
ContextConfidence *ContextConfidenceInfo `json:"contextConfidence,omitempty"`
Preflight *ActionPreflightInfo `json:"preflight,omitempty"`
}
type ActionPlanInfo struct {
ActionID string `json:"actionId,omitempty"`
RequestID string `json:"requestId,omitempty"`
Allowed bool `json:"allowed"`
RequiresApproval bool `json:"requiresApproval"`
ApprovalPolicy string `json:"approvalPolicy,omitempty"`
PredictedBlastRadius []string `json:"predictedBlastRadius,omitempty"`
RollbackAvailable bool `json:"rollbackAvailable"`
Message string `json:"message,omitempty"`
PlannedAt time.Time `json:"plannedAt,omitempty"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
ResourceVersion string `json:"resourceVersion,omitempty"`
PolicyVersion string `json:"policyVersion,omitempty"`
PlanHash string `json:"planHash,omitempty"`
}
type ContextConfidenceInfo struct {
Level string `json:"level,omitempty"`
Summary string `json:"summary,omitempty"`
Evidence []string `json:"evidence,omitempty"`
}
type ActionPreflightInfo struct {
Target string `json:"target,omitempty"`
CurrentState string `json:"currentState,omitempty"`
IntendedChange string `json:"intendedChange,omitempty"`
DryRunAvailable bool `json:"dryRunAvailable"`
DryRunSummary string `json:"dryRunSummary,omitempty"`
SafetyChecks []string `json:"safetyChecks,omitempty"`
VerificationSteps []string `json:"verificationSteps,omitempty"`
GeneratedAt time.Time `json:"generatedAt,omitempty"`
}
// ApprovalStoreAccessor provides approval operations for enterprise handlers.

View file

@ -36,10 +36,12 @@ func main() {
reflect.TypeOf(chat.ContentData{}),
reflect.TypeOf(chat.ThinkingData{}),
reflect.TypeOf(chat.ExploreStatusData{}),
reflect.TypeOf(chat.WorkflowStateData{}),
reflect.TypeOf(chat.ToolStartData{}),
reflect.TypeOf(chat.ToolEndData{}),
reflect.TypeOf(chat.ApprovalPlanData{}),
reflect.TypeOf(chat.ApprovalContextConfidenceData{}),
reflect.TypeOf(chat.ApprovalPreflightData{}),
reflect.TypeOf(chat.ApprovalNeededData{}),
reflect.TypeOf(chat.QuestionData{}),
reflect.TypeOf(chat.Question{}),
@ -71,6 +73,7 @@ func main() {
buf.WriteString(" | { type: 'content'; data: ContentData }\n")
buf.WriteString(" | { type: 'thinking'; data: ThinkingData }\n")
buf.WriteString(" | { type: 'explore_status'; data: ExploreStatusData }\n")
buf.WriteString(" | { type: 'workflow_state'; data: WorkflowStateData }\n")
buf.WriteString(" | { type: 'tool_start'; data: ToolStartData }\n")
buf.WriteString(" | { type: 'tool_end'; data: ToolEndData }\n")
buf.WriteString(" | { type: 'approval_needed'; data: ApprovalNeededData }\n")