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

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

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