吸收数字员工风险策略远端下发

This commit is contained in:
baiyanyun
2026-06-11 09:51:14 +08:00
parent 8ab3d73fb1
commit e9c76c59ca
15 changed files with 1175 additions and 192 deletions

View File

@@ -75,6 +75,7 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
readDigitalEmployeeState: vi.fn(),
readDigitalEmployeeUiState: vi.fn(),
readDigitalEmployeeCronSettings: vi.fn(),
evaluateDigitalEmployeeRiskPolicy: vi.fn(() => ({ ok: true, decision: "allowed", status: "allowed" })),
saveDigitalEmployeeCronSettings: vi.fn(),
readDigitalEmployeeScheduleRuns: vi.fn(),
saveDigitalEmployeeUiState: vi.fn(),
@@ -96,6 +97,7 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
getDigitalEmployeeSyncStatus: vi.fn(),
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: [] } })),
}));
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
@@ -268,6 +270,28 @@ describe("digital employee managed service actions", () => {
expect(syncService.pullDigitalEmployeeSkillPolicies).toHaveBeenCalledWith({ force: true });
});
it("registers risk approval 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:pullRiskApprovalPolicy");
const evaluateCall = calls.find(([channel]) => channel === "digitalEmployee:evaluateRiskPolicy");
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>)({}, { actionKind: "governance.run_schedule_now" });
expect(pullResult).toMatchObject({ ok: true });
expect(evaluateResult).toMatchObject({ decision: "allowed" });
expect(syncService.pullDigitalEmployeeRiskApprovalPolicy).toHaveBeenCalledWith({ force: true });
expect(stateService.evaluateDigitalEmployeeRiskPolicy).toHaveBeenCalledWith({ actionKind: "governance.run_schedule_now" });
});
it("registers approval action history through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");

View File

@@ -28,6 +28,7 @@ import {
readDigitalEmployeeState,
readDigitalEmployeeUiState,
readDigitalEmployeeCronSettings,
evaluateDigitalEmployeeRiskPolicy,
saveDigitalEmployeeCronSettings,
readDigitalEmployeeScheduleRuns,
saveDigitalEmployeeUiState,
@@ -59,6 +60,7 @@ import {
getDigitalEmployeeSyncStatus,
pullDigitalEmployeePlanStepDispatchPolicy,
pullDigitalEmployeeSkillPolicies,
pullDigitalEmployeeRiskApprovalPolicy,
} from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
import {
@@ -198,6 +200,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return pullDigitalEmployeeSkillPolicies({ force: true });
});
ipcMain.handle("digitalEmployee:pullRiskApprovalPolicy", async () => {
return pullDigitalEmployeeRiskApprovalPolicy({ force: true });
});
ipcMain.handle("digitalEmployee:evaluateRiskPolicy", async (_, input: unknown) => {
return evaluateDigitalEmployeeRiskPolicy(typeof input === "object" && input ? input as Parameters<typeof evaluateDigitalEmployeeRiskPolicy>[0] : {});
});
ipcMain.handle("digitalEmployee:getCronSettings", async () => {
return readDigitalEmployeeCronSettings();
});

View File

@@ -1006,6 +1006,99 @@ describe("digital employee state service", () => {
});
});
it("normalizes risk approval policy defaults and camel-case remote fields", async () => {
const { normalizeRiskApprovalPolicy } = await import("./stateService");
expect(normalizeRiskApprovalPolicy(null)).toMatchObject({
enabled: true,
approval_required_risk_levels: ["high", "critical"],
approval_required_actions: [],
auto_reject_actions: [],
source: "local",
});
expect(normalizeRiskApprovalPolicy({
approvalRequiredRiskLevels: ["Critical"],
approvalRequiredActions: ["governance.run_schedule_now"],
autoRejectActions: ["artifact.access.open_location"],
source: "remote",
remoteUpdatedAt: "2026-06-07T08:10:00.000Z",
lastPulledAt: "2026-06-07T08:11:00.000Z",
lastPullError: "previous error",
})).toMatchObject({
approval_required_risk_levels: ["critical"],
approval_required_actions: ["governance.run_schedule_now"],
auto_reject_actions: ["artifact.access.open_location"],
source: "remote",
remote_updated_at: "2026-06-07T08:10:00.000Z",
last_pulled_at: "2026-06-07T08:11:00.000Z",
last_pull_error: "previous error",
});
});
it("creates approval and audit event when risk policy requires approval", async () => {
const { evaluateDigitalEmployeeRiskPolicy } = await import("./stateService");
const result = evaluateDigitalEmployeeRiskPolicy({
actionKind: "governance.run_schedule_now",
actionLabel: "立即运行调度",
riskLevel: "high",
correlationId: "risk-command-1",
payload: { prompt: "x".repeat(500), nested: { secret: "hidden" } },
});
expect(result).toMatchObject({
ok: false,
decision: "approval_required",
action_kind: "governance.run_schedule_now",
risk_level: "high",
reason_codes: ["risk_level_requires_approval"],
approval_id: expect.stringContaining("risk-approval:governance-run_schedule_now:risk-command-1"),
});
expect(mockState.db?.approvals.get(result.approval_id!)).toMatchObject({
title: "立即运行调度",
status: "pending",
});
expect(JSON.parse(mockState.db?.approvals.get(result.approval_id!)?.payload ?? "{}")).toMatchObject({
approval_kind: "risk_policy",
action_kind: "governance.run_schedule_now",
risk_level: "high",
action_payload: {
nested: { type: "object" },
},
});
expect(mockState.db?.events.get(result.event_id!)).toMatchObject({ kind: "risk_approval_required" });
expect(mockState.db?.outbox.get(`approval:upsert:${result.approval_id}`)).toMatchObject({ entity_type: "approval" });
expect(mockState.db?.outbox.get(`event:insert:${result.event_id}`)).toMatchObject({ entity_type: "event" });
});
it("records rejection when risk policy auto rejects an action", async () => {
mockState.settings.set("digital_employee_ui_state_v1", {
riskApprovalPolicy: {
enabled: true,
approval_required_risk_levels: ["high"],
approval_required_actions: [],
auto_reject_actions: ["artifact.access.open_location"],
source: "remote",
},
});
const { evaluateDigitalEmployeeRiskPolicy } = await import("./stateService");
const result = evaluateDigitalEmployeeRiskPolicy({
actionKind: "artifact.access.open_location",
riskLevel: "critical",
correlationId: "artifact-risk-1",
});
expect(result).toMatchObject({
ok: false,
decision: "rejected",
reason_codes: ["risk_action_auto_rejected"],
});
expect(result.approval_id).toBeNull();
expect(mockState.db?.events.get(result.event_id!)).toMatchObject({ kind: "risk_policy_rejected" });
expect(Array.from(mockState.db?.approvals.values() ?? [])).toHaveLength(0);
});
it("records governance commands as formal runtime records", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");

View File

@@ -387,6 +387,47 @@ export interface DigitalEmployeePlanStepDispatchPolicy {
last_pull_error?: string | null;
}
export interface DigitalEmployeeRiskApprovalPolicy {
enabled: boolean;
approval_required_risk_levels: string[];
approval_required_actions: string[];
auto_reject_actions: 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 DigitalEmployeeRiskPolicyDecision = "allowed" | "approval_required" | "rejected";
export interface DigitalEmployeeRiskPolicyInput {
actionKind?: string | null;
actionLabel?: string | null;
riskLevel?: string | null;
reasonCodes?: string[] | null;
actor?: string | null;
source?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
correlationId?: string | null;
payload?: Record<string, unknown> | null;
occurredAt?: string | null;
}
export interface DigitalEmployeeRiskPolicyResult {
ok: boolean;
decision: DigitalEmployeeRiskPolicyDecision;
action_kind: string;
risk_level: string;
reason_codes: string[];
policy: DigitalEmployeeRiskApprovalPolicy;
approval_id?: string | null;
event_id?: string | null;
status: "allowed" | "approval_required" | "rejected";
}
export interface DigitalEmployeeRouteDecisionRecord {
id: string;
route_kind: string;
@@ -545,6 +586,7 @@ export interface DigitalEmployeeUiState {
updated_at: string | null;
};
planStepDispatchPolicy?: DigitalEmployeePlanStepDispatchPolicy;
riskApprovalPolicy?: DigitalEmployeeRiskApprovalPolicy;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -723,6 +765,7 @@ function defaultUiState(): DigitalEmployeeUiState {
notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
planStepDispatchPolicy: defaultPlanStepDispatchPolicy(),
riskApprovalPolicy: defaultRiskApprovalPolicy(),
consoleRole: "sales",
rolePreferences: {},
profile: {
@@ -808,6 +851,20 @@ function defaultPlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy
};
}
function defaultRiskApprovalPolicy(): DigitalEmployeeRiskApprovalPolicy {
return {
enabled: true,
approval_required_risk_levels: ["high", "critical"],
approval_required_actions: [],
auto_reject_actions: [],
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())))];
@@ -871,6 +928,27 @@ export function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployee
};
}
export function normalizeRiskApprovalPolicy(value: unknown): DigitalEmployeeRiskApprovalPolicy {
const fallback = defaultRiskApprovalPolicy();
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,
approval_required_risk_levels: normalizeStringArray(record.approval_required_risk_levels ?? record.approvalRequiredRiskLevels).length > 0
? normalizeStringArray(record.approval_required_risk_levels ?? record.approvalRequiredRiskLevels).map((value) => value.toLowerCase())
: fallback.approval_required_risk_levels,
approval_required_actions: normalizeStringArray(record.approval_required_actions ?? record.approvalRequiredActions),
auto_reject_actions: normalizeStringArray(record.auto_reject_actions ?? record.autoRejectActions),
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 {
@@ -888,6 +966,7 @@ function normalizeUiState(
notificationPreferences: normalizeNotificationPreferences(value.notificationPreferences),
approvalSubscriptionPreferences: normalizeApprovalSubscriptionPreferences(value.approvalSubscriptionPreferences),
planStepDispatchPolicy: normalizePlanStepDispatchPolicy(value.planStepDispatchPolicy),
riskApprovalPolicy: normalizeRiskApprovalPolicy(value.riskApprovalPolicy),
consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim()
? value.consoleRole
: fallback.consoleRole,
@@ -934,6 +1013,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
return "数字员工PlanStep派发策略已更新";
case "pull_plan_step_dispatch_policy":
return "数字员工PlanStep派发策略已拉取";
case "pull_risk_approval_policy":
return "数字员工风险审批策略已拉取";
default:
return `数字员工页面状态已更新${suffix}`;
}
@@ -967,6 +1048,10 @@ export function readDigitalEmployeePlanStepDispatchPolicy(): DigitalEmployeePlan
return readDigitalEmployeeUiState().planStepDispatchPolicy ?? defaultPlanStepDispatchPolicy();
}
export function readDigitalEmployeeRiskApprovalPolicy(): DigitalEmployeeRiskApprovalPolicy {
return readDigitalEmployeeUiState().riskApprovalPolicy ?? defaultRiskApprovalPolicy();
}
export function saveDigitalEmployeeUiState(
update: DigitalEmployeeUiStateUpdate,
): DigitalEmployeeUiState {
@@ -1274,6 +1359,183 @@ export function listDigitalEmployeeRouteDecisions(options: { limit?: number } =
.filter((decision): decision is DigitalEmployeeRouteDecisionRecord => Boolean(decision));
}
export function evaluateDigitalEmployeeRiskPolicy(
input: DigitalEmployeeRiskPolicyInput,
): DigitalEmployeeRiskPolicyResult {
const policy = readDigitalEmployeeRiskApprovalPolicy();
const actionKind = normalizeRiskActionKind(input.actionKind);
const riskLevel = normalizeRiskLevel(input.riskLevel);
const baseReasonCodes = normalizeSkillIds(input.reasonCodes ?? []);
if (!policy.enabled) {
return {
ok: true,
decision: "allowed",
action_kind: actionKind,
risk_level: riskLevel,
reason_codes: [...baseReasonCodes, "risk_policy_disabled"],
policy,
status: "allowed",
};
}
const autoRejectActions = new Set(policy.auto_reject_actions.map(normalizeRiskActionKind));
const approvalRequiredActions = new Set(policy.approval_required_actions.map(normalizeRiskActionKind));
if (autoRejectActions.has(actionKind)) {
return recordDigitalEmployeeRiskPolicyDecision(input, policy, "rejected", [...baseReasonCodes, "risk_action_auto_rejected"]);
}
if (approvalRequiredActions.has(actionKind)) {
return recordDigitalEmployeeRiskPolicyDecision(input, policy, "approval_required", [...baseReasonCodes, "risk_action_requires_approval"]);
}
if (policy.approval_required_risk_levels.map(normalizeRiskLevel).includes(riskLevel)) {
return recordDigitalEmployeeRiskPolicyDecision(input, policy, "approval_required", [...baseReasonCodes, "risk_level_requires_approval"]);
}
return {
ok: true,
decision: "allowed",
action_kind: actionKind,
risk_level: riskLevel,
reason_codes: baseReasonCodes.length > 0 ? baseReasonCodes : ["risk_policy_allowed"],
policy,
status: "allowed",
};
}
function recordDigitalEmployeeRiskPolicyDecision(
input: DigitalEmployeeRiskPolicyInput,
policy: DigitalEmployeeRiskApprovalPolicy,
decision: Exclude<DigitalEmployeeRiskPolicyDecision, "allowed">,
reasonCodes: string[],
): DigitalEmployeeRiskPolicyResult {
const db = getDigitalEmployeeDb();
const now = input.occurredAt || new Date().toISOString();
const actionKind = normalizeRiskActionKind(input.actionKind);
const riskLevel = normalizeRiskLevel(input.riskLevel);
if (!db) {
return {
ok: false,
decision: "rejected",
action_kind: actionKind,
risk_level: riskLevel,
reason_codes: [...reasonCodes, "database_unavailable"],
policy,
status: "rejected",
};
}
const source = stringValue(input.source) || "qimingclaw-risk-policy";
const correlationId = stringValue(input.correlationId) || `${actionKind}:${now}`;
const eventId = `risk-policy:${safeId(decision)}:${safeId(actionKind)}:${safeId(correlationId)}:${safeId(now)}`;
let approvalId: string | null = null;
if (decision === "approval_required") {
approvalId = `risk-approval:${safeId(actionKind)}:${safeId(correlationId)}:${safeId(now)}`;
upsertApproval({
id: approvalId,
planId: stringValue(input.planId),
taskId: stringValue(input.taskId),
runId: stringValue(input.runId),
title: stringValue(input.actionLabel) || `高风险动作审批:${actionKind}`,
status: "pending",
payload: {
source,
approval_kind: "risk_policy",
action_kind: actionKind,
action_label: stringValue(input.actionLabel) || null,
risk_level: riskLevel,
reason_codes: reasonCodes,
policy_snapshot: riskPolicyAuditSnapshot(policy),
correlation_id: correlationId,
action_payload: sanitizeRiskPolicyPayload(input.payload),
},
now,
});
}
const payload: Record<string, unknown> = {
source,
action_kind: actionKind,
action_label: stringValue(input.actionLabel) || null,
risk_level: riskLevel,
decision,
status: decision,
reason_codes: reasonCodes,
policy_source: policy.source || "local",
policy_snapshot: riskPolicyAuditSnapshot(policy),
approval_id: approvalId,
correlation_id: correlationId,
action_payload: sanitizeRiskPolicyPayload(input.payload),
actor: stringValue(input.actor) || null,
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
run_id: stringValue(input.runId) || null,
};
insertEvent(
{
event_id: eventId,
kind: decision === "approval_required" ? "risk_approval_required" : "risk_policy_rejected",
occurred_at: now,
message: riskPolicyDecisionMessage(decision, actionKind),
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
},
stringValue(input.runId) || null,
payload,
);
return {
ok: false,
decision,
action_kind: actionKind,
risk_level: riskLevel,
reason_codes: reasonCodes,
policy,
approval_id: approvalId,
event_id: eventId,
status: decision,
};
}
function normalizeRiskActionKind(value: unknown): string {
return stringValue(value).trim().toLowerCase().replace(/[^a-z0-9_.:-]+/g, "_") || "unknown_action";
}
function normalizeRiskLevel(value: unknown): string {
const riskLevel = stringValue(value).trim().toLowerCase();
return riskLevel || "normal";
}
function riskPolicyAuditSnapshot(policy: DigitalEmployeeRiskApprovalPolicy): Record<string, unknown> {
return {
enabled: policy.enabled,
approval_required_risk_levels: policy.approval_required_risk_levels,
approval_required_actions: policy.approval_required_actions,
auto_reject_actions: policy.auto_reject_actions,
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 sanitizeRiskPolicyPayload(payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
const record = objectRecord(payload) ?? {};
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record).slice(0, 30)) {
if (typeof value === "string") {
result[key] = value.length > 200 ? `${value.slice(0, 200)}...` : value;
} else if (typeof value === "number" || typeof value === "boolean" || value === null) {
result[key] = value;
} else if (Array.isArray(value)) {
result[key] = { type: "array", length: value.length };
} else if (typeof value === "object") {
result[key] = { type: "object" };
}
}
return result;
}
function riskPolicyDecisionMessage(decision: Exclude<DigitalEmployeeRiskPolicyDecision, "allowed">, actionKind: string): string {
return decision === "approval_required"
? `高风险动作需要审批:${actionKind}`
: `高风险动作已被策略拒绝:${actionKind}`;
}
export function recordDigitalEmployeeSkillCallAudit(
input: DigitalEmployeeSkillCallAuditInput,
): DigitalEmployeeSkillCallAuditRecord | null {

View File

@@ -215,6 +215,88 @@ describe("digital employee sync service", () => {
});
});
it("pulls remote risk approval policy and records UI state", async () => {
mockState.fetch.mockResolvedValue(jsonResponse({
success: true,
data: {
risk_approval_policy: {
enabled: true,
approval_required_risk_levels: ["critical"],
approval_required_actions: ["governance.run_schedule_now"],
auto_reject_actions: ["artifact.access.open_location"],
updated_at: "2026-06-07T08:02:00.000Z",
},
},
}));
const { pullDigitalEmployeeRiskApprovalPolicy } = await import("./syncService");
const { readDigitalEmployeeUiState } = await import("./stateService");
const result = await pullDigitalEmployeeRiskApprovalPolicy({ force: true });
expect(result).toMatchObject({
ok: true,
endpoint: "https://manage.example.com/api/digital-employee/policies/risk-approval",
policy: {
source: "remote",
approval_required_risk_levels: ["critical"],
approval_required_actions: ["governance.run_schedule_now"],
auto_reject_actions: ["artifact.access.open_location"],
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/risk-approval",
expect.objectContaining({ method: "GET" }),
);
expect(readDigitalEmployeeUiState().riskApprovalPolicy).toMatchObject(result.policy);
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "digital_workday_pull_risk_approval_policy" }),
]));
});
it("keeps current risk approval policy when pull fails", async () => {
mockState.fetch.mockResolvedValue({
ok: false,
status: 503,
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "risk policy unavailable" })),
} as unknown as Response);
const { pullDigitalEmployeeRiskApprovalPolicy } = await import("./syncService");
const result = await pullDigitalEmployeeRiskApprovalPolicy({ force: true });
expect(result).toMatchObject({
ok: false,
error: "risk policy unavailable",
policy: {
source: "local",
approval_required_risk_levels: ["high", "critical"],
last_pulled_at: "2026-06-07T08:00:00.000Z",
last_pull_error: "risk policy unavailable",
},
});
});
it.each([
["riskApprovalPolicy"],
["policy"],
])("pulls remote risk approval policy from data.%s", async (fieldName) => {
mockState.fetch.mockResolvedValue(jsonResponse({
success: true,
data: {
[fieldName]: {
approvalRequiredActions: ["managed_service.restart.fileServer"],
},
},
}));
const { pullDigitalEmployeeRiskApprovalPolicy } = await import("./syncService");
const result = await pullDigitalEmployeeRiskApprovalPolicy({ force: true });
expect(result.policy.approval_required_actions).toEqual(["managed_service.restart.fileServer"]);
});
it.each([
["skillPolicies"],
["policies"],
@@ -1017,6 +1099,21 @@ describe("digital employee sync service", () => {
},
},
});
insertEventEntity("event-risk-approval", "risk_approval_required", {
action_kind: "managed_service.restart.crm-sync",
action_label: "重启 CRM 同步服务",
risk_level: "high",
decision: "approval_required",
status: "pending",
reason_codes: ["risk_level_requires_approval"],
policy_source: "remote",
approval_id: "risk-approval-1",
correlation_id: "restart-1",
actor: "operator",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
@@ -1037,6 +1134,7 @@ describe("digital employee sync service", () => {
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
{ entity_type: "event", entity_id: "event-plan-step-policy" },
{ entity_type: "event", entity_id: "event-plan-step-policy-pull" },
{ entity_type: "event", entity_id: "event-risk-approval" },
],
}),
);
@@ -1219,6 +1317,22 @@ describe("digital employee sync service", () => {
remote_updated_at: "2026-06-07T08:08:00.000Z",
last_pulled_at: "2026-06-07T08:09:00.000Z",
});
expect(businessView("event-risk-approval")).toMatchObject({
category: "approval",
action_kind: "managed_service.restart.crm-sync",
action_label: "重启 CRM 同步服务",
risk_level: "high",
decision: "approval_required",
status: "pending",
reason_codes: ["risk_level_requires_approval"],
policy_source: "remote",
approval_id: "risk-approval-1",
correlation_id: "restart-1",
actor: "operator",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
expect(readEntity("event", "event-route")).toMatchObject({
sync_status: "synced",
sync_error: null,

View File

@@ -4,9 +4,11 @@ import { getDomainTokenKey } from "@shared/utils/domain";
import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
import {
normalizePlanStepDispatchPolicy,
normalizeRiskApprovalPolicy,
readDigitalEmployeeUiState,
saveDigitalEmployeeUiState,
type DigitalEmployeePlanStepDispatchPolicy,
type DigitalEmployeeRiskApprovalPolicy,
} from "./stateService";
import {
SKILL_POLICIES_SETTING_KEY,
@@ -18,6 +20,7 @@ import {
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_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync";
@@ -38,6 +41,8 @@ let policyPulling = false;
let lastPolicyPullAttemptMs = 0;
let skillPolicyPulling = false;
let lastSkillPolicyPullAttemptMs = 0;
let riskApprovalPolicyPulling = false;
let lastRiskApprovalPolicyPullAttemptMs = 0;
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
if (!ensureDigitalEmployeeSchema()) return null;
@@ -49,6 +54,7 @@ interface DigitalEmployeeSyncConfig {
endpoint: string | null;
policyEndpoint: string | null;
skillPolicyEndpoint: string | null;
riskApprovalPolicyEndpoint: string | null;
leaseEndpoint: string | null;
clientKey: string | null;
authToken: string | null;
@@ -73,6 +79,15 @@ export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
error?: string | null;
}
export interface DigitalEmployeeRiskApprovalPolicyPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
policy: DigitalEmployeeRiskApprovalPolicy;
error?: string | null;
}
export interface DigitalEmployeePlanStepRemoteLease {
lease_id: string;
state: "active" | "released" | "expired";
@@ -368,6 +383,63 @@ export async function pullDigitalEmployeeSkillPolicies(
}
}
export async function pullDigitalEmployeeRiskApprovalPolicy(
options: { force?: boolean } = {},
): Promise<DigitalEmployeeRiskApprovalPolicyPullResult> {
const currentState = readDigitalEmployeeUiState();
const currentPolicy = currentState.riskApprovalPolicy ?? normalizeRiskApprovalPolicy(null);
if (riskApprovalPolicyPulling) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().riskApprovalPolicyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
}
const nowMs = Date.now();
if (!options.force && nowMs - lastRiskApprovalPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().riskApprovalPolicyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
}
lastRiskApprovalPolicyPullAttemptMs = nowMs;
const config = resolveSyncConfig();
const missingCredentials = missingSyncCredentialLabels(config);
if (!config.riskApprovalPolicyEndpoint || missingCredentials.length > 0) {
const error = !config.riskApprovalPolicyEndpoint ? "management risk approval policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
return recordRiskApprovalPolicyPullFailure(currentState, currentPolicy, config.riskApprovalPolicyEndpoint, error);
}
riskApprovalPolicyPulling = true;
try {
const response = await fetchJsonWithAuth(config, config.riskApprovalPolicyEndpoint);
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
const remotePolicy = readRemoteRiskApprovalPolicy(response);
if (!remotePolicy) throw new Error("management policy response missing risk approval policy");
const pulledAt = new Date().toISOString();
const policy = normalizeRiskApprovalPolicy({
...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_risk_approval_policy",
metadata: {
source: "management-api",
endpoint: config.riskApprovalPolicyEndpoint,
ok: true,
risk_approval_policy: policy,
},
state: {
...currentState,
riskApprovalPolicy: policy,
},
});
return { ok: true, endpoint: config.riskApprovalPolicyEndpoint, pulled_at: pulledAt, policy, error: null };
} catch (error) {
return recordRiskApprovalPolicyPullFailure(currentState, currentPolicy, config.riskApprovalPolicyEndpoint, normalizeError(error));
} finally {
riskApprovalPolicyPulling = false;
}
}
export async function acquireDigitalEmployeePlanStepRemoteLease(
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
@@ -577,6 +649,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.skillPolicyEndpoint.trim()
: null;
const skillPolicyEndpoint = skillPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_SKILL_POLICY_PATH);
const riskApprovalPolicyEndpointOverride =
typeof config.riskApprovalPolicyEndpoint === "string" && config.riskApprovalPolicyEndpoint.trim()
? config.riskApprovalPolicyEndpoint.trim()
: null;
const riskApprovalPolicyEndpoint = riskApprovalPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_RISK_APPROVAL_POLICY_PATH);
const leaseEndpointOverride =
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
? config.planStepDispatchLeaseEndpoint.trim()
@@ -593,6 +670,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
endpoint,
policyEndpoint,
skillPolicyEndpoint,
riskApprovalPolicyEndpoint,
leaseEndpoint,
clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost),
@@ -746,6 +824,14 @@ function readRemoteSkillPolicyRecords(payload: Record<string, unknown>): Record<
?? (looksLikeSkillPolicyMap(payload) ? payload : null);
}
function readRemoteRiskApprovalPolicy(response: Record<string, unknown>): Record<string, unknown> | null {
const data = objectRecord(response.data) ?? response;
return objectRecord(data.risk_approval_policy)
?? objectRecord(data.riskApprovalPolicy)
?? objectRecord(data.policy)
?? (hasRiskApprovalPolicyFields(data) ? data : null);
}
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
@@ -813,6 +899,15 @@ function hasSkillPolicyWrapperFields(record: Record<string, unknown>): boolean {
|| "policies" in record;
}
function hasRiskApprovalPolicyFields(record: Record<string, unknown>): boolean {
return "approval_required_risk_levels" in record
|| "approvalRequiredRiskLevels" in record
|| "approval_required_actions" in record
|| "approvalRequiredActions" in record
|| "auto_reject_actions" in record
|| "autoRejectActions" in record;
}
function looksLikeSkillPolicyMap(record: Record<string, unknown>): boolean {
return Object.values(record).some((value) => {
if (typeof value === "boolean") return true;
@@ -865,6 +960,35 @@ function recordSkillPolicyPullFailure(
return { ok: false, endpoint, pulled_at: pulledAt, policies, error };
}
function recordRiskApprovalPolicyPullFailure(
state: ReturnType<typeof readDigitalEmployeeUiState>,
currentPolicy: DigitalEmployeeRiskApprovalPolicy,
endpoint: string | null,
error: string,
): DigitalEmployeeRiskApprovalPolicyPullResult {
const pulledAt = new Date().toISOString();
const policy = normalizeRiskApprovalPolicy({
...currentPolicy,
last_pulled_at: pulledAt,
last_pull_error: error,
});
saveDigitalEmployeeUiState({
action: "pull_risk_approval_policy",
metadata: {
source: "management-api",
endpoint,
ok: false,
error,
risk_approval_policy: policy,
},
state: {
...state,
riskApprovalPolicy: policy,
},
});
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
}
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
const payload = parsePayload(row.payload);
const summary = readEntitySummary(row.entity_type, row.entity_id);
@@ -1058,6 +1182,7 @@ function eventSpecificBusinessView(
return artifactEventBusinessView(payload);
}
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
if (kind === "risk_approval_required" || kind === "risk_policy_rejected") return riskPolicyBusinessView(payload);
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
if (isPlanStepLeaseEvent(kind)) return planStepLeaseBusinessView(payload);
@@ -1132,6 +1257,24 @@ function approvalSubscriptionBusinessView(payload: Record<string, unknown>): Rec
};
}
function riskPolicyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
action_kind: stringField(payload.action_kind ?? payload.actionKind) || null,
action_label: stringField(payload.action_label ?? payload.actionLabel) || null,
risk_level: stringField(payload.risk_level ?? payload.riskLevel) || null,
decision: stringField(payload.decision) || null,
status: stringField(payload.status) || null,
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
policy_source: stringField(payload.policy_source ?? payload.policySource) || null,
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
correlation_id: stringField(payload.correlation_id ?? payload.correlationId) || null,
actor: stringField(payload.actor) || null,
plan_id: stringField(payload.plan_id ?? payload.planId) || null,
task_id: stringField(payload.task_id ?? payload.taskId) || null,
run_id: stringField(payload.run_id ?? payload.runId) || null,
};
}
function artifactEventBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const retention = objectRecord(payload.retention_summary);
return {
@@ -1266,6 +1409,7 @@ function notificationBusinessView(payload: Record<string, unknown>): Record<stri
function eventBusinessCategory(kind: string): string {
if (kind === "route_decision") return "route";
if (kind.startsWith("approval_")) return "approval";
if (kind.startsWith("risk_")) return "approval";
if (isApprovalSubscriptionEvent(kind)) return "approval";
if (kind.startsWith("artifact_")) return "artifact";
if (kind.startsWith("skill_call_")) return "skill";

View File

@@ -134,6 +134,12 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async pullSkillPolicies() {
return ipcRenderer.invoke("digitalEmployee:pullSkillPolicies");
},
async pullRiskApprovalPolicy() {
return ipcRenderer.invoke("digitalEmployee:pullRiskApprovalPolicy");
},
async evaluateRiskPolicy(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:evaluateRiskPolicy", input);
},
async getCronSettings() {
return ipcRenderer.invoke("digitalEmployee:getCronSettings");
},