吸收数字员工入口路由策略下发
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user