吸收数字员工风险策略远端下发
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user