吸收数字员工入口路由策略下发
This commit is contained in:
@@ -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]) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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 decision;scheduler 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` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。
|
||||
|
||||
Reference in New Issue
Block a user