mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Improve Pulse Assistant approval continuity
This commit is contained in:
parent
b328ac297b
commit
bd138beeca
53 changed files with 1813 additions and 704 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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' } });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ vi.mock('@/stores/aiIntelligence', () => ({
|
|||
get pendingApprovals() {
|
||||
return state.pendingApprovals;
|
||||
},
|
||||
get patrolPendingApprovals() {
|
||||
return state.pendingApprovals;
|
||||
},
|
||||
get findingsWithPendingApprovals() {
|
||||
return state.findingsWithPendingApprovals;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ export default function Dashboard() {
|
|||
targetId: 'pulse-brief',
|
||||
initialPrompt: brief.assistantPrompt,
|
||||
context: brief.assistantContext,
|
||||
autonomousMode: false,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue