吸收数字员工入口路由干预回写

This commit is contained in:
baiyanyun
2026-06-11 18:31:41 +08:00
parent d292c34579
commit 4c862ef4b7
16 changed files with 1151 additions and 191 deletions

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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"],
];

View File

@@ -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");

View File

@@ -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) => {

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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";

View File

@@ -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);
},