吸收数字员工入口路由策略下发

This commit is contained in:
baiyanyun
2026-06-11 17:44:16 +08:00
parent 43418d0453
commit d292c34579
16 changed files with 1195 additions and 190 deletions

View File

@@ -29,6 +29,7 @@ import type {
SkillSpec,
CreatePlanRequest,
RouteDecisionTimelineResponse,
RouteDecisionPolicyPullResult,
IngressRouteRequest,
IngressRouteResponse,
GovernanceCommandRequest,
@@ -631,6 +632,12 @@ export function pullRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?: boole
});
}
export function pullRouteDecisionPolicy(): Promise<RouteDecisionPolicyPullResult> {
return apiFetch<RouteDecisionPolicyPullResult>('/api/route-decision/policy/pull', {
method: 'POST',
});
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {

View File

@@ -38,6 +38,8 @@ import type {
PlanStepDispatchPolicy,
PlanStepDispatchPolicyPullResult,
PlanStepSummary,
RouteDecisionPolicy,
RouteDecisionPolicyPullResult,
RouteDecisionRecord,
RouteDecisionTimelineResponse,
SkillSpec,
@@ -59,6 +61,8 @@ declare global {
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
pullRouteDecisionPolicy?: () => Promise<RouteDecisionPolicyPullResult>;
evaluateRouteDecisionPolicy?: (input: QimingclawRouteDecisionPolicyInput) => Promise<QimingclawRouteDecisionPolicyResult>;
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>;
@@ -100,6 +104,7 @@ interface AdapterState {
notificationPreferences?: NotificationPreferences;
approvalSubscriptionPreferences?: ApprovalSubscriptionPreferences;
planStepDispatchPolicy?: PlanStepDispatchPolicy;
routeDecisionPolicy?: RouteDecisionPolicy;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -360,6 +365,10 @@ interface QimingclawRouteDecisionInput {
createsTaskRun?: boolean;
approvalMode?: string | null;
policyResult?: string | null;
policySource?: string | null;
blockedReason?: string | null;
approvalId?: string | null;
matchedIntent?: string | null;
entrypoint?: string | null;
actor?: string | null;
message?: string | null;
@@ -367,6 +376,38 @@ interface QimingclawRouteDecisionInput {
recordedAt?: string | null;
}
interface QimingclawRouteDecisionPolicyInput {
routeKind?: string | null;
intentKind?: string | null;
reasonCodes?: string[] | null;
candidateSkills?: string[] | null;
contextRefs?: Record<string, unknown> | null;
correlationId?: string | null;
idempotencyKey?: string | null;
entrypoint?: string | null;
actor?: string | null;
source?: string | null;
payload?: Record<string, unknown> | null;
occurredAt?: string | null;
}
interface QimingclawRouteDecisionPolicyResult {
ok?: boolean;
decision?: 'allowed' | 'approval_required' | 'blocked' | string;
status?: 'allowed' | 'approval_required' | 'blocked' | string;
policy_result?: 'accepted' | 'approval_required' | 'blocked' | string;
route_kind?: string;
intent_kind?: string;
reason_codes?: string[];
matched_intent?: string | null;
blocked_reason?: string | null;
approval_id?: string | null;
event_id?: string | null;
policy_source?: string | null;
policy?: RouteDecisionPolicy;
error?: string | null;
}
interface QimingclawRiskPolicyInput {
actionKind?: string | null;
actionLabel?: string | null;
@@ -1022,6 +1063,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname === '/api/risk-approval/policy/pull') {
return await pullBridgeRiskApprovalPolicy() as T;
}
if (method === 'POST' && pathname === '/api/route-decision/policy/pull') {
return await pullBridgeRouteDecisionPolicy() as T;
}
if (method === 'POST' && pathname === '/api/approval/updates/pull') {
return await pullBridgeApprovalUpdates() as T;
}
@@ -1045,6 +1089,7 @@ function defaultState(): AdapterState {
notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
planStepDispatchPolicy: defaultPlanStepDispatchPolicy(),
routeDecisionPolicy: defaultRouteDecisionPolicy(),
consoleRole: 'sales',
rolePreferences: {},
profile: {
@@ -1072,6 +1117,7 @@ function normalizeAdapterState(value: Partial<AdapterState>): AdapterState {
notificationPreferences: notificationPreferences(value),
approvalSubscriptionPreferences: approvalSubscriptionPreferences(value),
planStepDispatchPolicy: planStepDispatchPolicy(value),
routeDecisionPolicy: routeDecisionPolicy(value),
};
}
@@ -1177,6 +1223,27 @@ async function pullBridgeRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?:
}
}
async function pullBridgeRouteDecisionPolicy(): Promise<RouteDecisionPolicyPullResult> {
const fallbackPolicy = routeDecisionPolicy((await readBridgeUiState()) ?? readState());
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullRouteDecisionPolicy) {
return { ok: false, policy: fallbackPolicy, error: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.pullRouteDecisionPolicy();
return {
ok: result?.ok !== false,
skipped: result?.skipped,
endpoint: result?.endpoint ?? null,
pulled_at: result?.pulled_at ?? null,
policy: routeDecisionPolicy({ routeDecisionPolicy: result?.policy ?? fallbackPolicy }),
error: result?.error ?? null,
};
} catch (error) {
return { ok: false, policy: fallbackPolicy, error: error instanceof Error ? error.message : String(error) };
}
}
async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullApprovalUpdates) {
@@ -1223,6 +1290,39 @@ async function evaluateBridgeRiskPolicy(input: QimingclawRiskPolicyInput): Promi
}
}
async function evaluateBridgeRouteDecisionPolicy(input: QimingclawRouteDecisionPolicyInput): Promise<QimingclawRouteDecisionPolicyResult> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.evaluateRouteDecisionPolicy) {
return {
ok: true,
decision: 'allowed',
status: 'allowed',
policy_result: 'accepted',
route_kind: input.routeKind || 'ChatOnly',
intent_kind: normalizeRouteIntentKind(input.intentKind),
reason_codes: ['qimingclaw_bridge_unavailable'],
policy_source: 'local',
policy: defaultRouteDecisionPolicy(),
};
}
try {
return await bridge.evaluateRouteDecisionPolicy(input);
} catch (error) {
return {
ok: true,
decision: 'allowed',
status: 'allowed',
policy_result: 'accepted',
route_kind: input.routeKind || 'ChatOnly',
intent_kind: normalizeRouteIntentKind(input.intentKind),
reason_codes: ['route_policy_evaluation_failed'],
policy_source: 'local',
policy: defaultRouteDecisionPolicy(),
error: error instanceof Error ? error.message : String(error),
};
}
}
function riskPolicyBlockedResponse(result: QimingclawRiskPolicyResult): Record<string, unknown> | null {
if (result.decision === 'allowed') return null;
return {
@@ -3097,6 +3197,10 @@ function routeDecisionFromRuntimePayload(payload: Record<string, unknown>, event
creates_task_run: Boolean(decision.creates_task_run ?? decision.createsTaskRun),
approval_mode: stringValue(decision.approval_mode ?? decision.approvalMode) || null,
policy_result: stringValue(decision.policy_result ?? decision.policyResult) || 'accepted',
policy_source: stringValue(decision.policy_source ?? decision.policySource) || null,
blocked_reason: stringValue(decision.blocked_reason ?? decision.blockedReason) || null,
approval_id: stringValue(decision.approval_id ?? decision.approvalId) || null,
matched_intent: stringValue(decision.matched_intent ?? decision.matchedIntent) || null,
recorded_at: stringValue(decision.recorded_at ?? decision.recordedAt) || event.occurredAt,
entrypoint: stringValue(decision.entrypoint) || null,
actor: stringValue(decision.actor) || null,
@@ -3104,6 +3208,7 @@ function routeDecisionFromRuntimePayload(payload: Record<string, unknown>, event
}
function routeDecisionAttentionLevel(decision: RouteDecisionRecord): 'info' | 'action' | 'warn' | 'error' {
if (decision.policy_result === 'approval_required') return 'action';
if (decision.policy_result && decision.policy_result !== 'accepted') return 'error';
if (decision.reason_codes.includes('candidate_skills_disabled') || decision.reason_codes.includes('route_action_missing_context')) return 'warn';
if (decision.created_command_id) return 'action';
@@ -3113,6 +3218,7 @@ function routeDecisionAttentionLevel(decision: RouteDecisionRecord): 'info' | 'a
function routeDecisionStatusLabel(decision: RouteDecisionRecord): string {
const attentionLevel = routeDecisionAttentionLevel(decision);
if (attentionLevel === 'error') return '策略拒绝';
if (decision.policy_result === 'approval_required') return '待审批';
if (attentionLevel === 'warn') return '需要关注';
if (decision.created_command_id) return '已映射命令';
return '已记录';
@@ -3121,10 +3227,16 @@ function routeDecisionStatusLabel(decision: RouteDecisionRecord): string {
function buildRouteDecisionSummary(snapshot: QimingclawSnapshot | null): DigitalEmployeeRouteSummary {
const rows = routeDecisionRows(snapshot);
const latest = rows[0] ?? null;
const policy = routeDecisionPolicy(snapshot?.uiState ?? readState());
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,
policy_source: policy.source ?? null,
policy_last_pulled_at: policy.last_pulled_at ?? null,
policy_last_pull_error: policy.last_pull_error ?? null,
latest: latest
? {
id: stringValue(latest.route_decision_id),
@@ -3133,6 +3245,9 @@ function buildRouteDecisionSummary(snapshot: QimingclawSnapshot | null): Digital
status_label: stringValue(latest.status_label),
attention_level: stringValue(latest.attention_level),
created_command_id: stringValue(latest.created_command_id) || null,
policy_source: stringValue(latest.policy_source) || null,
blocked_reason: stringValue(latest.blocked_reason) || null,
approval_id: stringValue(latest.approval_id) || null,
recorded_at: stringValue(latest.recorded_at),
}
: null,
@@ -4152,6 +4267,21 @@ function defaultPlanStepDispatchPolicy(): PlanStepDispatchPolicy {
};
}
function defaultRouteDecisionPolicy(): RouteDecisionPolicy {
return {
enabled: true,
auto_create_governance_commands: true,
approval_required_intents: [],
blocked_intents: [],
preferred_route_kinds: [],
updated_at: null,
source: 'local',
remote_updated_at: null,
last_pulled_at: null,
last_pull_error: null,
};
}
function approvalSubscriptionPreferences(state: Partial<AdapterState> | null | undefined): ApprovalSubscriptionPreferences {
const stored = state?.approvalSubscriptionPreferences;
const fallback = defaultApprovalSubscriptionPreferences();
@@ -4188,6 +4318,28 @@ function planStepDispatchPolicy(state: Partial<AdapterState> | null | undefined)
};
}
function routeDecisionPolicy(state: Partial<AdapterState> | null | undefined): RouteDecisionPolicy {
const stored = state?.routeDecisionPolicy;
const fallback = defaultRouteDecisionPolicy();
if (!stored || typeof stored !== 'object') return fallback;
return {
enabled: stored.enabled !== false,
auto_create_governance_commands: stored.auto_create_governance_commands !== false,
approval_required_intents: uniqueStrings(arrayValue(stored.approval_required_intents).map(normalizeRouteIntentKind)),
blocked_intents: uniqueStrings(arrayValue(stored.blocked_intents).map(normalizeRouteIntentKind)),
preferred_route_kinds: uniqueStrings(arrayValue(stored.preferred_route_kinds)),
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
source: stored.source === 'remote' ? 'remote' : 'local',
remote_updated_at: typeof stored.remote_updated_at === 'string' ? stored.remote_updated_at : null,
last_pulled_at: typeof stored.last_pulled_at === 'string' ? stored.last_pulled_at : null,
last_pull_error: typeof stored.last_pull_error === 'string' ? stored.last_pull_error : null,
};
}
function normalizeRouteIntentKind(value: unknown): string {
return stringValue(value).trim().toLowerCase().replace(/[^a-z0-9_.:-]+/g, '_') || 'chat';
}
function notificationPreferences(state: Partial<AdapterState> | null | undefined): NotificationPreferences {
const stored = state?.notificationPreferences;
const fallback = defaultNotificationPreferences();
@@ -5720,11 +5872,50 @@ async function handleIngressRoute(
creates_task_run: classification.createsTaskRun,
approval_mode: null,
policy_result: 'accepted',
policy_source: null,
blocked_reason: null,
approval_id: null,
matched_intent: null,
recorded_at: recordedAt,
entrypoint: body.entrypoint ?? null,
actor: body.actor ?? null,
};
const routeCommand = buildGovernanceCommandForRouteDecision(decision, body, snapshot);
const routePolicy = await evaluateBridgeRouteDecisionPolicy({
routeKind: decision.route_kind,
intentKind: decision.intent_kind,
reasonCodes: decision.reason_codes,
candidateSkills: decision.candidate_skills,
contextRefs: decision.context_refs,
correlationId,
idempotencyKey: body.idempotency_key,
entrypoint: body.entrypoint ?? null,
actor: body.actor ?? null,
source: 'qimingclaw-ingress-route',
payload: {
text: body.text ?? null,
payload: body.payload ?? null,
attachments: body.attachments ?? [],
},
occurredAt: recordedAt,
});
const routePolicyDecision = stringValue(routePolicy.decision) || 'allowed';
decision.policy_result = routePolicy.policy_result || (routePolicyDecision === 'allowed' ? 'accepted' : routePolicyDecision) || 'accepted';
decision.policy_source = routePolicy.policy_source ?? routePolicy.policy?.source ?? null;
decision.blocked_reason = routePolicy.blocked_reason ?? null;
decision.approval_id = routePolicy.approval_id ?? null;
decision.matched_intent = routePolicy.matched_intent ?? null;
decision.reason_codes = uniqueStrings([...decision.reason_codes, ...(routePolicy.reason_codes ?? [])]);
const preferredKinds = routePolicy.policy?.preferred_route_kinds ?? [];
if (preferredKinds.length > 0 && !preferredKinds.includes(decision.route_kind)) {
decision.reason_codes = uniqueStrings([...decision.reason_codes, 'route_kind_not_preferred']);
}
const shouldExecuteCommand = routePolicyDecision === 'allowed' && routePolicy.policy?.auto_create_governance_commands !== false;
if (routePolicyDecision === 'allowed' && routePolicy.policy?.auto_create_governance_commands === false) {
decision.reason_codes = uniqueStrings([...decision.reason_codes, 'route_command_auto_create_disabled']);
}
const routeCommand = shouldExecuteCommand
? buildGovernanceCommandForRouteDecision(decision, body, snapshot)
: { command: null, reasonCode: routePolicyDecision === 'allowed' ? 'route_command_auto_create_disabled' : `route_policy_${decision.policy_result}` };
const command = routeCommand.command
? await handleGovernanceCommand(routeCommand.command, snapshot)
: null;
@@ -5750,6 +5941,10 @@ async function handleIngressRoute(
createsTaskRun: decision.creates_task_run,
approvalMode: decision.approval_mode,
policyResult: decision.policy_result,
policySource: decision.policy_source,
blockedReason: decision.blocked_reason,
approvalId: decision.approval_id,
matchedIntent: decision.matched_intent,
entrypoint: decision.entrypoint,
actor: decision.actor,
message: `入口路由决策:${decision.route_kind} / ${decision.intent_kind}`,
@@ -5763,6 +5958,31 @@ async function handleIngressRoute(
: null;
const routeDecision = recorded ?? decision;
const resultIds = routeCommandResultIds(command);
if (routePolicyDecision !== 'allowed') {
return {
accepted: false,
envelope: {
entrypoint: body.entrypoint ?? 'chat',
text: body.text ?? null,
payload: body.payload ?? null,
attachments: body.attachments ?? [],
context_refs: contextRefs,
},
response_kind: 'route_decision',
route_decision: routeDecision,
command: null,
created_plan_id: null,
created_schedule_id: null,
created_task_ids: [],
created_run_ids: [],
projection_name: null,
projection_payload: null,
error: 'policy_blocked',
message: routePolicyDecision === 'approval_required'
? `入口路由需要审批:${routeDecision.route_kind} / ${routeDecision.intent_kind}`
: `入口路由已被策略阻断:${routeDecision.route_kind} / ${routeDecision.intent_kind}`,
};
}
return {
accepted: true,
envelope: {

View File

@@ -14,6 +14,7 @@ import {
pullApprovalUpdates,
pullPlanStepDispatchPolicy,
pullRiskApprovalPolicy,
pullRouteDecisionPolicy,
recordApprovalAction,
restartManagedService,
} from '@/lib/api';
@@ -1414,6 +1415,28 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [fetchProjection]);
const refreshRouteDecisionPolicy = useCallback(async () => {
setActionBusy('pull_route_decision_policy');
setActionError(null);
setActionNotice(null);
try {
const result = await pullRouteDecisionPolicy();
if (!mountedRef.current) return;
await fetchProjection();
if (!mountedRef.current) return;
const policy = result.policy;
setActionNotice(result.ok
? `入口路由策略已拉取:阻断 ${policy.blocked_intents.length} 类,审批 ${policy.approval_required_intents.length} 类。`
: `入口路由策略拉取失败,已沿用本地策略:${result.error || '未知错误'}`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '拉取入口路由策略失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const refreshApprovalUpdates = useCallback(async () => {
setActionBusy('pull_approval_updates');
setActionError(null);
@@ -2103,6 +2126,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<ShieldAlert size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_risk_approval_policy' ? '拉取中...' : '风险策略'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={projectionLoading || actionBusy === 'pull_route_decision_policy'}
onClick={refreshRouteDecisionPolicy}
title="从管理端拉取入口路由策略"
>
<MessageSquare size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_route_decision_policy' ? '拉取中...' : '路由策略'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
@@ -2325,7 +2358,13 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span><em></em><strong>{routeSummary.total}</strong></span>
<span><em></em><strong>{routeSummary.mapped_count}</strong></span>
<span className={routeSummary.attention_count > 0 ? 'is-warning' : ''}><em></em><strong>{routeSummary.attention_count}</strong></span>
<small>{latestRoute ? `${latestRoute.route_kind} / ${latestRoute.intent_kind} · ${latestRoute.status_label}` : '暂无路由决策'}</small>
{(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>}
<small>
{latestRoute ? `${latestRoute.route_kind} / ${latestRoute.intent_kind} · ${latestRoute.status_label}` : '暂无路由决策'}
{routeSummary.policy_source ? ` · 策略${routeSummary.policy_source === 'remote' ? '远端' : '本地'}` : ''}
{routeSummary.policy_last_pull_error ? ` · 拉取失败` : ''}
</small>
</div>
)}

View File

@@ -395,6 +395,10 @@ export interface RouteDecisionRecord {
creates_task_run: boolean;
approval_mode: string | null;
policy_result: string | null;
policy_source?: string | null;
blocked_reason?: string | null;
approval_id?: string | null;
matched_intent?: string | null;
recorded_at: string;
entrypoint?: string | null;
actor?: string | null;
@@ -405,6 +409,28 @@ export interface RouteDecisionTimelineResponse {
decisions: RouteDecisionRecord[];
}
export interface RouteDecisionPolicy {
enabled: boolean;
auto_create_governance_commands: boolean;
approval_required_intents: string[];
blocked_intents: string[];
preferred_route_kinds: string[];
updated_at: string | null;
source?: 'local' | 'remote';
remote_updated_at?: string | null;
last_pulled_at?: string | null;
last_pull_error?: string | null;
}
export interface RouteDecisionPolicyPullResult {
ok: boolean;
skipped?: boolean;
endpoint?: string | null;
pulled_at?: string | null;
policy: RouteDecisionPolicy;
error?: string | null;
}
export interface IngressRouteRequest {
entrypoint?: 'chat' | 'task_center' | 'cron' | 'webhook' | 'legacy_api' | 'browser_event' | 'file_event';
actor?: string;
@@ -961,6 +987,11 @@ export interface DigitalEmployeeRouteSummary {
total: number;
mapped_count: number;
attention_count: number;
blocked_count?: number;
approval_required_count?: number;
policy_source?: string | null;
policy_last_pulled_at?: string | null;
policy_last_pull_error?: string | null;
latest?: {
id: string;
route_kind: string;
@@ -968,6 +999,9 @@ export interface DigitalEmployeeRouteSummary {
status_label: string;
attention_level: string;
created_command_id?: string | null;
policy_source?: string | null;
blocked_reason?: string | null;
approval_id?: string | null;
recorded_at: 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-DxdWKD94.js"></script>
<script type="module" crossorigin src="./assets/index-BUX1PxSf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
</head>
<body>

View File

@@ -568,6 +568,55 @@ function checkDigitalRiskApprovalPolicy() {
console.log("[DigitalEmployeeCheck] risk approval policy hooks OK");
}
function checkDigitalRouteDecisionPolicy() {
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const commandDeckPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const stateServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "stateService.ts");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const ipcHandlersPath = path.join(packageRoot, "src", "main", "ipc", "digitalEmployeeHandlers.ts");
const preloadPath = path.join(packageRoot, "src", "preload", "webviewPerfBridge.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const commandDeck = fs.readFileSync(commandDeckPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const stateService = fs.readFileSync(stateServicePath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
const ipcHandlers = fs.readFileSync(ipcHandlersPath, "utf8");
const preload = fs.readFileSync(preloadPath, "utf8");
const requiredSnippets = [
[stateService, "routeDecisionPolicy", "route decision policy UI state"],
[stateService, "normalizeRouteDecisionPolicy", "route decision policy normalizer"],
[stateService, "evaluateDigitalEmployeeRouteDecisionPolicy", "route decision policy evaluator"],
[stateService, "route_decision_blocked", "route decision blocked event kind"],
[stateService, "route_decision_approval_required", "route decision approval-required 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"],
[ipcHandlers, "digitalEmployee:pullRouteDecisionPolicy", "IPC route decision policy pull handler"],
[ipcHandlers, "digitalEmployee:evaluateRouteDecisionPolicy", "IPC route decision policy evaluation handler"],
[preload, "pullRouteDecisionPolicy", "preload route decision policy pull bridge"],
[preload, "evaluateRouteDecisionPolicy", "preload route decision policy evaluation 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, "auto_create_governance_commands", "adapter route command auto-create switch"],
[api, "pullRouteDecisionPolicy", "embedded route decision policy pull API"],
[types, "RouteDecisionPolicy", "embedded route decision policy type"],
[commandDeck, "pull_route_decision_policy", "home route decision policy pull action"],
[commandDeck, "路由策略", "home route decision policy pull button"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing route decision policy hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] route decision policy hooks OK");
}
function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -617,6 +666,7 @@ function main() {
checkDigitalPlanStepGraphDispatchPolicy();
checkDigitalSkillRemotePolicy();
checkDigitalRiskApprovalPolicy();
checkDigitalRouteDecisionPolicy();
checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) {

View File

@@ -76,6 +76,7 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
readDigitalEmployeeUiState: vi.fn(),
readDigitalEmployeeCronSettings: vi.fn(),
evaluateDigitalEmployeeRiskPolicy: vi.fn(() => ({ ok: true, decision: "allowed", status: "allowed" })),
evaluateDigitalEmployeeRouteDecisionPolicy: vi.fn(() => ({ ok: true, decision: "allowed", status: "allowed", policy_result: "accepted" })),
saveDigitalEmployeeCronSettings: vi.fn(),
readDigitalEmployeeScheduleRuns: vi.fn(),
saveDigitalEmployeeUiState: vi.fn(),
@@ -98,6 +99,7 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
pullDigitalEmployeePlanStepDispatchPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null } })),
pullDigitalEmployeeSkillPolicies: vi.fn(async () => ({ ok: true, policies: { version: 1, local_skills: {}, remote_skills: {}, skills: {} } })),
pullDigitalEmployeeRiskApprovalPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, approval_required_risk_levels: ["high"], approval_required_actions: [], auto_reject_actions: [] } })),
pullDigitalEmployeeRouteDecisionPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, auto_create_governance_commands: true, approval_required_intents: [], blocked_intents: [], preferred_route_kinds: [], updated_at: null } })),
pullDigitalEmployeeApprovalUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
submitDigitalEmployeeApprovalDecision: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
}));
@@ -294,6 +296,28 @@ describe("digital employee managed service actions", () => {
expect(stateService.evaluateDigitalEmployeeRiskPolicy).toHaveBeenCalledWith({ actionKind: "governance.run_schedule_now" });
});
it("registers route decision policy pull and evaluation through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const syncService = await import("../services/digitalEmployee/syncService");
const stateService = await import("../services/digitalEmployee/stateService");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const pullCall = calls.find(([channel]) => channel === "digitalEmployee:pullRouteDecisionPolicy");
const evaluateCall = calls.find(([channel]) => channel === "digitalEmployee:evaluateRouteDecisionPolicy");
expect(pullCall).toBeTruthy();
expect(evaluateCall).toBeTruthy();
const pullResult = await (pullCall?.[1] as () => Promise<unknown>)();
const evaluateResult = await (evaluateCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { intentKind: "schedule" });
expect(pullResult).toMatchObject({ ok: true });
expect(evaluateResult).toMatchObject({ decision: "allowed", policy_result: "accepted" });
expect(syncService.pullDigitalEmployeeRouteDecisionPolicy).toHaveBeenCalledWith({ force: true });
expect(stateService.evaluateDigitalEmployeeRouteDecisionPolicy).toHaveBeenCalledWith({ intentKind: "schedule" });
});
it("registers approval action history through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");

View File

@@ -29,6 +29,7 @@ import {
readDigitalEmployeeUiState,
readDigitalEmployeeCronSettings,
evaluateDigitalEmployeeRiskPolicy,
evaluateDigitalEmployeeRouteDecisionPolicy,
saveDigitalEmployeeCronSettings,
readDigitalEmployeeScheduleRuns,
saveDigitalEmployeeUiState,
@@ -62,6 +63,7 @@ import {
pullDigitalEmployeePlanStepDispatchPolicy,
pullDigitalEmployeeSkillPolicies,
pullDigitalEmployeeRiskApprovalPolicy,
pullDigitalEmployeeRouteDecisionPolicy,
submitDigitalEmployeeApprovalDecision,
} from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
@@ -206,6 +208,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return pullDigitalEmployeeRiskApprovalPolicy({ force: true });
});
ipcMain.handle("digitalEmployee:pullRouteDecisionPolicy", async () => {
return pullDigitalEmployeeRouteDecisionPolicy({ force: true });
});
ipcMain.handle("digitalEmployee:pullApprovalUpdates", async () => {
return pullDigitalEmployeeApprovalUpdates({ force: true });
});
@@ -218,6 +224,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return evaluateDigitalEmployeeRiskPolicy(typeof input === "object" && input ? input as Parameters<typeof evaluateDigitalEmployeeRiskPolicy>[0] : {});
});
ipcMain.handle("digitalEmployee:evaluateRouteDecisionPolicy", async (_, input: unknown) => {
return evaluateDigitalEmployeeRouteDecisionPolicy(typeof input === "object" && input ? input as Parameters<typeof evaluateDigitalEmployeeRouteDecisionPolicy>[0] : {});
});
ipcMain.handle("digitalEmployee:getCronSettings", async () => {
return readDigitalEmployeeCronSettings();
});

View File

@@ -677,6 +677,96 @@ describe("digital employee state service", () => {
]);
});
it("normalizes route decision policy defaults and camel-case remote fields", async () => {
const { normalizeRouteDecisionPolicy } = await import("./stateService");
expect(normalizeRouteDecisionPolicy(null)).toMatchObject({
enabled: true,
auto_create_governance_commands: true,
approval_required_intents: [],
blocked_intents: [],
preferred_route_kinds: [],
source: "local",
});
expect(normalizeRouteDecisionPolicy({
autoCreateGovernanceCommands: false,
approvalRequiredIntents: ["Execute"],
blockedIntents: ["Schedule"],
preferredRouteKinds: ["PlanExecution"],
source: "remote",
remoteUpdatedAt: "2026-06-07T08:10:00.000Z",
lastPulledAt: "2026-06-07T08:11:00.000Z",
})).toMatchObject({
auto_create_governance_commands: false,
approval_required_intents: ["execute"],
blocked_intents: ["schedule"],
preferred_route_kinds: ["PlanExecution"],
source: "remote",
remote_updated_at: "2026-06-07T08:10:00.000Z",
last_pulled_at: "2026-06-07T08:11:00.000Z",
});
});
it("evaluates route decision policy interventions before governance command execution", async () => {
const { evaluateDigitalEmployeeRouteDecisionPolicy, readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService");
const state = readDigitalEmployeeUiState();
saveDigitalEmployeeUiState({
action: "pull_route_decision_policy",
state: {
...state,
routeDecisionPolicy: {
enabled: true,
auto_create_governance_commands: true,
approval_required_intents: ["execute"],
blocked_intents: ["schedule"],
preferred_route_kinds: [],
updated_at: "2026-06-07T08:00:00.000Z",
source: "remote",
},
},
});
const blocked = evaluateDigitalEmployeeRouteDecisionPolicy({
routeKind: "PlanExecution",
intentKind: "schedule",
correlationId: "corr-schedule",
occurredAt: "2026-06-07T08:12:00.000Z",
});
const approval = evaluateDigitalEmployeeRouteDecisionPolicy({
routeKind: "DirectRun",
intentKind: "execute",
correlationId: "corr-execute",
occurredAt: "2026-06-07T08:13:00.000Z",
});
const allowed = evaluateDigitalEmployeeRouteDecisionPolicy({
routeKind: "ProjectionQuery",
intentKind: "query",
reasonCodes: ["projection_query_text"],
});
expect(blocked).toMatchObject({
ok: false,
decision: "blocked",
policy_result: "blocked",
blocked_reason: "route_intent_blocked_by_policy",
matched_intent: "schedule",
policy_source: "remote",
});
expect(approval).toMatchObject({
ok: false,
decision: "approval_required",
policy_result: "approval_required",
matched_intent: "execute",
policy_source: "remote",
});
expect(approval.approval_id).toMatch(/^route-approval:execute:corr-execute:/);
expect(allowed).toMatchObject({ ok: true, decision: "allowed", policy_result: "accepted" });
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" });
});
it("updates formal approval records when UI approval decisions are saved", async () => {
const { saveDigitalEmployeeUiState } = await import("./stateService");
mockState.db?.approvals.set("approval-1", {

View File

@@ -440,6 +440,52 @@ export interface DigitalEmployeeRiskApprovalPolicy {
last_pull_error?: string | null;
}
export interface DigitalEmployeeRouteDecisionPolicy {
enabled: boolean;
auto_create_governance_commands: boolean;
approval_required_intents: string[];
blocked_intents: string[];
preferred_route_kinds: string[];
updated_at: string | null;
source?: "local" | "remote";
remote_updated_at?: string | null;
last_pulled_at?: string | null;
last_pull_error?: string | null;
}
export type DigitalEmployeeRouteDecisionPolicyDecision = "allowed" | "approval_required" | "blocked";
export interface DigitalEmployeeRouteDecisionPolicyInput {
routeKind?: string | null;
intentKind?: string | null;
reasonCodes?: string[] | null;
candidateSkills?: string[] | null;
contextRefs?: Record<string, unknown> | null;
correlationId?: string | null;
idempotencyKey?: string | null;
entrypoint?: string | null;
actor?: string | null;
source?: string | null;
payload?: Record<string, unknown> | null;
occurredAt?: string | null;
}
export interface DigitalEmployeeRouteDecisionPolicyResult {
ok: boolean;
decision: DigitalEmployeeRouteDecisionPolicyDecision;
status: DigitalEmployeeRouteDecisionPolicyDecision;
policy_result: "accepted" | "approval_required" | "blocked";
route_kind: string;
intent_kind: string;
reason_codes: string[];
matched_intent?: string | null;
blocked_reason?: string | null;
approval_id?: string | null;
event_id?: string | null;
policy_source: "local" | "remote";
policy: DigitalEmployeeRouteDecisionPolicy;
}
export type DigitalEmployeeRiskPolicyDecision = "allowed" | "approval_required" | "rejected";
export interface DigitalEmployeeRiskPolicyInput {
@@ -482,6 +528,10 @@ export interface DigitalEmployeeRouteDecisionRecord {
creates_task_run: boolean;
approval_mode: string | null;
policy_result: string | null;
policy_source?: string | null;
blocked_reason?: string | null;
approval_id?: string | null;
matched_intent?: string | null;
recorded_at: string;
entrypoint?: string | null;
actor?: string | null;
@@ -501,6 +551,10 @@ export interface DigitalEmployeeRouteDecisionInput {
createsTaskRun?: boolean;
approvalMode?: string | null;
policyResult?: string | null;
policySource?: string | null;
blockedReason?: string | null;
approvalId?: string | null;
matchedIntent?: string | null;
entrypoint?: string | null;
actor?: string | null;
message?: string | null;
@@ -628,6 +682,7 @@ export interface DigitalEmployeeUiState {
};
planStepDispatchPolicy?: DigitalEmployeePlanStepDispatchPolicy;
riskApprovalPolicy?: DigitalEmployeeRiskApprovalPolicy;
routeDecisionPolicy?: DigitalEmployeeRouteDecisionPolicy;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -807,6 +862,7 @@ function defaultUiState(): DigitalEmployeeUiState {
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
planStepDispatchPolicy: defaultPlanStepDispatchPolicy(),
riskApprovalPolicy: defaultRiskApprovalPolicy(),
routeDecisionPolicy: defaultRouteDecisionPolicy(),
consoleRole: "sales",
rolePreferences: {},
profile: {
@@ -906,6 +962,21 @@ function defaultRiskApprovalPolicy(): DigitalEmployeeRiskApprovalPolicy {
};
}
function defaultRouteDecisionPolicy(): DigitalEmployeeRouteDecisionPolicy {
return {
enabled: true,
auto_create_governance_commands: true,
approval_required_intents: [],
blocked_intents: [],
preferred_route_kinds: [],
updated_at: null,
source: "local",
remote_updated_at: null,
last_pulled_at: null,
last_pull_error: null,
};
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())))];
@@ -990,6 +1061,26 @@ export function normalizeRiskApprovalPolicy(value: unknown): DigitalEmployeeRisk
};
}
export function normalizeRouteDecisionPolicy(value: unknown): DigitalEmployeeRouteDecisionPolicy {
const fallback = defaultRouteDecisionPolicy();
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const source = record.source === "remote" ? "remote" : "local";
return {
enabled: record.enabled !== false,
auto_create_governance_commands: (record.auto_create_governance_commands ?? record.autoCreateGovernanceCommands) !== false,
approval_required_intents: normalizeStringArray(record.approval_required_intents ?? record.approvalRequiredIntents).map(normalizeRouteIntentKind),
blocked_intents: normalizeStringArray(record.blocked_intents ?? record.blockedIntents).map(normalizeRouteIntentKind),
preferred_route_kinds: normalizeStringArray(record.preferred_route_kinds ?? record.preferredRouteKinds),
updated_at: typeof (record.updated_at ?? record.updatedAt) === "string" ? String(record.updated_at ?? record.updatedAt) : null,
source,
remote_updated_at: typeof (record.remote_updated_at ?? record.remoteUpdatedAt) === "string" ? String(record.remote_updated_at ?? record.remoteUpdatedAt) : null,
last_pulled_at: typeof (record.last_pulled_at ?? record.lastPulledAt) === "string" ? String(record.last_pulled_at ?? record.lastPulledAt) : null,
last_pull_error: typeof (record.last_pull_error ?? record.lastPullError) === "string" ? String(record.last_pull_error ?? record.lastPullError) : null,
};
}
function normalizeUiState(
value: Partial<DigitalEmployeeUiState>,
): DigitalEmployeeUiState {
@@ -1008,6 +1099,7 @@ function normalizeUiState(
approvalSubscriptionPreferences: normalizeApprovalSubscriptionPreferences(value.approvalSubscriptionPreferences),
planStepDispatchPolicy: normalizePlanStepDispatchPolicy(value.planStepDispatchPolicy),
riskApprovalPolicy: normalizeRiskApprovalPolicy(value.riskApprovalPolicy),
routeDecisionPolicy: normalizeRouteDecisionPolicy(value.routeDecisionPolicy),
consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim()
? value.consoleRole
: fallback.consoleRole,
@@ -1056,6 +1148,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
return "数字员工PlanStep派发策略已拉取";
case "pull_risk_approval_policy":
return "数字员工风险审批策略已拉取";
case "pull_route_decision_policy":
return "数字员工入口路由策略已拉取";
case "approval_updates_pulled":
return "数字员工审批更新已拉取";
case "approval_updates_pull_failed":
@@ -1101,6 +1195,10 @@ export function readDigitalEmployeeRiskApprovalPolicy(): DigitalEmployeeRiskAppr
return readDigitalEmployeeUiState().riskApprovalPolicy ?? defaultRiskApprovalPolicy();
}
export function readDigitalEmployeeRouteDecisionPolicy(): DigitalEmployeeRouteDecisionPolicy {
return readDigitalEmployeeUiState().routeDecisionPolicy ?? defaultRouteDecisionPolicy();
}
export function saveDigitalEmployeeUiState(
update: DigitalEmployeeUiStateUpdate,
): DigitalEmployeeUiState {
@@ -1600,6 +1698,10 @@ export function recordDigitalEmployeeRouteDecision(
creates_task_run: Boolean(input.createsTaskRun),
approval_mode: stringValue(input.approvalMode) || null,
policy_result: stringValue(input.policyResult) || null,
policy_source: stringValue(input.policySource) || null,
blocked_reason: stringValue(input.blockedReason) || null,
approval_id: stringValue(input.approvalId) || null,
matched_intent: stringValue(input.matchedIntent) || null,
recorded_at: now,
entrypoint: stringValue(input.entrypoint) || null,
actor: stringValue(input.actor) || null,
@@ -1641,6 +1743,162 @@ export function listDigitalEmployeeRouteDecisions(options: { limit?: number } =
.filter((decision): decision is DigitalEmployeeRouteDecisionRecord => Boolean(decision));
}
export function evaluateDigitalEmployeeRouteDecisionPolicy(
input: DigitalEmployeeRouteDecisionPolicyInput,
): DigitalEmployeeRouteDecisionPolicyResult {
const policy = readDigitalEmployeeRouteDecisionPolicy();
const routeKind = normalizeRouteKind(input.routeKind);
const intentKind = normalizeRouteIntentKind(input.intentKind);
const baseReasonCodes = normalizeSkillIds(input.reasonCodes ?? []);
const source = policy.source || "local";
if (!policy.enabled) {
return {
ok: true,
decision: "allowed",
status: "allowed",
policy_result: "accepted",
route_kind: routeKind,
intent_kind: intentKind,
reason_codes: [...baseReasonCodes, "route_policy_disabled"],
policy_source: source,
policy,
};
}
const blockedIntents = new Set(policy.blocked_intents.map(normalizeRouteIntentKind));
const approvalRequiredIntents = new Set(policy.approval_required_intents.map(normalizeRouteIntentKind));
if (blockedIntents.has(intentKind)) {
return recordDigitalEmployeeRouteDecisionPolicyIntervention(input, policy, "blocked", [
...baseReasonCodes,
"route_intent_blocked_by_policy",
]);
}
if (approvalRequiredIntents.has(intentKind)) {
return recordDigitalEmployeeRouteDecisionPolicyIntervention(input, policy, "approval_required", [
...baseReasonCodes,
"route_intent_requires_approval",
]);
}
return {
ok: true,
decision: "allowed",
status: "allowed",
policy_result: "accepted",
route_kind: routeKind,
intent_kind: intentKind,
reason_codes: baseReasonCodes.length > 0 ? baseReasonCodes : ["route_policy_allowed"],
policy_source: source,
policy,
};
}
function recordDigitalEmployeeRouteDecisionPolicyIntervention(
input: DigitalEmployeeRouteDecisionPolicyInput,
policy: DigitalEmployeeRouteDecisionPolicy,
decision: Exclude<DigitalEmployeeRouteDecisionPolicyDecision, "allowed">,
reasonCodes: string[],
): DigitalEmployeeRouteDecisionPolicyResult {
const db = getDigitalEmployeeDb();
const now = input.occurredAt || new Date().toISOString();
const routeKind = normalizeRouteKind(input.routeKind);
const intentKind = normalizeRouteIntentKind(input.intentKind);
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)}`;
const policySource = policy.source || "local";
const blockedReason = decision === "blocked" ? "route_intent_blocked_by_policy" : null;
let approvalId: string | null = null;
if (!db) {
return {
ok: false,
decision: "blocked",
status: "blocked",
policy_result: "blocked",
route_kind: routeKind,
intent_kind: intentKind,
reason_codes: [...reasonCodes, "database_unavailable"],
matched_intent: intentKind,
blocked_reason: "database_unavailable",
policy_source: policySource,
policy,
};
}
if (decision === "approval_required") {
approvalId = `route-approval:${safeId(intentKind)}:${safeId(correlationId)}:${safeId(now)}`;
upsertApproval({
id: approvalId,
planId: "",
taskId: "",
runId: "",
title: `入口路由审批:${routeKind} / ${intentKind}`,
status: "pending",
payload: {
source,
approval_kind: "route_decision_policy",
route_kind: routeKind,
intent_kind: intentKind,
reason_codes: reasonCodes,
policy_snapshot: routeDecisionPolicyAuditSnapshot(policy),
correlation_id: correlationId,
context_refs: objectRecord(input.contextRefs) ?? {},
route_payload: sanitizeRouteDecisionPolicyPayload(input.payload),
},
now,
});
}
const payload: Record<string, unknown> = {
source,
route_kind: routeKind,
intent_kind: intentKind,
decision,
status: decision,
policy_result: decision,
reason_codes: reasonCodes,
candidate_skills: normalizeSkillIds(input.candidateSkills ?? []),
context_refs: objectRecord(input.contextRefs) ?? {},
policy_source: policySource,
policy_snapshot: routeDecisionPolicyAuditSnapshot(policy),
blocked_reason: blockedReason,
approval_id: approvalId,
matched_intent: intentKind,
correlation_id: correlationId,
entrypoint: stringValue(input.entrypoint) || null,
actor: stringValue(input.actor) || null,
route_payload: sanitizeRouteDecisionPolicyPayload(input.payload),
};
insertEvent(
{
event_id: eventId,
kind: decision === "approval_required" ? "route_decision_approval_required" : "route_decision_blocked",
occurred_at: now,
message: routeDecisionPolicyMessage(decision, routeKind, intentKind),
plan_id: null,
task_id: null,
},
null,
payload,
);
return {
ok: false,
decision,
status: decision,
policy_result: decision,
route_kind: routeKind,
intent_kind: intentKind,
reason_codes: reasonCodes,
matched_intent: intentKind,
blocked_reason: blockedReason,
approval_id: approvalId,
event_id: eventId,
policy_source: policySource,
policy,
};
}
export function evaluateDigitalEmployeeRiskPolicy(
input: DigitalEmployeeRiskPolicyInput,
): DigitalEmployeeRiskPolicyResult {
@@ -1818,6 +2076,42 @@ function riskPolicyDecisionMessage(decision: Exclude<DigitalEmployeeRiskPolicyDe
: `高风险动作已被策略拒绝:${actionKind}`;
}
function normalizeRouteKind(value: unknown): string {
return stringValue(value).trim() || "ChatOnly";
}
function normalizeRouteIntentKind(value: unknown): string {
return stringValue(value).trim().toLowerCase().replace(/[^a-z0-9_.:-]+/g, "_") || "chat";
}
function routeDecisionPolicyAuditSnapshot(policy: DigitalEmployeeRouteDecisionPolicy): Record<string, unknown> {
return {
enabled: policy.enabled,
auto_create_governance_commands: policy.auto_create_governance_commands,
approval_required_intents: policy.approval_required_intents,
blocked_intents: policy.blocked_intents,
preferred_route_kinds: policy.preferred_route_kinds,
source: policy.source || "local",
remote_updated_at: policy.remote_updated_at ?? null,
last_pulled_at: policy.last_pulled_at ?? null,
last_pull_error: policy.last_pull_error ?? null,
};
}
function sanitizeRouteDecisionPolicyPayload(payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
return sanitizeRiskPolicyPayload(payload);
}
function routeDecisionPolicyMessage(
decision: Exclude<DigitalEmployeeRouteDecisionPolicyDecision, "allowed">,
routeKind: string,
intentKind: string,
): string {
return decision === "approval_required"
? `入口路由需要审批:${routeKind} / ${intentKind}`
: `入口路由已被策略阻断:${routeKind} / ${intentKind}`;
}
export function recordDigitalEmployeeSkillCallAudit(
input: DigitalEmployeeSkillCallAuditInput,
): DigitalEmployeeSkillCallAuditRecord | null {
@@ -2718,6 +3012,10 @@ function routeDecisionFromEventPayload(payload: unknown): DigitalEmployeeRouteDe
creates_task_run: Boolean(decision.creates_task_run ?? decision.createsTaskRun),
approval_mode: stringValue(decision.approval_mode ?? decision.approvalMode) || null,
policy_result: stringValue(decision.policy_result ?? decision.policyResult) || null,
policy_source: stringValue(decision.policy_source ?? decision.policySource) || null,
blocked_reason: stringValue(decision.blocked_reason ?? decision.blockedReason) || null,
approval_id: stringValue(decision.approval_id ?? decision.approvalId) || null,
matched_intent: stringValue(decision.matched_intent ?? decision.matchedIntent) || null,
recorded_at: stringValue(decision.recorded_at ?? decision.recordedAt) || new Date().toISOString(),
entrypoint: stringValue(decision.entrypoint) || null,
actor: stringValue(decision.actor) || null,

View File

@@ -137,6 +137,77 @@ describe("digital employee sync service", () => {
});
});
it("pulls remote route decision policy and records policy state", async () => {
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
data: {
routeDecisionPolicy: {
enabled: true,
autoCreateGovernanceCommands: false,
approvalRequiredIntents: ["Execute"],
blockedIntents: ["Schedule"],
preferredRouteKinds: ["PlanExecution"],
updatedAt: "2026-06-07T08:02:00.000Z",
},
},
}),
);
const { pullDigitalEmployeeRouteDecisionPolicy } = await import("./syncService");
const { readDigitalEmployeeUiState } = await import("./stateService");
const result = await pullDigitalEmployeeRouteDecisionPolicy({ force: true });
expect(result).toMatchObject({
ok: true,
endpoint: "https://manage.example.com/api/digital-employee/policies/route-decision",
policy: {
source: "remote",
enabled: true,
auto_create_governance_commands: false,
approval_required_intents: ["execute"],
blocked_intents: ["schedule"],
preferred_route_kinds: ["PlanExecution"],
remote_updated_at: "2026-06-07T08:02:00.000Z",
last_pulled_at: "2026-06-07T08:00:00.000Z",
last_pull_error: null,
},
});
expect(mockState.fetch).toHaveBeenCalledWith(
"https://manage.example.com/api/digital-employee/policies/route-decision",
expect.objectContaining({ method: "GET" }),
);
expect(readDigitalEmployeeUiState().routeDecisionPolicy).toMatchObject(result.policy);
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "digital_workday_pull_route_decision_policy" }),
]));
});
it("keeps the current route decision policy when remote policy pull fails", async () => {
mockState.fetch.mockResolvedValue({
ok: false,
status: 503,
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "route policy unavailable" })),
} as unknown as Response);
const { pullDigitalEmployeeRouteDecisionPolicy } = await import("./syncService");
const result = await pullDigitalEmployeeRouteDecisionPolicy({ force: true });
expect(result).toMatchObject({
ok: false,
error: "route policy unavailable",
policy: {
source: "local",
enabled: true,
auto_create_governance_commands: true,
blocked_intents: [],
approval_required_intents: [],
last_pulled_at: "2026-06-07T08:00:00.000Z",
last_pull_error: "route policy unavailable",
},
});
});
it("pulls remote skill policies and preserves local skill preferences", async () => {
mockState.settings.set("digital_employee_skill_policies_v1", {
version: 1,
@@ -947,6 +1018,10 @@ describe("digital employee sync service", () => {
reason_codes: ["ready_plan_step"],
candidate_skills: ["crm-search"],
created_command_id: "governance-command-1",
policy_source: "remote",
blocked_reason: null,
approval_id: "route-approval-1",
matched_intent: "schedule",
},
});
insertEventEntity("event-approval", "approval_action_recorded", {
@@ -1235,6 +1310,9 @@ describe("digital employee sync service", () => {
reason_codes: ["ready_plan_step"],
candidate_skills: ["crm-search"],
created_command_id: "governance-command-1",
policy_source: "remote",
approval_id: "route-approval-1",
matched_intent: "schedule",
attention_level: "action",
});
expect(businessView("event-approval")).toMatchObject({

View File

@@ -5,12 +5,14 @@ import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from ".
import {
normalizePlanStepDispatchPolicy,
normalizeRiskApprovalPolicy,
normalizeRouteDecisionPolicy,
readDigitalEmployeeUiState,
saveDigitalEmployeeUiState,
upsertDigitalEmployeeApprovalFromRemote,
type DigitalEmployeeApprovalUpdateInput,
type DigitalEmployeePlanStepDispatchPolicy,
type DigitalEmployeeRiskApprovalPolicy,
type DigitalEmployeeRouteDecisionPolicy,
} from "./stateService";
import {
SKILL_POLICIES_SETTING_KEY,
@@ -23,6 +25,7 @@ const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
const DEFAULT_SKILL_POLICY_PATH = "/api/digital-employee/policies/skills";
const DEFAULT_RISK_APPROVAL_POLICY_PATH = "/api/digital-employee/policies/risk-approval";
const DEFAULT_ROUTE_DECISION_POLICY_PATH = "/api/digital-employee/policies/route-decision";
const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
@@ -47,6 +50,8 @@ let skillPolicyPulling = false;
let lastSkillPolicyPullAttemptMs = 0;
let riskApprovalPolicyPulling = false;
let lastRiskApprovalPolicyPullAttemptMs = 0;
let routeDecisionPolicyPulling = false;
let lastRouteDecisionPolicyPullAttemptMs = 0;
let approvalUpdatesPulling = false;
let lastApprovalUpdatesPullAttemptMs = 0;
@@ -61,6 +66,7 @@ interface DigitalEmployeeSyncConfig {
policyEndpoint: string | null;
skillPolicyEndpoint: string | null;
riskApprovalPolicyEndpoint: string | null;
routeDecisionPolicyEndpoint: string | null;
approvalUpdatesEndpoint: string | null;
approvalDecisionsEndpoint: string | null;
leaseEndpoint: string | null;
@@ -96,6 +102,15 @@ export interface DigitalEmployeeRiskApprovalPolicyPullResult {
error?: string | null;
}
export interface DigitalEmployeeRouteDecisionPolicyPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
policy: DigitalEmployeeRouteDecisionPolicy;
error?: string | null;
}
export interface DigitalEmployeeApprovalUpdatesPullResult {
ok: boolean;
skipped?: boolean;
@@ -465,6 +480,63 @@ export async function pullDigitalEmployeeRiskApprovalPolicy(
}
}
export async function pullDigitalEmployeeRouteDecisionPolicy(
options: { force?: boolean } = {},
): Promise<DigitalEmployeeRouteDecisionPolicyPullResult> {
const currentState = readDigitalEmployeeUiState();
const currentPolicy = currentState.routeDecisionPolicy ?? normalizeRouteDecisionPolicy(null);
if (routeDecisionPolicyPulling) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().routeDecisionPolicyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
}
const nowMs = Date.now();
if (!options.force && nowMs - lastRouteDecisionPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().routeDecisionPolicyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
}
lastRouteDecisionPolicyPullAttemptMs = nowMs;
const config = resolveSyncConfig();
const missingCredentials = missingSyncCredentialLabels(config);
if (!config.routeDecisionPolicyEndpoint || missingCredentials.length > 0) {
const error = !config.routeDecisionPolicyEndpoint ? "management route decision policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
return recordRouteDecisionPolicyPullFailure(currentState, currentPolicy, config.routeDecisionPolicyEndpoint, error);
}
routeDecisionPolicyPulling = true;
try {
const response = await fetchJsonWithAuth(config, config.routeDecisionPolicyEndpoint);
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
const remotePolicy = readRemoteRouteDecisionPolicy(response);
if (!remotePolicy) throw new Error("management policy response missing route decision policy");
const pulledAt = new Date().toISOString();
const policy = normalizeRouteDecisionPolicy({
...remotePolicy,
source: "remote",
remote_updated_at: stringField(remotePolicy.updated_at) || stringField(remotePolicy.updatedAt) || stringField(remotePolicy.remote_updated_at) || null,
last_pulled_at: pulledAt,
last_pull_error: null,
});
saveDigitalEmployeeUiState({
action: "pull_route_decision_policy",
metadata: {
source: "management-api",
endpoint: config.routeDecisionPolicyEndpoint,
ok: true,
route_decision_policy: policy,
},
state: {
...currentState,
routeDecisionPolicy: policy,
},
});
return { ok: true, endpoint: config.routeDecisionPolicyEndpoint, pulled_at: pulledAt, policy, error: null };
} catch (error) {
return recordRouteDecisionPolicyPullFailure(currentState, currentPolicy, config.routeDecisionPolicyEndpoint, normalizeError(error));
} finally {
routeDecisionPolicyPulling = false;
}
}
export async function pullDigitalEmployeeApprovalUpdates(
options: { force?: boolean } = {},
): Promise<DigitalEmployeeApprovalUpdatesPullResult> {
@@ -794,6 +866,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.riskApprovalPolicyEndpoint.trim()
: null;
const riskApprovalPolicyEndpoint = riskApprovalPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_RISK_APPROVAL_POLICY_PATH);
const routeDecisionPolicyEndpointOverride =
typeof config.routeDecisionPolicyEndpoint === "string" && config.routeDecisionPolicyEndpoint.trim()
? config.routeDecisionPolicyEndpoint.trim()
: null;
const routeDecisionPolicyEndpoint = routeDecisionPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_ROUTE_DECISION_POLICY_PATH);
const approvalUpdatesEndpointOverride =
typeof config.approvalUpdatesEndpoint === "string" && config.approvalUpdatesEndpoint.trim()
? config.approvalUpdatesEndpoint.trim()
@@ -821,6 +898,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
policyEndpoint,
skillPolicyEndpoint,
riskApprovalPolicyEndpoint,
routeDecisionPolicyEndpoint,
approvalUpdatesEndpoint,
approvalDecisionsEndpoint,
leaseEndpoint,
@@ -984,6 +1062,14 @@ function readRemoteRiskApprovalPolicy(response: Record<string, unknown>): Record
?? (hasRiskApprovalPolicyFields(data) ? data : null);
}
function readRemoteRouteDecisionPolicy(response: Record<string, unknown>): Record<string, unknown> | null {
const data = objectRecord(response.data) ?? response;
return objectRecord(data.route_decision_policy)
?? objectRecord(data.routeDecisionPolicy)
?? objectRecord(data.policy)
?? (hasRouteDecisionPolicyFields(data) ? data : null);
}
function readRemoteApprovalUpdates(response: Record<string, unknown>): DigitalEmployeeApprovalUpdateInput[] {
const data = objectRecord(response.data) ?? response;
const raw = Array.isArray(data.approvals)
@@ -1088,6 +1174,17 @@ function hasRiskApprovalPolicyFields(record: Record<string, unknown>): boolean {
|| "autoRejectActions" in record;
}
function hasRouteDecisionPolicyFields(record: Record<string, unknown>): boolean {
return "approval_required_intents" in record
|| "approvalRequiredIntents" in record
|| "blocked_intents" in record
|| "blockedIntents" in record
|| "preferred_route_kinds" in record
|| "preferredRouteKinds" in record
|| "auto_create_governance_commands" in record
|| "autoCreateGovernanceCommands" in record;
}
function looksLikeSkillPolicyMap(record: Record<string, unknown>): boolean {
return Object.values(record).some((value) => {
if (typeof value === "boolean") return true;
@@ -1169,6 +1266,35 @@ function recordRiskApprovalPolicyPullFailure(
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
}
function recordRouteDecisionPolicyPullFailure(
state: ReturnType<typeof readDigitalEmployeeUiState>,
currentPolicy: DigitalEmployeeRouteDecisionPolicy,
endpoint: string | null,
error: string,
): DigitalEmployeeRouteDecisionPolicyPullResult {
const pulledAt = new Date().toISOString();
const policy = normalizeRouteDecisionPolicy({
...currentPolicy,
last_pulled_at: pulledAt,
last_pull_error: error,
});
saveDigitalEmployeeUiState({
action: "pull_route_decision_policy",
metadata: {
source: "management-api",
endpoint,
ok: false,
error,
route_decision_policy: policy,
},
state: {
...state,
routeDecisionPolicy: policy,
},
});
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
}
function recordApprovalSyncEvent(action: string, occurredAt: string, metadata: Record<string, unknown>): void {
const state = readDigitalEmployeeUiState();
saveDigitalEmployeeUiState({
@@ -1364,6 +1490,7 @@ function eventSpecificBusinessView(
eventId: string,
): 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 === "approval_decision") return approvalDecisionBusinessView(payload);
if (kind.startsWith("approval_action_")) return approvalActionBusinessView(payload);
if (isApprovalSyncEvent(kind)) return approvalSyncBusinessView(payload);
@@ -1395,10 +1522,31 @@ function routeDecisionBusinessView(payload: Record<string, unknown>, eventId: st
reason_codes: reasonCodes,
candidate_skills: stringArrayField(decision.candidate_skills ?? decision.candidateSkills),
created_command_id: createdCommandId,
policy_source: stringField(decision.policy_source ?? decision.policySource) || null,
blocked_reason: stringField(decision.blocked_reason ?? decision.blockedReason) || null,
approval_id: stringField(decision.approval_id ?? decision.approvalId) || null,
matched_intent: stringField(decision.matched_intent ?? decision.matchedIntent) || null,
attention_level: routeDecisionAttentionLevel(policyResult, reasonCodes, createdCommandId),
};
}
function routeDecisionPolicyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const policyResult = stringField(payload.policy_result ?? payload.decision) || stringField(payload.status) || "blocked";
const reasonCodes = stringArrayField(payload.reason_codes ?? payload.reasonCodes);
return {
route_kind: stringField(payload.route_kind ?? payload.routeKind) || "ChatOnly",
intent_kind: stringField(payload.intent_kind ?? payload.intentKind) || "chat",
policy_result: policyResult,
policy_source: stringField(payload.policy_source ?? payload.policySource) || null,
blocked_reason: stringField(payload.blocked_reason ?? payload.blockedReason) || null,
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
matched_intent: stringField(payload.matched_intent ?? payload.matchedIntent) || null,
reason_codes: reasonCodes,
candidate_skills: stringArrayField(payload.candidate_skills ?? payload.candidateSkills),
attention_level: policyResult === "approval_required" ? "action" : "error",
};
}
function approvalActionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const comment = objectRecord(payload.comment_summary);
const delegate = objectRecord(payload.delegate_summary);
@@ -1626,7 +1774,7 @@ function notificationBusinessView(payload: Record<string, unknown>): Record<stri
}
function eventBusinessCategory(kind: string): string {
if (kind === "route_decision") return "route";
if (kind === "route_decision" || kind === "route_decision_blocked" || kind === "route_decision_approval_required") return "route";
if (kind.startsWith("approval_")) return "approval";
if (kind.startsWith("risk_")) return "approval";
if (isApprovalSyncEvent(kind)) return "approval";

View File

@@ -137,6 +137,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async pullRiskApprovalPolicy() {
return ipcRenderer.invoke("digitalEmployee:pullRiskApprovalPolicy");
},
async pullRouteDecisionPolicy() {
return ipcRenderer.invoke("digitalEmployee:pullRouteDecisionPolicy");
},
async pullApprovalUpdates() {
return ipcRenderer.invoke("digitalEmployee:pullApprovalUpdates");
},
@@ -146,6 +149,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async evaluateRiskPolicy(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:evaluateRiskPolicy", input);
},
async evaluateRouteDecisionPolicy(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:evaluateRouteDecisionPolicy", input);
},
async getCronSettings() {
return ipcRenderer.invoke("digitalEmployee:getCronSettings");
},

View File

@@ -453,6 +453,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action把处理结果写入 SQLite UI state回写正式 approval 状态并进入 approval outbox同时生成 `approval_decision` / `digital_workday_decide_approval` runtime event。approval payload 已支持 `workflow``steps``current_step_id``participants``decision_history`,旧单步审批会自动规范化为单步 workflow多步审批未完成时保持 `pending`,全部通过才进入 `approved`,任一步拒绝进入 `rejected`。同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。
- 审批跨端更新与回写已开始闭环:客户端可通过 `POST /api/approval/updates/pull` 拉取管理端 `GET /api/digital-employee/approvals/updates` 审批更新,也会在本地处理审批后通过 `POST /api/digital-employee/approvals/decisions` 尝试回写管理端;远端异常会写入 `digital_workday_approval_*` 事件,不阻断本地审批状态推进。
- 高风险动作审批策略已沉淀到 UI state 的 `riskApprovalPolicy`,可通过 `POST /api/risk-approval/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/risk-approval` 下发策略;受控的服务重启、产物访问/保留、审批动作、治理命令和 ready PlanStep 派发前会先评估风险策略,命中审批会写入 `risk_approval_required` approval/event命中拒绝会写入 `risk_policy_rejected` event管理端同步业务视图会提炼 action、risk level、decision、approval ID 和策略来源。
- 入口路由治理策略已沉淀到 UI state 的 `routeDecisionPolicy`,可通过 `POST /api/route-decision/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/route-decision` 下发策略;`/api/ingress/route` 在生成 route decision 后、映射 governance command 前会先评估策略,命中阻断会写入 `route_decision_blocked` event 并停止执行,命中审批会创建 `route_decision_approval_required` approval/event 并等待人工处理,放行时才继续按 `auto_create_governance_commands` 映射命令route decision 与管理端 business view 会携带 `policy_result``policy_source``blocked_reason``approval_id``matched_intent`
- 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan``submit_plan``approve_plan``activate_plan``create_schedule``run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。
- 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。
- 任务中心和委托详情不再使用 sgRobot demo mission fallback没有 qimingclaw Plan / Task / Run / Event 时展示真实空态或错误态,避免示例任务冒充本地任务记录。
@@ -597,9 +598,9 @@ GET /api/digital-employee/approvals
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions``/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decisionscheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要和通知联动已开始闭环。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decision入口路由策略可从管理端远端下发,支持按 intent 阻断、按 intent 要求审批、限制自动创建 governance command并在策略干预时先记录 route decision 再停止本地命令执行;scheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要、策略来源/拉取错误、阻断/待审计数和通知联动已开始闭环。
- 后续缺口:管理端正式路由 UI、路由策略下发和跨端路由干预入口仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 和路由策略下发,把入口路由从本地可观测推进到跨端治理策略。
- 建议:下一轮围绕管理端正式路由 UI 和审批处理回写,把入口路由从跨端治理策略推进到可运营的管理端干预台
11. **诊断 / 成本 / 审计视图**
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成只读诊断、估算成本和审计投影;数字员工首页已展示诊断/审计/成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。