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

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

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