吸收数字员工入口路由干预回写
This commit is contained in:
@@ -30,6 +30,9 @@ import type {
|
||||
CreatePlanRequest,
|
||||
RouteDecisionTimelineResponse,
|
||||
RouteDecisionPolicyPullResult,
|
||||
RouteInterventionDecisionRequest,
|
||||
RouteInterventionDecisionResponse,
|
||||
RouteInterventionTimelineResponse,
|
||||
IngressRouteRequest,
|
||||
IngressRouteResponse,
|
||||
GovernanceCommandRequest,
|
||||
@@ -496,6 +499,20 @@ export function getRouteDecisionTimeline(): Promise<RouteDecisionTimelineRespons
|
||||
return apiFetch<RouteDecisionTimelineResponse>('/api/route-decisions');
|
||||
}
|
||||
|
||||
export function getRouteInterventions(): Promise<RouteInterventionTimelineResponse> {
|
||||
return apiFetch<RouteInterventionTimelineResponse>('/api/route-decisions/interventions');
|
||||
}
|
||||
|
||||
export function decideRouteIntervention(
|
||||
routeDecisionId: string,
|
||||
input: RouteInterventionDecisionRequest,
|
||||
): Promise<RouteInterventionDecisionResponse> {
|
||||
return apiFetch<RouteInterventionDecisionResponse>(`/api/route-decisions/${encodeURIComponent(routeDecisionId)}/intervention`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function routeIngress(
|
||||
body: IngressRouteRequest,
|
||||
): Promise<IngressRouteResponse> {
|
||||
|
||||
@@ -42,6 +42,10 @@ import type {
|
||||
RouteDecisionPolicyPullResult,
|
||||
RouteDecisionRecord,
|
||||
RouteDecisionTimelineResponse,
|
||||
RouteInterventionDecisionRequest,
|
||||
RouteInterventionDecisionResponse,
|
||||
RouteInterventionRecord,
|
||||
RouteInterventionTimelineResponse,
|
||||
SkillSpec,
|
||||
UserNotification,
|
||||
} from '@/types/api';
|
||||
@@ -78,7 +82,9 @@ declare global {
|
||||
dispatchPlanReadySteps?: (planId: string) => Promise<ReadyStepDispatchResult>;
|
||||
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
|
||||
listRouteDecisions?: (options?: { limit?: number }) => Promise<RouteDecisionRecord[]>;
|
||||
listRouteInterventions?: (options?: { limit?: number }) => Promise<RouteInterventionRecord[]>;
|
||||
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
|
||||
recordRouteInterventionResolution?: (input: QimingclawRouteInterventionResolutionInput) => Promise<RouteInterventionRecord>;
|
||||
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
||||
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
|
||||
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
|
||||
@@ -377,6 +383,7 @@ interface QimingclawRouteDecisionInput {
|
||||
}
|
||||
|
||||
interface QimingclawRouteDecisionPolicyInput {
|
||||
routeDecisionId?: string | null;
|
||||
routeKind?: string | null;
|
||||
intentKind?: string | null;
|
||||
reasonCodes?: string[] | null;
|
||||
@@ -408,6 +415,30 @@ interface QimingclawRouteDecisionPolicyResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawRouteInterventionResolutionInput {
|
||||
routeDecisionId: string;
|
||||
approvalId?: string | null;
|
||||
decision: string;
|
||||
createdCommandId?: string | null;
|
||||
commandAccepted?: boolean | null;
|
||||
commandError?: string | null;
|
||||
blockedReason?: string | null;
|
||||
policyResult?: string | null;
|
||||
policySource?: string | null;
|
||||
matchedIntent?: string | null;
|
||||
routeKind?: string | null;
|
||||
intentKind?: string | null;
|
||||
reasonCodes?: string[] | null;
|
||||
candidateSkills?: string[] | null;
|
||||
contextRefs?: Record<string, unknown> | null;
|
||||
entrypoint?: string | null;
|
||||
actor?: string | null;
|
||||
correlationId?: string | null;
|
||||
message?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
occurredAt?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawRiskPolicyInput {
|
||||
actionKind?: string | null;
|
||||
actionLabel?: string | null;
|
||||
@@ -903,6 +934,17 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && pathname === '/api/route-decisions') {
|
||||
return await readRouteDecisions(Number(url.searchParams.get('limit')) || 80) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/route-decisions/interventions') {
|
||||
return await readRouteInterventions(Number(url.searchParams.get('limit')) || 80) as T;
|
||||
}
|
||||
const routeInterventionMatch = pathname.match(/^\/api\/route-decisions\/([^/]+)\/intervention$/);
|
||||
if (method === 'POST' && routeInterventionMatch) {
|
||||
return await handleRouteInterventionDecision(
|
||||
decodeURIComponent(routeInterventionMatch[1] || ''),
|
||||
parseJsonBody<RouteInterventionDecisionRequest>(options.body),
|
||||
await readQimingclawSnapshot(),
|
||||
) as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/ingress/route') {
|
||||
return await handleIngressRoute(parseJsonBody<IngressRouteRequest>(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
@@ -1251,6 +1293,9 @@ async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: num
|
||||
}
|
||||
try {
|
||||
const result = await bridge.pullApprovalUpdates();
|
||||
if (result?.ok !== false) {
|
||||
await resolveApprovedRouteInterventions(await readQimingclawSnapshot());
|
||||
}
|
||||
return {
|
||||
ok: result?.ok !== false,
|
||||
applied: Number(result?.applied ?? 0),
|
||||
@@ -1263,6 +1308,24 @@ async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: num
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApprovedRouteInterventions(snapshot: QimingclawSnapshot | null): Promise<void> {
|
||||
const timeline = await readRouteInterventions(120);
|
||||
const approved = timeline.interventions.filter((item) => (
|
||||
item.intervention_status === 'approved'
|
||||
&& item.approval_id
|
||||
&& !item.resolved_at
|
||||
)).slice(0, 5);
|
||||
for (const intervention of approved) {
|
||||
await handleRouteInterventionDecision(intervention.route_decision_id, {
|
||||
approval_id: intervention.approval_id,
|
||||
decision: 'approved',
|
||||
actor: 'management-api',
|
||||
}, snapshot).catch((error) => {
|
||||
console.warn('[qimingclawAdapter] auto route intervention resolution failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateBridgeRiskPolicy(input: QimingclawRiskPolicyInput): Promise<QimingclawRiskPolicyResult> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.evaluateRiskPolicy) {
|
||||
@@ -3228,12 +3291,19 @@ function buildRouteDecisionSummary(snapshot: QimingclawSnapshot | null): Digital
|
||||
const rows = routeDecisionRows(snapshot);
|
||||
const latest = rows[0] ?? null;
|
||||
const policy = routeDecisionPolicy(snapshot?.uiState ?? readState());
|
||||
const resolutionEvents = runtimeEvents(snapshot).filter((event) => event.kind === 'route_decision_intervention_resolved');
|
||||
const resolvedRouteIds = new Set(resolutionEvents.map((event) => stringValue(asRecord(event.payload)?.route_decision_id)).filter(Boolean));
|
||||
const latestResolution = resolutionEvents.sort((left, right) => right.occurredAt.localeCompare(left.occurredAt))[0] ?? null;
|
||||
const latestResolutionPayload = asRecord(latestResolution?.payload) ?? null;
|
||||
return {
|
||||
total: rows.length,
|
||||
mapped_count: rows.filter((row) => stringValue(row.created_command_id)).length,
|
||||
attention_count: rows.filter((row) => ['warn', 'error'].includes(stringValue(row.attention_level))).length,
|
||||
blocked_count: rows.filter((row) => stringValue(row.policy_result) === 'blocked').length,
|
||||
approval_required_count: rows.filter((row) => stringValue(row.policy_result) === 'approval_required').length,
|
||||
pending_intervention_count: rows.filter((row) => stringValue(row.policy_result) !== 'accepted' && !resolvedRouteIds.has(stringValue(row.route_decision_id))).length,
|
||||
restored_count: resolutionEvents.filter((event) => asRecord(event.payload)?.command_accepted === true).length,
|
||||
latest_intervention_status: stringValue(latestResolutionPayload?.status ?? latestResolutionPayload?.decision) || null,
|
||||
policy_source: policy.source ?? null,
|
||||
policy_last_pulled_at: policy.last_pulled_at ?? null,
|
||||
policy_last_pull_error: policy.last_pull_error ?? null,
|
||||
@@ -5846,6 +5916,224 @@ async function readRouteDecisions(limit = 80): Promise<RouteDecisionTimelineResp
|
||||
}
|
||||
}
|
||||
|
||||
async function readRouteInterventions(limit = 80): Promise<RouteInterventionTimelineResponse> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.listRouteInterventions) {
|
||||
return { has_route_intervention_state: false, interventions: [] };
|
||||
}
|
||||
try {
|
||||
const interventions = await bridge.listRouteInterventions({ limit });
|
||||
return { has_route_intervention_state: true, interventions: interventions ?? [] };
|
||||
} catch (error) {
|
||||
console.warn('[qimingclawAdapter] list route interventions failed:', error);
|
||||
return { has_route_intervention_state: false, interventions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRouteInterventionDecision(
|
||||
routeDecisionId: string,
|
||||
body: RouteInterventionDecisionRequest,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<RouteInterventionDecisionResponse> {
|
||||
const decisionText = stringValue(body.decision) || 'reject';
|
||||
const approvalId = stringValue(body.approval_id) || routeInterventionApprovalId(routeDecisionId, snapshot);
|
||||
const existing = await readRouteInterventions(160);
|
||||
const existingRecord = existing.interventions.find((item) => item.route_decision_id === routeDecisionId) ?? null;
|
||||
const detail = routeDecisionDetail(routeDecisionId, snapshot);
|
||||
const routeDecision = detail?.decision ?? routeInterventionToRouteDecision(existingRecord, routeDecisionId);
|
||||
if (!routeDecision) {
|
||||
const intervention = await recordRouteInterventionResolution({
|
||||
routeDecisionId,
|
||||
approvalId,
|
||||
decision: decisionText,
|
||||
commandAccepted: false,
|
||||
commandError: 'route_intervention_missing_context',
|
||||
actor: body.actor ?? 'digital_employee_operator',
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
return { accepted: false, route_decision_id: routeDecisionId, intervention, error: 'invalid_context', message: '入口路由上下文缺失,无法恢复执行。' };
|
||||
}
|
||||
|
||||
await recordLocalRouteApprovalDecision(approvalId, decisionText, body.actor ?? 'digital_employee_operator', snapshot);
|
||||
|
||||
let command: GovernanceCommandResponse | null = null;
|
||||
let commandError: string | null = null;
|
||||
if (isApprovedInterventionDecision(decisionText)) {
|
||||
const routeCommand = buildGovernanceCommandForRouteDecision(routeDecision, detail?.body ?? routeDecisionToIngressBody(routeDecision), snapshot);
|
||||
if (routeCommand.command) {
|
||||
command = await handleGovernanceCommand(routeCommand.command, snapshot);
|
||||
if (!command.accepted) commandError = command.error || 'route_intervention_command_rejected';
|
||||
} else {
|
||||
commandError = routeCommand.reasonCode || 'route_intervention_missing_context';
|
||||
}
|
||||
}
|
||||
|
||||
const intervention = await recordRouteInterventionResolution({
|
||||
routeDecisionId,
|
||||
approvalId,
|
||||
decision: decisionText,
|
||||
createdCommandId: command?.command_id ?? null,
|
||||
commandAccepted: command?.accepted === true,
|
||||
commandError,
|
||||
blockedReason: routeDecision.blocked_reason ?? null,
|
||||
policyResult: routeDecision.policy_result ?? null,
|
||||
policySource: routeDecision.policy_source ?? null,
|
||||
matchedIntent: routeDecision.matched_intent ?? null,
|
||||
routeKind: routeDecision.route_kind,
|
||||
intentKind: routeDecision.intent_kind,
|
||||
reasonCodes: routeDecision.reason_codes,
|
||||
candidateSkills: routeDecision.candidate_skills,
|
||||
contextRefs: routeDecision.context_refs,
|
||||
entrypoint: routeDecision.entrypoint ?? null,
|
||||
actor: body.actor ?? 'digital_employee_operator',
|
||||
correlationId: routeDecision.correlation_id,
|
||||
payload: { comment: body.comment ?? null },
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
accepted: intervention.intervention_status !== 'failed',
|
||||
route_decision_id: routeDecisionId,
|
||||
intervention,
|
||||
command,
|
||||
error: commandError || undefined,
|
||||
message: intervention.resolution_duplicate
|
||||
? '入口路由干预已处理。'
|
||||
: isApprovedInterventionDecision(decisionText)
|
||||
? command?.command_id ? '入口路由干预已通过并恢复执行。' : '入口路由干预已通过。'
|
||||
: '入口路由干预已拒绝。',
|
||||
};
|
||||
}
|
||||
|
||||
async function recordRouteInterventionResolution(input: QimingclawRouteInterventionResolutionInput): Promise<RouteInterventionRecord> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.recordRouteInterventionResolution) {
|
||||
return {
|
||||
route_decision_id: input.routeDecisionId,
|
||||
approval_id: input.approvalId ?? null,
|
||||
route_kind: input.routeKind ?? 'ChatOnly',
|
||||
intent_kind: input.intentKind ?? 'chat',
|
||||
policy_result: input.policyResult ?? null,
|
||||
intervention_status: input.commandError ? 'failed' : isApprovedInterventionDecision(input.decision) ? 'approved' : 'rejected',
|
||||
decision: input.decision,
|
||||
created_command_id: input.createdCommandId ?? null,
|
||||
blocked_reason: input.blockedReason ?? null,
|
||||
matched_intent: input.matchedIntent ?? null,
|
||||
policy_source: input.policySource ?? null,
|
||||
reason_codes: input.reasonCodes ?? [],
|
||||
candidate_skills: input.candidateSkills ?? [],
|
||||
context_refs: input.contextRefs ?? {},
|
||||
entrypoint: input.entrypoint ?? null,
|
||||
actor: input.actor ?? null,
|
||||
correlation_id: input.correlationId ?? null,
|
||||
approval_status: null,
|
||||
recorded_at: input.occurredAt ?? new Date().toISOString(),
|
||||
resolved_at: input.occurredAt ?? new Date().toISOString(),
|
||||
resolution_duplicate: false,
|
||||
};
|
||||
}
|
||||
return bridge.recordRouteInterventionResolution(input);
|
||||
}
|
||||
|
||||
function routeDecisionDetail(routeDecisionId: string, snapshot: QimingclawSnapshot | null): { decision: RouteDecisionRecord; body: IngressRouteRequest } | null {
|
||||
const event = runtimeEvents(snapshot).find((item) => {
|
||||
if (item.kind !== 'route_decision') return false;
|
||||
const payload = asRecord(item.payload) ?? {};
|
||||
const decision = routeDecisionFromRuntimePayload(payload, item);
|
||||
return decision.id === routeDecisionId;
|
||||
});
|
||||
if (!event) return null;
|
||||
const payload = asRecord(event.payload) ?? {};
|
||||
const decision = routeDecisionFromRuntimePayload(payload, event);
|
||||
const envelope = asRecord(payload.envelope) ?? {};
|
||||
return {
|
||||
decision,
|
||||
body: {
|
||||
entrypoint: decision.entrypoint as IngressRouteRequest['entrypoint'] ?? 'chat',
|
||||
actor: decision.actor ?? undefined,
|
||||
text: stringValue(envelope.text) || undefined,
|
||||
payload: asRecord(envelope.payload) ?? {},
|
||||
attachments: arrayValue(envelope.attachments).filter((item): item is string => typeof item === 'string'),
|
||||
timestamp: decision.recorded_at,
|
||||
correlation_id: decision.correlation_id,
|
||||
idempotency_key: `route-intervention:${decision.id}`,
|
||||
context_refs: decision.context_refs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function routeInterventionToRouteDecision(record: RouteInterventionRecord | null, routeDecisionId: string): RouteDecisionRecord | null {
|
||||
if (!record) return null;
|
||||
return {
|
||||
id: routeDecisionId,
|
||||
route_kind: record.route_kind,
|
||||
intent_kind: record.intent_kind,
|
||||
reason_codes: record.reason_codes ?? [],
|
||||
candidate_skills: record.candidate_skills ?? [],
|
||||
context_refs: record.context_refs ?? {},
|
||||
created_command_id: record.created_command_id,
|
||||
correlation_id: record.correlation_id || routeDecisionId,
|
||||
creates_plan: record.route_kind === 'PlanControl' || record.route_kind === 'PlanExecution',
|
||||
creates_task_run: record.route_kind === 'DirectRun' || record.route_kind === 'PlanExecution',
|
||||
approval_mode: record.approval_id ? 'manual' : null,
|
||||
policy_result: record.policy_result,
|
||||
policy_source: record.policy_source,
|
||||
blocked_reason: record.blocked_reason,
|
||||
approval_id: record.approval_id,
|
||||
matched_intent: record.matched_intent,
|
||||
recorded_at: record.recorded_at,
|
||||
entrypoint: record.entrypoint,
|
||||
actor: record.actor,
|
||||
};
|
||||
}
|
||||
|
||||
function routeDecisionToIngressBody(decision: RouteDecisionRecord): IngressRouteRequest {
|
||||
return {
|
||||
entrypoint: decision.entrypoint as IngressRouteRequest['entrypoint'] ?? 'chat',
|
||||
actor: decision.actor ?? undefined,
|
||||
payload: {},
|
||||
attachments: [],
|
||||
timestamp: decision.recorded_at,
|
||||
correlation_id: decision.correlation_id,
|
||||
idempotency_key: `route-intervention:${decision.id}`,
|
||||
context_refs: decision.context_refs,
|
||||
};
|
||||
}
|
||||
|
||||
function routeInterventionApprovalId(routeDecisionId: string, snapshot: QimingclawSnapshot | null): string | null {
|
||||
const detail = routeDecisionDetail(routeDecisionId, snapshot);
|
||||
if (detail?.decision.approval_id) return detail.decision.approval_id;
|
||||
const row = routeDecisionRows(snapshot).find((item) => stringValue(item.route_decision_id) === routeDecisionId);
|
||||
return stringValue(row?.approval_id) || null;
|
||||
}
|
||||
|
||||
async function recordLocalRouteApprovalDecision(
|
||||
approvalId: string | null,
|
||||
decision: string,
|
||||
actor: string,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<void> {
|
||||
if (!approvalId) return;
|
||||
const date = todayInputDate();
|
||||
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
|
||||
state.decisionResultsByDate = state.decisionResultsByDate ?? {};
|
||||
state.decisionResultsByDate[date] = {
|
||||
...(state.decisionResultsByDate[date] ?? {}),
|
||||
[approvalId]: decision,
|
||||
};
|
||||
await saveState(state, 'decide_approval', date, { decision_id: approvalId, decision, actor });
|
||||
await window.QimingClawBridge?.digital?.submitApprovalDecision?.({
|
||||
approval_id: approvalId,
|
||||
decision,
|
||||
date,
|
||||
actor,
|
||||
source: 'sgrobot-route-intervention',
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
function isApprovedInterventionDecision(decision: string): boolean {
|
||||
return ['approve', 'approved', 'accept', 'accepted'].includes(decision.toLowerCase());
|
||||
}
|
||||
|
||||
async function handleIngressRoute(
|
||||
body: IngressRouteRequest,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
@@ -5881,6 +6169,7 @@ async function handleIngressRoute(
|
||||
actor: body.actor ?? null,
|
||||
};
|
||||
const routePolicy = await evaluateBridgeRouteDecisionPolicy({
|
||||
routeDecisionId: decision.id,
|
||||
routeKind: decision.route_kind,
|
||||
intentKind: decision.intent_kind,
|
||||
reasonCodes: decision.reason_codes,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
dispatchPlanReadySteps,
|
||||
getDigitalEmployeeWorkday,
|
||||
getPlanStepGraph,
|
||||
getRouteInterventions,
|
||||
getSchedulerJobs,
|
||||
governanceCommand,
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
pullRiskApprovalPolicy,
|
||||
pullRouteDecisionPolicy,
|
||||
recordApprovalAction,
|
||||
decideRouteIntervention,
|
||||
restartManagedService,
|
||||
} from '@/lib/api';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
@@ -33,6 +35,7 @@ import type {
|
||||
GovernanceCommandKind,
|
||||
PlanStepBusinessRow,
|
||||
PlanStepDependencyDetail,
|
||||
RouteInterventionRecord,
|
||||
} from '@/types/api';
|
||||
import type { DigitalNavigationTarget } from './navigation';
|
||||
|
||||
@@ -990,6 +993,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
const [blockedPlanSteps, setBlockedPlanSteps] = useState<PlanStepBusinessRow[]>([]);
|
||||
const [planStepGraphRows, setPlanStepGraphRows] = useState<PlanStepBusinessRow[]>([]);
|
||||
const [blockedPlanStepError, setBlockedPlanStepError] = useState<string | null>(null);
|
||||
const [routeInterventions, setRouteInterventions] = useState<RouteInterventionRecord[]>([]);
|
||||
const [routeInterventionError, setRouteInterventionError] = useState<string | null>(null);
|
||||
const [blockedInputTarget, setBlockedInputTarget] = useState<BlockedPlanStepInputTarget | null>(null);
|
||||
const [blockedInputDraft, setBlockedInputDraft] = useState('');
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
@@ -1076,7 +1081,19 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
setBlockedPlanStepError(error instanceof Error ? error.message : '阻塞步骤加载失败');
|
||||
});
|
||||
|
||||
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise, planStepGraphPromise]);
|
||||
const routeInterventionPromise = getRouteInterventions()
|
||||
.then((response) => {
|
||||
if (!mountedRef.current) return;
|
||||
setRouteInterventions(response.interventions.slice(0, 6));
|
||||
setRouteInterventionError(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!mountedRef.current) return;
|
||||
setRouteInterventions([]);
|
||||
setRouteInterventionError(error instanceof Error ? error.message : '路由干预加载失败');
|
||||
});
|
||||
|
||||
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise, planStepGraphPromise, routeInterventionPromise]);
|
||||
if (mountedRef.current) {
|
||||
setProjectionLoading(false);
|
||||
}
|
||||
@@ -1458,6 +1475,30 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
const resolveRouteIntervention = useCallback(async (intervention: RouteInterventionRecord, decision: 'approved' | 'rejected') => {
|
||||
const actionKey = `route-intervention:${intervention.route_decision_id}:${decision}`;
|
||||
setActionBusy(actionKey);
|
||||
setActionError(null);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const result = await decideRouteIntervention(intervention.route_decision_id, {
|
||||
approval_id: intervention.approval_id,
|
||||
decision,
|
||||
actor: 'digital_employee_operator',
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
await fetchProjection();
|
||||
if (!mountedRef.current) return;
|
||||
setActionNotice(result.message || (decision === 'approved' ? '入口路由干预已通过。' : '入口路由干预已拒绝。'));
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setActionError(error instanceof Error ? error.message : '处理入口路由干预失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setActionBusy(null);
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
|
||||
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
|
||||
try {
|
||||
@@ -2177,6 +2218,45 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
{actionError || actionNotice || planDataError || workdayProjection?.demo_reason}
|
||||
</div>
|
||||
)}
|
||||
{(routeInterventions.length > 0 || routeInterventionError) && (
|
||||
<div className="de-management-sync-failures" aria-label="入口路由干预" style={{ marginBottom: 10 }}>
|
||||
{routeInterventionError && <p className="de-empty-text">{routeInterventionError}</p>}
|
||||
{routeInterventions.slice(0, 4).map((intervention) => {
|
||||
const status = intervention.intervention_status;
|
||||
const pending = !intervention.resolved_at && ['pending', 'blocked', 'approved'].includes(status);
|
||||
const approveKey = `route-intervention:${intervention.route_decision_id}:approved`;
|
||||
const rejectKey = `route-intervention:${intervention.route_decision_id}:rejected`;
|
||||
return (
|
||||
<div key={intervention.route_decision_id} className="de-management-sync-failure" title={intervention.blocked_reason || intervention.route_decision_id}>
|
||||
<span>{intervention.entrypoint || 'route'}</span>
|
||||
<strong>{intervention.route_kind} / {intervention.intent_kind}</strong>
|
||||
<em>{status}</em>
|
||||
<small>{intervention.blocked_reason || intervention.matched_intent || intervention.approval_id || intervention.route_decision_id}</small>
|
||||
{pending && (
|
||||
<div className="de-task-agent-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={actionBusy === approveKey}
|
||||
onClick={() => { void resolveRouteIntervention(intervention, 'approved'); }}
|
||||
>
|
||||
{status === 'approved' ? '恢复' : '通过'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={actionBusy === rejectKey}
|
||||
onClick={() => { void resolveRouteIntervention(intervention, 'rejected'); }}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{managedServiceIssues.length > 0 && (
|
||||
<div className="de-management-sync-failures" aria-label="服务状态处理" style={{ marginBottom: 10 }}>
|
||||
{managedServiceIssues.map((service) => {
|
||||
@@ -2360,9 +2440,12 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<span className={routeSummary.attention_count > 0 ? 'is-warning' : ''}><em>需关注</em><strong>{routeSummary.attention_count}</strong></span>
|
||||
{(routeSummary.blocked_count ?? 0) > 0 && <span className="is-warning"><em>阻断</em><strong>{routeSummary.blocked_count}</strong></span>}
|
||||
{(routeSummary.approval_required_count ?? 0) > 0 && <span className="is-warning"><em>待审</em><strong>{routeSummary.approval_required_count}</strong></span>}
|
||||
{(routeSummary.pending_intervention_count ?? 0) > 0 && <span className="is-warning"><em>待干预</em><strong>{routeSummary.pending_intervention_count}</strong></span>}
|
||||
{(routeSummary.restored_count ?? 0) > 0 && <span><em>已恢复</em><strong>{routeSummary.restored_count}</strong></span>}
|
||||
<small>
|
||||
{latestRoute ? `${latestRoute.route_kind} / ${latestRoute.intent_kind} · ${latestRoute.status_label}` : '暂无路由决策'}
|
||||
{routeSummary.policy_source ? ` · 策略${routeSummary.policy_source === 'remote' ? '远端' : '本地'}` : ''}
|
||||
{routeSummary.latest_intervention_status ? ` · 干预${routeSummary.latest_intervention_status}` : ''}
|
||||
{routeSummary.policy_last_pull_error ? ` · 拉取失败` : ''}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -409,6 +409,52 @@ export interface RouteDecisionTimelineResponse {
|
||||
decisions: RouteDecisionRecord[];
|
||||
}
|
||||
|
||||
export interface RouteInterventionRecord {
|
||||
route_decision_id: string;
|
||||
approval_id: string | null;
|
||||
route_kind: string;
|
||||
intent_kind: string;
|
||||
policy_result: string | null;
|
||||
intervention_status: 'pending' | 'blocked' | 'approved' | 'rejected' | 'executed' | 'failed' | 'resolved' | string;
|
||||
decision: string | null;
|
||||
created_command_id: string | null;
|
||||
blocked_reason: string | null;
|
||||
matched_intent: string | null;
|
||||
policy_source: string | null;
|
||||
reason_codes: string[];
|
||||
candidate_skills: string[];
|
||||
context_refs: Record<string, unknown>;
|
||||
entrypoint: string | null;
|
||||
actor: string | null;
|
||||
correlation_id: string | null;
|
||||
approval_status: string | null;
|
||||
recorded_at: string;
|
||||
resolved_at: string | null;
|
||||
resolution_event_id?: string | null;
|
||||
resolution_duplicate?: boolean;
|
||||
}
|
||||
|
||||
export interface RouteInterventionTimelineResponse {
|
||||
has_route_intervention_state: boolean;
|
||||
interventions: RouteInterventionRecord[];
|
||||
}
|
||||
|
||||
export interface RouteInterventionDecisionRequest {
|
||||
approval_id?: string | null;
|
||||
decision: 'approve' | 'approved' | 'reject' | 'rejected' | string;
|
||||
actor?: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export interface RouteInterventionDecisionResponse {
|
||||
accepted: boolean;
|
||||
route_decision_id: string;
|
||||
intervention: RouteInterventionRecord;
|
||||
command?: unknown;
|
||||
message?: string;
|
||||
error?: GovernanceCommandErrorCode | string;
|
||||
}
|
||||
|
||||
export interface RouteDecisionPolicy {
|
||||
enabled: boolean;
|
||||
auto_create_governance_commands: boolean;
|
||||
@@ -989,6 +1035,9 @@ export interface DigitalEmployeeRouteSummary {
|
||||
attention_count: number;
|
||||
blocked_count?: number;
|
||||
approval_required_count?: number;
|
||||
pending_intervention_count?: number;
|
||||
restored_count?: number;
|
||||
latest_intervention_status?: string | null;
|
||||
policy_source?: string | null;
|
||||
policy_last_pulled_at?: string | null;
|
||||
policy_last_pull_error?: string | null;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-BUX1PxSf.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CEkePwhY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -589,22 +589,35 @@ function checkDigitalRouteDecisionPolicy() {
|
||||
[stateService, "routeDecisionPolicy", "route decision policy UI state"],
|
||||
[stateService, "normalizeRouteDecisionPolicy", "route decision policy normalizer"],
|
||||
[stateService, "evaluateDigitalEmployeeRouteDecisionPolicy", "route decision policy evaluator"],
|
||||
[stateService, "listDigitalEmployeeRouteInterventions", "route intervention list helper"],
|
||||
[stateService, "recordDigitalEmployeeRouteInterventionResolution", "route intervention resolution recorder"],
|
||||
[stateService, "route_decision_blocked", "route decision blocked event kind"],
|
||||
[stateService, "route_decision_approval_required", "route decision approval-required event kind"],
|
||||
[stateService, "route_decision_intervention_resolved", "route decision intervention resolved event kind"],
|
||||
[syncService, "pullDigitalEmployeeRouteDecisionPolicy", "remote route decision policy pull service"],
|
||||
[syncService, "routeDecisionPolicyEndpoint", "remote route decision policy endpoint config"],
|
||||
[syncService, "/api/digital-employee/policies/route-decision", "remote route decision policy default path"],
|
||||
[syncService, "routeDecisionPolicyBusinessView", "route decision policy sync business view"],
|
||||
[syncService, "routeDecisionInterventionBusinessView", "route decision intervention sync business view"],
|
||||
[ipcHandlers, "digitalEmployee:pullRouteDecisionPolicy", "IPC route decision policy pull handler"],
|
||||
[ipcHandlers, "digitalEmployee:evaluateRouteDecisionPolicy", "IPC route decision policy evaluation handler"],
|
||||
[ipcHandlers, "digitalEmployee:listRouteInterventions", "IPC route intervention list handler"],
|
||||
[ipcHandlers, "digitalEmployee:recordRouteInterventionResolution", "IPC route intervention resolution handler"],
|
||||
[preload, "pullRouteDecisionPolicy", "preload route decision policy pull bridge"],
|
||||
[preload, "evaluateRouteDecisionPolicy", "preload route decision policy evaluation bridge"],
|
||||
[preload, "listRouteInterventions", "preload route intervention list bridge"],
|
||||
[preload, "recordRouteInterventionResolution", "preload route intervention resolution bridge"],
|
||||
[adapter, "pullRouteDecisionPolicy", "adapter route decision policy pull bridge"],
|
||||
[adapter, "evaluateRouteDecisionPolicy", "adapter route decision policy evaluation bridge"],
|
||||
[adapter, "/api/route-decision/policy/pull", "adapter route decision policy pull endpoint"],
|
||||
[adapter, "/api/route-decisions/interventions", "adapter route intervention list endpoint"],
|
||||
[adapter, "handleRouteInterventionDecision", "adapter route intervention decision handler"],
|
||||
[adapter, "resolveApprovedRouteInterventions", "adapter approved route intervention auto resolver"],
|
||||
[adapter, "auto_create_governance_commands", "adapter route command auto-create switch"],
|
||||
[api, "pullRouteDecisionPolicy", "embedded route decision policy pull API"],
|
||||
[api, "decideRouteIntervention", "embedded route intervention decision API"],
|
||||
[types, "RouteDecisionPolicy", "embedded route decision policy type"],
|
||||
[types, "RouteInterventionRecord", "embedded route intervention type"],
|
||||
[commandDeck, "pull_route_decision_policy", "home route decision policy pull action"],
|
||||
[commandDeck, "路由策略", "home route decision policy pull button"],
|
||||
];
|
||||
|
||||
@@ -86,6 +86,8 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
|
||||
syncQimingclawMemoryEntries: vi.fn(),
|
||||
recordDigitalEmployeeRouteDecision: vi.fn(),
|
||||
listDigitalEmployeeRouteDecisions: vi.fn(),
|
||||
listDigitalEmployeeRouteInterventions: vi.fn(() => [{ route_decision_id: "route-1", intervention_status: "pending" }]),
|
||||
recordDigitalEmployeeRouteInterventionResolution: vi.fn(() => ({ route_decision_id: "route-1", intervention_status: "executed" })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/digitalEmployee/schedulerService", () => ({
|
||||
@@ -318,6 +320,27 @@ describe("digital employee managed service actions", () => {
|
||||
expect(stateService.evaluateDigitalEmployeeRouteDecisionPolicy).toHaveBeenCalledWith({ intentKind: "schedule" });
|
||||
});
|
||||
|
||||
it("registers route intervention list and resolution through the digital employee IPC boundary", async () => {
|
||||
const electron = await import("electron");
|
||||
const stateService = await import("../services/digitalEmployee/stateService");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
registerDigitalEmployeeHandlers(createCtx());
|
||||
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
|
||||
const listCall = calls.find(([channel]) => channel === "digitalEmployee:listRouteInterventions");
|
||||
const resolutionCall = calls.find(([channel]) => channel === "digitalEmployee:recordRouteInterventionResolution");
|
||||
expect(listCall).toBeTruthy();
|
||||
expect(resolutionCall).toBeTruthy();
|
||||
|
||||
const listResult = await (listCall?.[1] as (_event: unknown, options: unknown) => Promise<unknown>)({}, { limit: 5 });
|
||||
const resolutionResult = await (resolutionCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { routeDecisionId: "route-1", decision: "approved" });
|
||||
|
||||
expect(listResult).toEqual([expect.objectContaining({ route_decision_id: "route-1" })]);
|
||||
expect(resolutionResult).toMatchObject({ route_decision_id: "route-1", intervention_status: "executed" });
|
||||
expect(stateService.listDigitalEmployeeRouteInterventions).toHaveBeenCalledWith({ limit: 5 });
|
||||
expect(stateService.recordDigitalEmployeeRouteInterventionResolution).toHaveBeenCalledWith({ routeDecisionId: "route-1", decision: "approved" });
|
||||
});
|
||||
|
||||
it("registers approval action history through the digital employee IPC boundary", async () => {
|
||||
const electron = await import("electron");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
syncQimingclawMemoryEntries,
|
||||
recordDigitalEmployeeRouteDecision,
|
||||
listDigitalEmployeeRouteDecisions,
|
||||
listDigitalEmployeeRouteInterventions,
|
||||
recordDigitalEmployeeRouteInterventionResolution,
|
||||
type DigitalEmployeeManagedService,
|
||||
type DigitalEmployeeManagedServiceActionInput,
|
||||
type DigitalEmployeeGovernanceCommandRecord,
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
type DigitalEmployeeApprovalActionInput,
|
||||
type DigitalEmployeeMemoryUpsertInput,
|
||||
type DigitalEmployeeRouteDecisionInput,
|
||||
type DigitalEmployeeRouteInterventionResolutionInput,
|
||||
type DigitalEmployeeServiceStatus,
|
||||
type DigitalEmployeeSnapshot,
|
||||
type DigitalEmployeeUiStateUpdate,
|
||||
@@ -256,10 +259,18 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
return listDigitalEmployeeRouteDecisions(options ?? {});
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:listRouteInterventions", async (_, options?: { limit?: number }) => {
|
||||
return listDigitalEmployeeRouteInterventions(options ?? {});
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:recordRouteDecision", async (_, input: DigitalEmployeeRouteDecisionInput) => {
|
||||
return recordDigitalEmployeeRouteDecision(input);
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:recordRouteInterventionResolution", async (_, input: DigitalEmployeeRouteInterventionResolutionInput) => {
|
||||
return recordDigitalEmployeeRouteInterventionResolution(input);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"digitalEmployee:recordGovernanceCommand",
|
||||
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {
|
||||
|
||||
@@ -734,6 +734,7 @@ describe("digital employee state service", () => {
|
||||
occurredAt: "2026-06-07T08:12:00.000Z",
|
||||
});
|
||||
const approval = evaluateDigitalEmployeeRouteDecisionPolicy({
|
||||
routeDecisionId: "route-decision-execute",
|
||||
routeKind: "DirectRun",
|
||||
intentKind: "execute",
|
||||
correlationId: "corr-execute",
|
||||
@@ -765,6 +766,90 @@ describe("digital employee state service", () => {
|
||||
expect(mockState.db?.events.get(blocked.event_id!)).toMatchObject({ kind: "route_decision_blocked" });
|
||||
expect(mockState.db?.events.get(approval.event_id!)).toMatchObject({ kind: "route_decision_approval_required" });
|
||||
expect(mockState.db?.approvals.get(approval.approval_id!)).toMatchObject({ status: "pending" });
|
||||
expect(JSON.parse(mockState.db?.approvals.get(approval.approval_id!)?.payload ?? "{}")).toMatchObject({
|
||||
approval_kind: "route_decision_policy",
|
||||
route_decision_id: "route-decision-execute",
|
||||
route_kind: "DirectRun",
|
||||
intent_kind: "execute",
|
||||
});
|
||||
});
|
||||
|
||||
it("records route intervention resolution once and derives intervention rows", async () => {
|
||||
const {
|
||||
listDigitalEmployeeRouteInterventions,
|
||||
recordDigitalEmployeeRouteDecision,
|
||||
recordDigitalEmployeeRouteInterventionResolution,
|
||||
} = await import("./stateService");
|
||||
|
||||
recordDigitalEmployeeRouteDecision({
|
||||
id: "route-decision-restore",
|
||||
correlationId: "route-corr-restore",
|
||||
routeKind: "DirectRun",
|
||||
intentKind: "execute",
|
||||
reasonCodes: ["route_intent_requires_approval"],
|
||||
candidateSkills: ["crm-search"],
|
||||
policyResult: "approval_required",
|
||||
policySource: "remote",
|
||||
approvalId: "route-approval-restore",
|
||||
matchedIntent: "execute",
|
||||
contextRefs: { task_id: "task-1" },
|
||||
recordedAt: "2026-06-07T08:14:00.000Z",
|
||||
});
|
||||
mockState.db?.approvals.set("route-approval-restore", {
|
||||
id: "route-approval-restore",
|
||||
plan_id: "",
|
||||
task_id: "",
|
||||
run_id: "",
|
||||
title: "入口路由审批",
|
||||
status: "approved",
|
||||
payload: JSON.stringify({ approval_kind: "route_decision_policy", route_decision_id: "route-decision-restore" }),
|
||||
sync_status: "pending",
|
||||
created_at: "2026-06-07T08:14:00.000Z",
|
||||
updated_at: "2026-06-07T08:15:00.000Z",
|
||||
});
|
||||
|
||||
expect(listDigitalEmployeeRouteInterventions()).toEqual([
|
||||
expect.objectContaining({
|
||||
route_decision_id: "route-decision-restore",
|
||||
approval_id: "route-approval-restore",
|
||||
intervention_status: "approved",
|
||||
approval_status: "approved",
|
||||
}),
|
||||
]);
|
||||
|
||||
const first = recordDigitalEmployeeRouteInterventionResolution({
|
||||
routeDecisionId: "route-decision-restore",
|
||||
approvalId: "route-approval-restore",
|
||||
decision: "approved",
|
||||
createdCommandId: "governance-command-restore",
|
||||
commandAccepted: true,
|
||||
routeKind: "DirectRun",
|
||||
intentKind: "execute",
|
||||
policySource: "remote",
|
||||
occurredAt: "2026-06-07T08:16:00.000Z",
|
||||
});
|
||||
const second = recordDigitalEmployeeRouteInterventionResolution({
|
||||
routeDecisionId: "route-decision-restore",
|
||||
approvalId: "route-approval-restore",
|
||||
decision: "approved",
|
||||
createdCommandId: "governance-command-duplicate",
|
||||
commandAccepted: true,
|
||||
occurredAt: "2026-06-07T08:17:00.000Z",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
route_decision_id: "route-decision-restore",
|
||||
approval_id: "route-approval-restore",
|
||||
intervention_status: "executed",
|
||||
created_command_id: "governance-command-restore",
|
||||
});
|
||||
expect(second).toMatchObject({ resolution_duplicate: true, created_command_id: "governance-command-restore" });
|
||||
expect(Array.from(mockState.db?.events.values() ?? []).filter((event) => event.kind === "route_decision_intervention_resolved")).toHaveLength(1);
|
||||
expect(listDigitalEmployeeRouteInterventions()[0]).toMatchObject({
|
||||
intervention_status: "executed",
|
||||
resolved_at: "2026-06-07T08:16:00.000Z",
|
||||
created_command_id: "governance-command-restore",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates formal approval records when UI approval decisions are saved", async () => {
|
||||
|
||||
@@ -456,6 +456,7 @@ export interface DigitalEmployeeRouteDecisionPolicy {
|
||||
export type DigitalEmployeeRouteDecisionPolicyDecision = "allowed" | "approval_required" | "blocked";
|
||||
|
||||
export interface DigitalEmployeeRouteDecisionPolicyInput {
|
||||
routeDecisionId?: string | null;
|
||||
routeKind?: string | null;
|
||||
intentKind?: string | null;
|
||||
reasonCodes?: string[] | null;
|
||||
@@ -477,6 +478,7 @@ export interface DigitalEmployeeRouteDecisionPolicyResult {
|
||||
policy_result: "accepted" | "approval_required" | "blocked";
|
||||
route_kind: string;
|
||||
intent_kind: string;
|
||||
route_decision_id?: string | null;
|
||||
reason_codes: string[];
|
||||
matched_intent?: string | null;
|
||||
blocked_reason?: string | null;
|
||||
@@ -562,6 +564,55 @@ export interface DigitalEmployeeRouteDecisionInput {
|
||||
recordedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRouteInterventionRecord {
|
||||
route_decision_id: string;
|
||||
approval_id: string | null;
|
||||
route_kind: string;
|
||||
intent_kind: string;
|
||||
policy_result: string | null;
|
||||
intervention_status: "pending" | "blocked" | "approved" | "rejected" | "executed" | "failed" | "resolved";
|
||||
decision: string | null;
|
||||
created_command_id: string | null;
|
||||
blocked_reason: string | null;
|
||||
matched_intent: string | null;
|
||||
policy_source: string | null;
|
||||
reason_codes: string[];
|
||||
candidate_skills: string[];
|
||||
context_refs: Record<string, unknown>;
|
||||
entrypoint: string | null;
|
||||
actor: string | null;
|
||||
correlation_id: string | null;
|
||||
approval_status: string | null;
|
||||
recorded_at: string;
|
||||
resolved_at: string | null;
|
||||
resolution_event_id?: string | null;
|
||||
resolution_duplicate?: boolean;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRouteInterventionResolutionInput {
|
||||
routeDecisionId: string;
|
||||
approvalId?: string | null;
|
||||
decision: string;
|
||||
createdCommandId?: string | null;
|
||||
commandAccepted?: boolean | null;
|
||||
commandError?: string | null;
|
||||
blockedReason?: string | null;
|
||||
policyResult?: string | null;
|
||||
policySource?: string | null;
|
||||
matchedIntent?: string | null;
|
||||
routeKind?: string | null;
|
||||
intentKind?: string | null;
|
||||
reasonCodes?: string[] | null;
|
||||
candidateSkills?: string[] | null;
|
||||
contextRefs?: Record<string, unknown> | null;
|
||||
entrypoint?: string | null;
|
||||
actor?: string | null;
|
||||
correlationId?: string | null;
|
||||
message?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
occurredAt?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeArtifactRecord {
|
||||
id: string;
|
||||
remoteId?: string | null;
|
||||
@@ -1743,12 +1794,134 @@ export function listDigitalEmployeeRouteDecisions(options: { limit?: number } =
|
||||
.filter((decision): decision is DigitalEmployeeRouteDecisionRecord => Boolean(decision));
|
||||
}
|
||||
|
||||
export function listDigitalEmployeeRouteInterventions(options: { limit?: number } = {}): DigitalEmployeeRouteInterventionRecord[] {
|
||||
const db = getDigitalEmployeeDb();
|
||||
if (!db) return [];
|
||||
const safeLimit = Math.max(1, Math.min(Math.floor(options.limit ?? 80), 300));
|
||||
const eventRows = db.prepare(`
|
||||
SELECT id, kind, payload, occurred_at, created_at
|
||||
FROM digital_events
|
||||
WHERE kind IN ('route_decision', 'route_decision_blocked', 'route_decision_approval_required', 'route_decision_intervention_resolved')
|
||||
ORDER BY occurred_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
`).all(Math.max(safeLimit * 4, 80)) as Array<Record<string, unknown>>;
|
||||
const approvalRows = db.prepare(`
|
||||
SELECT id, status, payload, updated_at, created_at
|
||||
FROM digital_approvals
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
`).all(Math.max(safeLimit * 4, 80)) as Array<Record<string, unknown>>;
|
||||
const approvalsById = new Map<string, { status: string | null; payload: Record<string, unknown> | null }>();
|
||||
for (const row of approvalRows) {
|
||||
const id = stringValue(row.id);
|
||||
if (!id) continue;
|
||||
approvalsById.set(id, {
|
||||
status: stringValue(row.status) || null,
|
||||
payload: objectRecord(parseStoredPayload(row.payload)) ?? null,
|
||||
});
|
||||
}
|
||||
const records = new Map<string, DigitalEmployeeRouteInterventionRecord>();
|
||||
const resolutions = new Map<string, Record<string, unknown>>();
|
||||
for (const row of eventRows) {
|
||||
const kind = stringValue(row.kind);
|
||||
const payload = objectRecord(parseStoredPayload(row.payload)) ?? {};
|
||||
if (kind === "route_decision_intervention_resolved") {
|
||||
const routeDecisionId = stringValue(payload.route_decision_id ?? payload.routeDecisionId);
|
||||
if (routeDecisionId) resolutions.set(routeDecisionId, { ...payload, event_id: row.id, occurred_at: row.occurred_at });
|
||||
continue;
|
||||
}
|
||||
const decision = kind === "route_decision" ? routeDecisionFromEventPayload(payload) : null;
|
||||
const source = decision ? routeInterventionSourceFromDecision(decision) : routeInterventionSourceFromPayload(payload, row);
|
||||
if (!source) continue;
|
||||
records.set(source.route_decision_id, source);
|
||||
}
|
||||
for (const approval of approvalsById.values()) {
|
||||
const payload = approval.payload;
|
||||
if (!payload || stringValue(payload.approval_kind) !== "route_decision_policy") continue;
|
||||
const routeDecisionId = stringValue(payload.route_decision_id ?? payload.routeDecisionId);
|
||||
if (!routeDecisionId || records.has(routeDecisionId)) continue;
|
||||
records.set(routeDecisionId, routeInterventionSourceFromPayload({
|
||||
...payload,
|
||||
approval_id: stringValue(payload.approval_id ?? payload.approvalId) || null,
|
||||
policy_result: "approval_required",
|
||||
}, { occurred_at: stringValue(payload.created_at ?? payload.createdAt) || new Date().toISOString() })!);
|
||||
}
|
||||
for (const record of records.values()) {
|
||||
const approval = record.approval_id ? approvalsById.get(record.approval_id) : null;
|
||||
if (approval?.status) record.approval_status = approval.status;
|
||||
const resolution = resolutions.get(record.route_decision_id);
|
||||
if (resolution) applyRouteInterventionResolution(record, resolution);
|
||||
else if (record.approval_status && isApprovedRouteInterventionDecision(record.approval_status)) record.intervention_status = "approved";
|
||||
else if (record.approval_status && isRejectedRouteInterventionDecision(record.approval_status)) record.intervention_status = "rejected";
|
||||
}
|
||||
return Array.from(records.values())
|
||||
.sort((left, right) => (right.resolved_at || right.recorded_at).localeCompare(left.resolved_at || left.recorded_at))
|
||||
.slice(0, safeLimit);
|
||||
}
|
||||
|
||||
export function recordDigitalEmployeeRouteInterventionResolution(
|
||||
input: DigitalEmployeeRouteInterventionResolutionInput,
|
||||
): DigitalEmployeeRouteInterventionRecord {
|
||||
const db = getDigitalEmployeeDb();
|
||||
const now = input.occurredAt || new Date().toISOString();
|
||||
const routeDecisionId = stringValue(input.routeDecisionId);
|
||||
if (!routeDecisionId) throw new Error("route decision id is required");
|
||||
const approvalId = stringValue(input.approvalId) || null;
|
||||
if (db) {
|
||||
const existing = findRouteInterventionResolution(routeDecisionId, approvalId);
|
||||
if (existing) {
|
||||
return routeInterventionRecordFromResolution(input, existing, true);
|
||||
}
|
||||
}
|
||||
const decision = normalizeRouteInterventionDecision(input.decision);
|
||||
const commandAccepted = input.commandAccepted === true;
|
||||
const commandError = stringValue(input.commandError) || null;
|
||||
const eventId = `route-intervention:${safeId(routeDecisionId)}:${safeId(approvalId || "no-approval")}`;
|
||||
const payload: Record<string, unknown> = {
|
||||
source: "qimingclaw-route-intervention",
|
||||
route_decision_id: routeDecisionId,
|
||||
approval_id: approvalId,
|
||||
decision,
|
||||
status: routeInterventionStatusFromResolution(decision, commandAccepted, commandError),
|
||||
created_command_id: stringValue(input.createdCommandId) || null,
|
||||
command_accepted: commandAccepted,
|
||||
command_error: commandError,
|
||||
blocked_reason: stringValue(input.blockedReason) || null,
|
||||
policy_result: stringValue(input.policyResult) || null,
|
||||
policy_source: stringValue(input.policySource) || null,
|
||||
matched_intent: stringValue(input.matchedIntent) || null,
|
||||
route_kind: normalizeRouteKind(input.routeKind),
|
||||
intent_kind: normalizeRouteIntentKind(input.intentKind),
|
||||
reason_codes: normalizeSkillIds(input.reasonCodes ?? []),
|
||||
candidate_skills: normalizeSkillIds(input.candidateSkills ?? []),
|
||||
context_refs: objectRecord(input.contextRefs) ?? {},
|
||||
entrypoint: stringValue(input.entrypoint) || null,
|
||||
actor: stringValue(input.actor) || "digital_employee_operator",
|
||||
correlation_id: stringValue(input.correlationId) || null,
|
||||
resolution_payload: sanitizeRouteDecisionPolicyPayload(input.payload),
|
||||
};
|
||||
insertEvent(
|
||||
{
|
||||
event_id: eventId,
|
||||
kind: "route_decision_intervention_resolved",
|
||||
occurred_at: now,
|
||||
message: input.message || routeInterventionResolutionMessage(decision, routeDecisionId),
|
||||
plan_id: null,
|
||||
task_id: null,
|
||||
},
|
||||
null,
|
||||
payload,
|
||||
);
|
||||
return routeInterventionRecordFromResolution(input, { ...payload, event_id: eventId, occurred_at: now }, false);
|
||||
}
|
||||
|
||||
export function evaluateDigitalEmployeeRouteDecisionPolicy(
|
||||
input: DigitalEmployeeRouteDecisionPolicyInput,
|
||||
): DigitalEmployeeRouteDecisionPolicyResult {
|
||||
const policy = readDigitalEmployeeRouteDecisionPolicy();
|
||||
const routeKind = normalizeRouteKind(input.routeKind);
|
||||
const intentKind = normalizeRouteIntentKind(input.intentKind);
|
||||
const routeDecisionId = stringValue(input.routeDecisionId) || null;
|
||||
const baseReasonCodes = normalizeSkillIds(input.reasonCodes ?? []);
|
||||
const source = policy.source || "local";
|
||||
if (!policy.enabled) {
|
||||
@@ -1759,6 +1932,7 @@ export function evaluateDigitalEmployeeRouteDecisionPolicy(
|
||||
policy_result: "accepted",
|
||||
route_kind: routeKind,
|
||||
intent_kind: intentKind,
|
||||
route_decision_id: routeDecisionId,
|
||||
reason_codes: [...baseReasonCodes, "route_policy_disabled"],
|
||||
policy_source: source,
|
||||
policy,
|
||||
@@ -1787,6 +1961,7 @@ export function evaluateDigitalEmployeeRouteDecisionPolicy(
|
||||
policy_result: "accepted",
|
||||
route_kind: routeKind,
|
||||
intent_kind: intentKind,
|
||||
route_decision_id: routeDecisionId,
|
||||
reason_codes: baseReasonCodes.length > 0 ? baseReasonCodes : ["route_policy_allowed"],
|
||||
policy_source: source,
|
||||
policy,
|
||||
@@ -1803,6 +1978,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
|
||||
const now = input.occurredAt || new Date().toISOString();
|
||||
const routeKind = normalizeRouteKind(input.routeKind);
|
||||
const intentKind = normalizeRouteIntentKind(input.intentKind);
|
||||
const routeDecisionId = stringValue(input.routeDecisionId) || null;
|
||||
const source = stringValue(input.source) || "qimingclaw-route-policy";
|
||||
const correlationId = stringValue(input.correlationId) || stringValue(input.idempotencyKey) || `${routeKind}:${intentKind}:${now}`;
|
||||
const eventId = `route-policy:${safeId(decision)}:${safeId(intentKind)}:${safeId(correlationId)}:${safeId(now)}`;
|
||||
@@ -1818,6 +1994,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
|
||||
policy_result: "blocked",
|
||||
route_kind: routeKind,
|
||||
intent_kind: intentKind,
|
||||
route_decision_id: routeDecisionId,
|
||||
reason_codes: [...reasonCodes, "database_unavailable"],
|
||||
matched_intent: intentKind,
|
||||
blocked_reason: "database_unavailable",
|
||||
@@ -1838,6 +2015,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
|
||||
payload: {
|
||||
source,
|
||||
approval_kind: "route_decision_policy",
|
||||
route_decision_id: routeDecisionId,
|
||||
route_kind: routeKind,
|
||||
intent_kind: intentKind,
|
||||
reason_codes: reasonCodes,
|
||||
@@ -1854,6 +2032,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
|
||||
source,
|
||||
route_kind: routeKind,
|
||||
intent_kind: intentKind,
|
||||
route_decision_id: routeDecisionId,
|
||||
decision,
|
||||
status: decision,
|
||||
policy_result: decision,
|
||||
@@ -1889,6 +2068,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
|
||||
policy_result: decision,
|
||||
route_kind: routeKind,
|
||||
intent_kind: intentKind,
|
||||
route_decision_id: routeDecisionId,
|
||||
reason_codes: reasonCodes,
|
||||
matched_intent: intentKind,
|
||||
blocked_reason: blockedReason,
|
||||
@@ -2112,6 +2292,164 @@ function routeDecisionPolicyMessage(
|
||||
: `入口路由已被策略阻断:${routeKind} / ${intentKind}`;
|
||||
}
|
||||
|
||||
function routeInterventionSourceFromDecision(decision: DigitalEmployeeRouteDecisionRecord): DigitalEmployeeRouteInterventionRecord | null {
|
||||
if (!decision.approval_id && !decision.blocked_reason && decision.policy_result !== "blocked" && decision.policy_result !== "approval_required") return null;
|
||||
return {
|
||||
route_decision_id: decision.id,
|
||||
approval_id: decision.approval_id ?? null,
|
||||
route_kind: decision.route_kind,
|
||||
intent_kind: decision.intent_kind,
|
||||
policy_result: decision.policy_result,
|
||||
intervention_status: decision.policy_result === "approval_required" ? "pending" : "blocked",
|
||||
decision: null,
|
||||
created_command_id: decision.created_command_id,
|
||||
blocked_reason: decision.blocked_reason ?? null,
|
||||
matched_intent: decision.matched_intent ?? null,
|
||||
policy_source: decision.policy_source ?? null,
|
||||
reason_codes: decision.reason_codes,
|
||||
candidate_skills: decision.candidate_skills,
|
||||
context_refs: decision.context_refs,
|
||||
entrypoint: decision.entrypoint ?? null,
|
||||
actor: decision.actor ?? null,
|
||||
correlation_id: decision.correlation_id,
|
||||
approval_status: null,
|
||||
recorded_at: decision.recorded_at,
|
||||
resolved_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function routeInterventionSourceFromPayload(
|
||||
payload: Record<string, unknown>,
|
||||
row: Record<string, unknown>,
|
||||
): DigitalEmployeeRouteInterventionRecord | null {
|
||||
const routeDecisionId = stringValue(payload.route_decision_id ?? payload.routeDecisionId);
|
||||
if (!routeDecisionId) return null;
|
||||
const policyResult = stringValue(payload.policy_result ?? payload.policyResult ?? payload.decision) || null;
|
||||
return {
|
||||
route_decision_id: routeDecisionId,
|
||||
approval_id: stringValue(payload.approval_id ?? payload.approvalId) || null,
|
||||
route_kind: normalizeRouteKind(payload.route_kind ?? payload.routeKind),
|
||||
intent_kind: normalizeRouteIntentKind(payload.intent_kind ?? payload.intentKind),
|
||||
policy_result: policyResult,
|
||||
intervention_status: policyResult === "approval_required" ? "pending" : "blocked",
|
||||
decision: null,
|
||||
created_command_id: null,
|
||||
blocked_reason: stringValue(payload.blocked_reason ?? payload.blockedReason) || null,
|
||||
matched_intent: stringValue(payload.matched_intent ?? payload.matchedIntent) || null,
|
||||
policy_source: stringValue(payload.policy_source ?? payload.policySource) || null,
|
||||
reason_codes: parseRouteDecisionStringArray(payload.reason_codes ?? payload.reasonCodes),
|
||||
candidate_skills: parseRouteDecisionStringArray(payload.candidate_skills ?? payload.candidateSkills),
|
||||
context_refs: objectRecord(payload.context_refs ?? payload.contextRefs) ?? {},
|
||||
entrypoint: stringValue(payload.entrypoint) || null,
|
||||
actor: stringValue(payload.actor) || null,
|
||||
correlation_id: stringValue(payload.correlation_id ?? payload.correlationId) || null,
|
||||
approval_status: null,
|
||||
recorded_at: stringValue(row.occurred_at ?? row.occurredAt) || new Date().toISOString(),
|
||||
resolved_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyRouteInterventionResolution(record: DigitalEmployeeRouteInterventionRecord, resolution: Record<string, unknown>): void {
|
||||
record.decision = stringValue(resolution.decision) || null;
|
||||
record.created_command_id = stringValue(resolution.created_command_id ?? resolution.createdCommandId) || record.created_command_id;
|
||||
record.intervention_status = routeInterventionStatusFromResolution(
|
||||
record.decision || "resolved",
|
||||
resolution.command_accepted === true || resolution.commandAccepted === true,
|
||||
stringValue(resolution.command_error ?? resolution.commandError) || null,
|
||||
);
|
||||
record.resolved_at = stringValue(resolution.occurred_at ?? resolution.occurredAt) || null;
|
||||
record.resolution_event_id = stringValue(resolution.event_id ?? resolution.eventId) || null;
|
||||
}
|
||||
|
||||
function routeInterventionRecordFromResolution(
|
||||
input: DigitalEmployeeRouteInterventionResolutionInput,
|
||||
resolution: Record<string, unknown>,
|
||||
duplicate: boolean,
|
||||
): DigitalEmployeeRouteInterventionRecord {
|
||||
const decision = stringValue(resolution.decision) || normalizeRouteInterventionDecision(input.decision);
|
||||
const record: DigitalEmployeeRouteInterventionRecord = {
|
||||
route_decision_id: stringValue(resolution.route_decision_id ?? resolution.routeDecisionId) || input.routeDecisionId,
|
||||
approval_id: stringValue(resolution.approval_id ?? resolution.approvalId ?? input.approvalId) || null,
|
||||
route_kind: normalizeRouteKind(resolution.route_kind ?? resolution.routeKind ?? input.routeKind),
|
||||
intent_kind: normalizeRouteIntentKind(resolution.intent_kind ?? resolution.intentKind ?? input.intentKind),
|
||||
policy_result: stringValue(resolution.policy_result ?? resolution.policyResult ?? input.policyResult) || null,
|
||||
intervention_status: routeInterventionStatusFromResolution(
|
||||
decision,
|
||||
resolution.command_accepted === true || input.commandAccepted === true,
|
||||
stringValue(resolution.command_error ?? resolution.commandError ?? input.commandError) || null,
|
||||
),
|
||||
decision,
|
||||
created_command_id: stringValue(resolution.created_command_id ?? resolution.createdCommandId ?? input.createdCommandId) || null,
|
||||
blocked_reason: stringValue(resolution.blocked_reason ?? resolution.blockedReason ?? input.blockedReason) || null,
|
||||
matched_intent: stringValue(resolution.matched_intent ?? resolution.matchedIntent ?? input.matchedIntent) || null,
|
||||
policy_source: stringValue(resolution.policy_source ?? resolution.policySource ?? input.policySource) || null,
|
||||
reason_codes: parseRouteDecisionStringArray(resolution.reason_codes ?? resolution.reasonCodes ?? input.reasonCodes),
|
||||
candidate_skills: parseRouteDecisionStringArray(resolution.candidate_skills ?? resolution.candidateSkills ?? input.candidateSkills),
|
||||
context_refs: objectRecord(resolution.context_refs ?? resolution.contextRefs ?? input.contextRefs) ?? {},
|
||||
entrypoint: stringValue(resolution.entrypoint ?? input.entrypoint) || null,
|
||||
actor: stringValue(resolution.actor ?? input.actor) || null,
|
||||
correlation_id: stringValue(resolution.correlation_id ?? resolution.correlationId ?? input.correlationId) || null,
|
||||
approval_status: null,
|
||||
recorded_at: stringValue(resolution.occurred_at ?? resolution.occurredAt ?? input.occurredAt) || new Date().toISOString(),
|
||||
resolved_at: stringValue(resolution.occurred_at ?? resolution.occurredAt ?? input.occurredAt) || new Date().toISOString(),
|
||||
resolution_event_id: stringValue(resolution.event_id ?? resolution.eventId) || null,
|
||||
resolution_duplicate: duplicate,
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
function findRouteInterventionResolution(routeDecisionId: string, approvalId: string | null): Record<string, unknown> | null {
|
||||
const db = getDigitalEmployeeDb();
|
||||
if (!db) return null;
|
||||
const rows = db.prepare(`
|
||||
SELECT id, payload, occurred_at, created_at
|
||||
FROM digital_events
|
||||
WHERE kind = 'route_decision_intervention_resolved'
|
||||
ORDER BY occurred_at DESC, created_at DESC
|
||||
LIMIT 300
|
||||
`).all() as Array<Record<string, unknown>>;
|
||||
for (const row of rows) {
|
||||
const payload = objectRecord(parseStoredPayload(row.payload));
|
||||
if (!payload) continue;
|
||||
const payloadRouteDecisionId = stringValue(payload.route_decision_id ?? payload.routeDecisionId);
|
||||
const payloadApprovalId = stringValue(payload.approval_id ?? payload.approvalId) || null;
|
||||
if (payloadRouteDecisionId === routeDecisionId && payloadApprovalId === approvalId) {
|
||||
return { ...payload, event_id: row.id, occurred_at: row.occurred_at };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeRouteInterventionDecision(value: unknown): string {
|
||||
return stringValue(value).trim().toLowerCase() || "resolved";
|
||||
}
|
||||
|
||||
function isApprovedRouteInterventionDecision(value: string): boolean {
|
||||
return ["approve", "approved", "accept", "accepted"].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function isRejectedRouteInterventionDecision(value: string): boolean {
|
||||
return ["reject", "rejected", "deny", "denied", "dismiss", "dismissed"].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function routeInterventionStatusFromResolution(
|
||||
decision: string,
|
||||
commandAccepted: boolean,
|
||||
commandError: string | null,
|
||||
): DigitalEmployeeRouteInterventionRecord["intervention_status"] {
|
||||
if (commandError) return "failed";
|
||||
if (commandAccepted) return "executed";
|
||||
if (isRejectedRouteInterventionDecision(decision)) return "rejected";
|
||||
if (isApprovedRouteInterventionDecision(decision)) return "approved";
|
||||
return "resolved";
|
||||
}
|
||||
|
||||
function routeInterventionResolutionMessage(decision: string, routeDecisionId: string): string {
|
||||
if (isRejectedRouteInterventionDecision(decision)) return `入口路由干预已拒绝:${routeDecisionId}`;
|
||||
if (isApprovedRouteInterventionDecision(decision)) return `入口路由干预已通过:${routeDecisionId}`;
|
||||
return `入口路由干预已处理:${routeDecisionId}`;
|
||||
}
|
||||
|
||||
export function recordDigitalEmployeeSkillCallAudit(
|
||||
input: DigitalEmployeeSkillCallAuditInput,
|
||||
): DigitalEmployeeSkillCallAuditRecord | null {
|
||||
|
||||
@@ -1024,6 +1024,19 @@ describe("digital employee sync service", () => {
|
||||
matched_intent: "schedule",
|
||||
},
|
||||
});
|
||||
insertEventEntity("event-route-intervention", "route_decision_intervention_resolved", {
|
||||
route_decision_id: "route-1",
|
||||
approval_id: "route-approval-1",
|
||||
route_kind: "PlanStepDispatch",
|
||||
intent_kind: "schedule",
|
||||
decision: "approved",
|
||||
status: "executed",
|
||||
policy_result: "approval_required",
|
||||
policy_source: "remote",
|
||||
created_command_id: "governance-command-restore",
|
||||
command_accepted: true,
|
||||
reason_codes: ["route_intent_requires_approval"],
|
||||
});
|
||||
insertEventEntity("event-approval", "approval_action_recorded", {
|
||||
approval_id: "approval-1",
|
||||
action: "mark_risk",
|
||||
@@ -1272,6 +1285,7 @@ describe("digital employee sync service", () => {
|
||||
success: true,
|
||||
acked: [
|
||||
{ entity_type: "event", entity_id: "event-route" },
|
||||
{ entity_type: "event", entity_id: "event-route-intervention" },
|
||||
{ entity_type: "event", entity_id: "event-approval" },
|
||||
{ entity_type: "event", entity_id: "event-approval-decision" },
|
||||
{ entity_type: "event", entity_id: "event-artifact" },
|
||||
@@ -1315,6 +1329,17 @@ describe("digital employee sync service", () => {
|
||||
matched_intent: "schedule",
|
||||
attention_level: "action",
|
||||
});
|
||||
expect(businessView("event-route-intervention")).toMatchObject({
|
||||
category: "route",
|
||||
route_decision_id: "route-1",
|
||||
approval_id: "route-approval-1",
|
||||
decision: "approved",
|
||||
status: "executed",
|
||||
created_command_id: "governance-command-restore",
|
||||
policy_source: "remote",
|
||||
command_accepted: true,
|
||||
attention_level: "action",
|
||||
});
|
||||
expect(businessView("event-approval")).toMatchObject({
|
||||
category: "approval",
|
||||
approval_id: "approval-1",
|
||||
|
||||
@@ -1491,6 +1491,7 @@ function eventSpecificBusinessView(
|
||||
): Record<string, unknown> {
|
||||
if (kind === "route_decision") return routeDecisionBusinessView(payload, eventId);
|
||||
if (kind === "route_decision_blocked" || kind === "route_decision_approval_required") return routeDecisionPolicyBusinessView(payload);
|
||||
if (kind === "route_decision_intervention_resolved") return routeDecisionInterventionBusinessView(payload);
|
||||
if (kind === "approval_decision") return approvalDecisionBusinessView(payload);
|
||||
if (kind.startsWith("approval_action_")) return approvalActionBusinessView(payload);
|
||||
if (isApprovalSyncEvent(kind)) return approvalSyncBusinessView(payload);
|
||||
@@ -1547,6 +1548,26 @@ function routeDecisionPolicyBusinessView(payload: Record<string, unknown>): Reco
|
||||
};
|
||||
}
|
||||
|
||||
function routeDecisionInterventionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
route_decision_id: stringField(payload.route_decision_id ?? payload.routeDecisionId) || null,
|
||||
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
|
||||
route_kind: stringField(payload.route_kind ?? payload.routeKind) || "ChatOnly",
|
||||
intent_kind: stringField(payload.intent_kind ?? payload.intentKind) || "chat",
|
||||
decision: stringField(payload.decision) || null,
|
||||
status: stringField(payload.status) || null,
|
||||
policy_result: stringField(payload.policy_result ?? payload.policyResult) || null,
|
||||
policy_source: stringField(payload.policy_source ?? payload.policySource) || null,
|
||||
blocked_reason: stringField(payload.blocked_reason ?? payload.blockedReason) || null,
|
||||
matched_intent: stringField(payload.matched_intent ?? payload.matchedIntent) || null,
|
||||
created_command_id: stringField(payload.created_command_id ?? payload.createdCommandId) || null,
|
||||
command_accepted: payload.command_accepted === true || payload.commandAccepted === true,
|
||||
command_error: stringField(payload.command_error ?? payload.commandError) || null,
|
||||
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
|
||||
attention_level: stringField(payload.command_error ?? payload.commandError) ? "error" : "action",
|
||||
};
|
||||
}
|
||||
|
||||
function approvalActionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const comment = objectRecord(payload.comment_summary);
|
||||
const delegate = objectRecord(payload.delegate_summary);
|
||||
@@ -1774,7 +1795,7 @@ function notificationBusinessView(payload: Record<string, unknown>): Record<stri
|
||||
}
|
||||
|
||||
function eventBusinessCategory(kind: string): string {
|
||||
if (kind === "route_decision" || kind === "route_decision_blocked" || kind === "route_decision_approval_required") return "route";
|
||||
if (kind === "route_decision" || kind === "route_decision_blocked" || kind === "route_decision_approval_required" || kind === "route_decision_intervention_resolved") return "route";
|
||||
if (kind.startsWith("approval_")) return "approval";
|
||||
if (kind.startsWith("risk_")) return "approval";
|
||||
if (isApprovalSyncEvent(kind)) return "approval";
|
||||
|
||||
@@ -173,9 +173,15 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async listRouteDecisions(options?: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:listRouteDecisions", options);
|
||||
},
|
||||
async listRouteInterventions(options?: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:listRouteInterventions", options);
|
||||
},
|
||||
async recordRouteDecision(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:recordRouteDecision", input);
|
||||
},
|
||||
async recordRouteInterventionResolution(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:recordRouteInterventionResolution", input);
|
||||
},
|
||||
async recordGovernanceCommand(command: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user