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

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

@@ -625,6 +625,12 @@ export function pullSkillPolicies(): Promise<{ ok: boolean; skipped?: boolean; e
}); });
} }
export function pullRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?: boolean; error?: string | null }> {
return apiFetch<{ ok: boolean; skipped?: boolean; error?: string | null }>('/api/risk-approval/policy/pull', {
method: 'POST',
});
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> { export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams(); const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {

View File

@@ -55,6 +55,8 @@ declare global {
getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>; getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>;
setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>; setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>;
pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>; pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>; getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>; listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>; addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>;
@@ -353,6 +355,32 @@ interface QimingclawRouteDecisionInput {
recordedAt?: string | null; recordedAt?: string | null;
} }
interface QimingclawRiskPolicyInput {
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;
}
interface QimingclawRiskPolicyResult {
ok: boolean;
decision: 'allowed' | 'approval_required' | 'rejected';
action_kind: string;
risk_level: string;
reason_codes?: string[];
approval_id?: string | null;
event_id?: string | null;
status?: string;
error?: string | null;
}
interface QimingclawRuntimeRecords { interface QimingclawRuntimeRecords {
generatedAt: string; generatedAt: string;
plans: QimingclawPlanRecord[]; plans: QimingclawPlanRecord[];
@@ -979,6 +1007,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname === '/api/skill/policies/pull') { if (method === 'POST' && pathname === '/api/skill/policies/pull') {
return await pullBridgeSkillPolicies() as T; return await pullBridgeSkillPolicies() as T;
} }
if (method === 'POST' && pathname === '/api/risk-approval/policy/pull') {
return await pullBridgeRiskApprovalPolicy() as T;
}
if (method === 'POST' && pathname.startsWith('/api/skill/') && pathname.endsWith('/enabled')) { if (method === 'POST' && pathname.startsWith('/api/skill/') && pathname.endsWith('/enabled')) {
const skillId = decodeURIComponent(pathname.split('/')[3] || ''); const skillId = decodeURIComponent(pathname.split('/')[3] || '');
const body = parseJsonBody<{ enabled?: boolean }>(options.body); const body = parseJsonBody<{ enabled?: boolean }>(options.body);
@@ -1114,6 +1145,65 @@ async function pullBridgeSkillPolicies(): Promise<{ ok: boolean; skipped?: boole
} }
} }
async function pullBridgeRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?: boolean; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullRiskApprovalPolicy) {
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.pullRiskApprovalPolicy();
return {
ok: result?.ok !== false,
...(result?.skipped ? { skipped: true } : {}),
...(result?.error ? { error: result.error } : {}),
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
async function evaluateBridgeRiskPolicy(input: QimingclawRiskPolicyInput): Promise<QimingclawRiskPolicyResult> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.evaluateRiskPolicy) {
return {
ok: true,
decision: 'allowed',
action_kind: input.actionKind || 'unknown_action',
risk_level: input.riskLevel || 'normal',
reason_codes: ['qimingclaw_bridge_unavailable'],
status: 'allowed',
};
}
try {
return await bridge.evaluateRiskPolicy(input);
} catch (error) {
return {
ok: true,
decision: 'allowed',
action_kind: input.actionKind || 'unknown_action',
risk_level: input.riskLevel || 'normal',
reason_codes: ['risk_policy_evaluation_failed'],
status: 'allowed',
error: error instanceof Error ? error.message : String(error),
};
}
}
function riskPolicyBlockedResponse(result: QimingclawRiskPolicyResult): Record<string, unknown> | null {
if (result.decision === 'allowed') return null;
return {
ok: false,
status: result.decision,
decision: result.decision,
action_kind: result.action_kind,
risk_level: result.risk_level,
reason_codes: result.reason_codes ?? [],
approval_id: result.approval_id ?? null,
event_id: result.event_id ?? null,
error: result.decision === 'approval_required' ? 'risk_approval_required' : 'risk_policy_rejected',
};
}
function dedupeSkills(skills: SkillSpec[]): SkillSpec[] { function dedupeSkills(skills: SkillSpec[]): SkillSpec[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: SkillSpec[] = []; const result: SkillSpec[] = [];
@@ -1511,6 +1601,29 @@ async function handleManagedServiceRestart(
serviceId: string, serviceId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<QimingclawManagedServiceActionResponse> { ): Promise<QimingclawManagedServiceActionResponse> {
const riskPolicy = await evaluateBridgeRiskPolicy({
actionKind: `managed_service.restart.${serviceId}`,
actionLabel: `重启服务:${serviceId}`,
riskLevel: 'high',
reasonCodes: ['managed_service_restart'],
actor: stringValue(body.actor) || 'digital_employee_ui',
source: 'sgrobot-digital-adapter',
correlationId: `managed-service:${serviceId}:restart`,
payload: { service_id: serviceId, reason: stringValue(body.reason) || null },
});
const blocked = riskPolicyBlockedResponse(riskPolicy);
if (blocked) {
const snapshot = await readQimingclawSnapshot();
return {
ok: false,
service_id: serviceId,
action: 'restart',
status: blocked.status === 'approval_required' ? 'rejected' : 'failed',
error: String(blocked.error || blocked.status || 'risk_policy_blocked'),
managed_service: buildManagedServices(snapshot).find((service) => service.service_id === serviceId) ?? null,
event_id: stringValue(blocked.event_id) || null,
};
}
const bridge = window.QimingClawBridge?.digital; const bridge = window.QimingClawBridge?.digital;
if (!bridge?.restartManagedService) { if (!bridge?.restartManagedService) {
const snapshot = await readQimingclawSnapshot(); const snapshot = await readQimingclawSnapshot();
@@ -1552,6 +1665,28 @@ async function handleArtifactAccess(
): Promise<QimingclawArtifactAccessResponse> { ): Promise<QimingclawArtifactAccessResponse> {
const bridge = window.QimingClawBridge?.digital; const bridge = window.QimingClawBridge?.digital;
const action = normalizeArtifactAccessAction(stringValue(body.action)); const action = normalizeArtifactAccessAction(stringValue(body.action));
const riskPolicy = await evaluateBridgeRiskPolicy({
actionKind: `artifact.access.${action}`,
actionLabel: `访问产物:${action}`,
riskLevel: action === 'download' || action === 'open_location' ? 'high' : 'medium',
reasonCodes: ['artifact_access'],
actor: stringValue(body.actor) || 'digital_employee_ui',
source: 'sgrobot-digital-adapter',
correlationId: `artifact:${artifactId}:${action}`,
payload: { artifact_id: artifactId, action },
});
const blocked = riskPolicyBlockedResponse(riskPolicy);
if (blocked) {
return {
ok: false,
artifact_id: artifactId,
action,
status: 'rejected',
error: String(blocked.error || blocked.status || 'risk_policy_blocked'),
reason_codes: Array.isArray(blocked.reason_codes) ? blocked.reason_codes.map(String) : ['risk_policy_blocked'],
event_id: stringValue(blocked.event_id) || null,
};
}
if (!bridge?.accessArtifact) { if (!bridge?.accessArtifact) {
return { return {
ok: false, ok: false,
@@ -1592,6 +1727,21 @@ async function handleArtifactRetention(
const bridge = window.QimingClawBridge?.digital; const bridge = window.QimingClawBridge?.digital;
const action = stringValue(body.action) || 'mark_review'; const action = stringValue(body.action) || 'mark_review';
const artifact = artifactDetailRow(snapshot, artifactId); const artifact = artifactDetailRow(snapshot, artifactId);
const riskPolicy = await evaluateBridgeRiskPolicy({
actionKind: `artifact.retention.${action}`,
actionLabel: `调整产物保留策略:${action}`,
riskLevel: action === 'mark_expire' || action === 'clear_retention' ? 'high' : 'medium',
reasonCodes: ['artifact_retention'],
actor: stringValue(body.actor) || 'digital_employee_ui',
source: 'sgrobot-digital-adapter',
planId: artifact ? stringValue(artifact.plan_id) || null : null,
taskId: artifact ? stringValue(artifact.task_id) || null : null,
runId: artifact ? stringValue(artifact.run_id) || null : null,
correlationId: `artifact:${artifactId}:retention:${action}`,
payload: { artifact_id: artifactId, action },
});
const blocked = riskPolicyBlockedResponse(riskPolicy);
if (blocked) return { ...blocked, artifact_id: artifactId, action, item: artifact };
if (!bridge?.recordArtifactLifecycle) { if (!bridge?.recordArtifactLifecycle) {
return { ok: false, artifact_id: artifactId, action, status: 'rejected', error: 'bridge_unavailable' }; return { ok: false, artifact_id: artifactId, action, status: 'rejected', error: 'bridge_unavailable' };
} }
@@ -1627,6 +1777,21 @@ async function handleApprovalAction(
const bridge = window.QimingClawBridge?.digital; const bridge = window.QimingClawBridge?.digital;
const action = stringValue(body.action) || 'comment'; const action = stringValue(body.action) || 'comment';
const approval = approvalDetailRow(snapshot, approvalId); const approval = approvalDetailRow(snapshot, approvalId);
const riskPolicy = await evaluateBridgeRiskPolicy({
actionKind: `approval.action.${action}`,
actionLabel: `审批治理动作:${action}`,
riskLevel: action === 'mark_risk' || action === 'delegate' || action === 'mark_timeout' ? 'medium' : 'normal',
reasonCodes: ['approval_action'],
actor: stringValue(body.actor) || 'digital_employee_ui',
source: 'sgrobot-digital-adapter',
planId: approval ? stringValue(approval.plan_id) || null : null,
taskId: approval ? stringValue(approval.task_id) || null : null,
runId: approval ? stringValue(approval.run_id) || null : null,
correlationId: `approval:${approvalId}:action:${action}`,
payload: { approval_id: approvalId, action },
});
const blocked = riskPolicyBlockedResponse(riskPolicy);
if (blocked) return { ...blocked, approval_id: approvalId, action, item: approval };
if (!bridge?.recordApprovalAction) { if (!bridge?.recordApprovalAction) {
return { ok: false, approval_id: approvalId, action, status: 'rejected', error: 'bridge_unavailable' }; return { ok: false, approval_id: approvalId, action, status: 'rejected', error: 'bridge_unavailable' };
} }
@@ -5794,6 +5959,12 @@ function compactRecord(record: Record<string, unknown>): Record<string, unknown>
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null && value !== '')); return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null && value !== ''));
} }
function governanceCommandRiskLevel(commandKind: string): string {
if (['activate_plan', 'run_schedule_now', 'start_run', 'cancel_run', 'cancel_task'].includes(commandKind)) return 'high';
if (['delete_schedule', 'pause_schedule', 'skip_task', 'retry_task', 'provide_task_input'].includes(commandKind)) return 'medium';
return 'normal';
}
async function handleGovernanceCommand( async function handleGovernanceCommand(
body: GovernanceCommandRequest, body: GovernanceCommandRequest,
snapshot: QimingclawSnapshot | null, snapshot: QimingclawSnapshot | null,
@@ -5815,6 +5986,39 @@ async function handleGovernanceCommand(
const scheduleId = stringFromUnknown(body.context_refs?.schedule_id) const scheduleId = stringFromUnknown(body.context_refs?.schedule_id)
|| stringFromUnknown(body.payload?.schedule_id); || stringFromUnknown(body.payload?.schedule_id);
const riskPolicy = await evaluateBridgeRiskPolicy({
actionKind: `governance.${body.command_kind}`,
actionLabel: `治理命令:${body.command_kind}`,
riskLevel: governanceCommandRiskLevel(body.command_kind),
reasonCodes: ['governance_command'],
actor: stringValue(body.payload?.actor) || 'digital_employee_ui',
source: 'sgrobot-digital-adapter',
planId: planId || null,
taskId: taskId || null,
runId: runId || null,
correlationId,
payload: {
command_kind: body.command_kind,
schedule_id: scheduleId || null,
step_id: stepId || null,
},
});
const blocked = riskPolicyBlockedResponse(riskPolicy);
if (blocked) {
const response: GovernanceCommandResponse = {
command_id: commandId,
accepted: false,
correlation_id: correlationId,
error: 'policy_blocked',
message: blocked.status === 'approval_required'
? '高风险治理命令需要审批后执行。'
: '高风险治理命令已被风险策略拒绝。',
result: blocked,
};
await recordGovernanceCommand(body, response);
return response;
}
if (body.command_kind === 'create_plan') { if (body.command_kind === 'create_plan') {
const createdPlanId = `qimingclaw-local-${slugifyIdPart(stringFromUnknown(body.payload?.title) || planId || 'plan')}-${Date.now()}`; const createdPlanId = `qimingclaw-local-${slugifyIdPart(stringFromUnknown(body.payload?.title) || planId || 'plan')}-${Date.now()}`;
const response = governanceAccepted(commandId, correlationId, '已在 qimingclaw 兼容层创建本地计划草稿。', { const response = governanceAccepted(commandId, correlationId, '已在 qimingclaw 兼容层创建本地计划草稿。', {
@@ -6234,6 +6438,34 @@ async function dispatchPlanReadySteps(
snapshot: QimingclawSnapshot | null = null, snapshot: QimingclawSnapshot | null = null,
): Promise<ReadyStepDispatchResult> { ): Promise<ReadyStepDispatchResult> {
const targetPlanId = stringValue(planId); const targetPlanId = stringValue(planId);
const riskPolicy = await evaluateBridgeRiskPolicy({
actionKind: 'plan_step.dispatch_ready_steps',
actionLabel: '派发 Ready PlanStep',
riskLevel: 'high',
reasonCodes: ['plan_step_dispatch'],
actor: 'digital_employee_ui',
source: 'sgrobot-digital-adapter',
planId: targetPlanId || null,
correlationId: `plan-step-dispatch:${targetPlanId || 'unknown'}`,
payload: { plan_id: targetPlanId || null, trigger_source: 'manual' },
});
const blocked = riskPolicyBlockedResponse(riskPolicy);
if (blocked) {
return {
ok: false,
plan_id: targetPlanId || null,
trigger_source: 'manual',
orchestration_id: stringValue(blocked.event_id) || `risk-blocked-${slugifyIdPart(targetPlanId || 'unknown')}`,
candidates: 0,
dispatched: 0,
skipped: 0,
failed: 1,
dispatched_step_ids: [],
skipped_step_ids: [],
failed_step_ids: [],
errors: [{ stepId: targetPlanId || 'unknown', error: String(blocked.error || blocked.status || 'risk_policy_blocked') }],
};
}
const bridgeResult = targetPlanId ? await window.QimingClawBridge?.digital?.dispatchPlanReadySteps?.(targetPlanId).catch(() => null) : null; const bridgeResult = targetPlanId ? await window.QimingClawBridge?.digital?.dispatchPlanReadySteps?.(targetPlanId).catch(() => null) : null;
if (bridgeResult) return bridgeResult; if (bridgeResult) return bridgeResult;
const now = new Date().toISOString(); const now = new Date().toISOString();

View File

@@ -12,6 +12,7 @@ import {
governanceCommand, governanceCommand,
postDigitalEmployeeWorkdayAction, postDigitalEmployeeWorkdayAction,
pullPlanStepDispatchPolicy, pullPlanStepDispatchPolicy,
pullRiskApprovalPolicy,
recordApprovalAction, recordApprovalAction,
restartManagedService, restartManagedService,
} from '@/lib/api'; } from '@/lib/api';
@@ -1391,6 +1392,27 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
} }
}, [fetchProjection]); }, [fetchProjection]);
const refreshRiskApprovalPolicy = useCallback(async () => {
setActionBusy('pull_risk_approval_policy');
setActionError(null);
setActionNotice(null);
try {
const result = await pullRiskApprovalPolicy();
if (!mountedRef.current) return;
await fetchProjection();
if (!mountedRef.current) return;
setActionNotice(result.ok
? '风险审批策略已拉取。'
: `风险审批策略拉取失败,已沿用本地策略:${result.error || '未知错误'}`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '拉取风险审批策略失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => { const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus); const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
try { try {
@@ -2049,6 +2071,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<ShieldCheck size={13} aria-hidden="true" /> <ShieldCheck size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_plan_step_dispatch_policy' ? '拉取中...' : '拉取策略'}</span> <span>{actionBusy === 'pull_plan_step_dispatch_policy' ? '拉取中...' : '拉取策略'}</span>
</button> </button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={projectionLoading || actionBusy === 'pull_risk_approval_policy'}
onClick={refreshRiskApprovalPolicy}
title="从管理端拉取风险审批策略"
>
<ShieldAlert size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_risk_approval_policy' ? '拉取中...' : '风险策略'}</span>
</button>
</div> </div>
<div className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}> <div className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error); console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
} }
</script> </script>
<script type="module" crossorigin src="./assets/index-1hmQ8OoX.js"></script> <script type="module" crossorigin src="./assets/index-COcOCjV1.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css"> <link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
</head> </head>
<body> <body>

View File

@@ -458,6 +458,60 @@ function checkDigitalSkillRemotePolicy() {
console.log("[DigitalEmployeeCheck] skill remote policy hooks OK"); console.log("[DigitalEmployeeCheck] skill remote policy hooks OK");
} }
function checkDigitalRiskApprovalPolicy() {
console.log("\n[DigitalEmployeeCheck] Verify risk approval policy hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const commandDeckPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const stateServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "stateService.ts");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const ipcHandlersPath = path.join(packageRoot, "src", "main", "ipc", "digitalEmployeeHandlers.ts");
const preloadPath = path.join(packageRoot, "src", "preload", "webviewPerfBridge.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const commandDeck = fs.readFileSync(commandDeckPath, "utf8");
const stateService = fs.readFileSync(stateServicePath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
const ipcHandlers = fs.readFileSync(ipcHandlersPath, "utf8");
const preload = fs.readFileSync(preloadPath, "utf8");
const requiredSnippets = [
[stateService, "riskApprovalPolicy", "risk approval policy UI state"],
[stateService, "normalizeRiskApprovalPolicy", "risk approval policy normalizer"],
[stateService, "evaluateDigitalEmployeeRiskPolicy", "risk approval policy evaluator"],
[stateService, "risk_approval_required", "risk approval-required event kind"],
[stateService, "risk_policy_rejected", "risk policy rejected event kind"],
[stateService, "pull_risk_approval_policy", "risk approval policy pull UI event"],
[syncService, "pullDigitalEmployeeRiskApprovalPolicy", "remote risk approval policy pull service"],
[syncService, "riskApprovalPolicyEndpoint", "remote risk approval policy endpoint config"],
[syncService, "/api/digital-employee/policies/risk-approval", "remote risk approval policy default path"],
[syncService, "risk_approval_required", "risk approval-required sync event kind"],
[syncService, "risk_policy_rejected", "risk policy rejected sync event kind"],
[syncService, "riskPolicyBusinessView", "risk policy sync business view"],
[ipcHandlers, "digitalEmployee:pullRiskApprovalPolicy", "IPC risk approval policy pull handler"],
[ipcHandlers, "digitalEmployee:evaluateRiskPolicy", "IPC risk policy evaluation handler"],
[preload, "pullRiskApprovalPolicy", "preload risk approval policy pull bridge"],
[preload, "evaluateRiskPolicy", "preload risk policy evaluation bridge"],
[adapter, "pullRiskApprovalPolicy", "adapter risk approval policy pull bridge"],
[adapter, "evaluateRiskPolicy", "adapter risk policy evaluation bridge"],
[adapter, "/api/risk-approval/policy/pull", "adapter risk approval policy pull endpoint"],
[adapter, "riskPolicyBlockedResponse", "adapter risk policy blocked response"],
[adapter, "governanceCommandRiskLevel", "adapter governance risk level mapper"],
[adapter, "managed_service.restart", "managed service restart risk action"],
[adapter, "artifact.access", "artifact access risk action"],
[adapter, "plan_step.dispatch_ready_steps", "ready PlanStep dispatch risk action"],
[api, "pullRiskApprovalPolicy", "embedded risk approval policy pull API"],
[commandDeck, "pull_risk_approval_policy", "home risk approval policy pull action"],
[commandDeck, "风险策略", "home risk approval policy pull button"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing risk approval policy hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] risk approval policy hooks OK");
}
function checkLocalDatabaseSchema() { function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema"); console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db"); const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -505,6 +559,7 @@ function main() {
checkDigitalApprovalHistorySubscriptions(); checkDigitalApprovalHistorySubscriptions();
checkDigitalPlanStepGraphDispatchPolicy(); checkDigitalPlanStepGraphDispatchPolicy();
checkDigitalSkillRemotePolicy(); checkDigitalSkillRemotePolicy();
checkDigitalRiskApprovalPolicy();
checkLocalDatabaseSchema(); checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed"); console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) { } catch (error) {

View File

@@ -75,6 +75,7 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
readDigitalEmployeeState: vi.fn(), readDigitalEmployeeState: vi.fn(),
readDigitalEmployeeUiState: vi.fn(), readDigitalEmployeeUiState: vi.fn(),
readDigitalEmployeeCronSettings: vi.fn(), readDigitalEmployeeCronSettings: vi.fn(),
evaluateDigitalEmployeeRiskPolicy: vi.fn(() => ({ ok: true, decision: "allowed", status: "allowed" })),
saveDigitalEmployeeCronSettings: vi.fn(), saveDigitalEmployeeCronSettings: vi.fn(),
readDigitalEmployeeScheduleRuns: vi.fn(), readDigitalEmployeeScheduleRuns: vi.fn(),
saveDigitalEmployeeUiState: vi.fn(), saveDigitalEmployeeUiState: vi.fn(),
@@ -96,6 +97,7 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
getDigitalEmployeeSyncStatus: vi.fn(), getDigitalEmployeeSyncStatus: vi.fn(),
pullDigitalEmployeePlanStepDispatchPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null } })), 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: {} } })), 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", () => ({ vi.mock("../services/digitalEmployee/planTemplateService", () => ({
@@ -268,6 +270,28 @@ describe("digital employee managed service actions", () => {
expect(syncService.pullDigitalEmployeeSkillPolicies).toHaveBeenCalledWith({ force: true }); 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 () => { it("registers approval action history through the digital employee IPC boundary", async () => {
const electron = await import("electron"); const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers"); const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");

View File

@@ -28,6 +28,7 @@ import {
readDigitalEmployeeState, readDigitalEmployeeState,
readDigitalEmployeeUiState, readDigitalEmployeeUiState,
readDigitalEmployeeCronSettings, readDigitalEmployeeCronSettings,
evaluateDigitalEmployeeRiskPolicy,
saveDigitalEmployeeCronSettings, saveDigitalEmployeeCronSettings,
readDigitalEmployeeScheduleRuns, readDigitalEmployeeScheduleRuns,
saveDigitalEmployeeUiState, saveDigitalEmployeeUiState,
@@ -59,6 +60,7 @@ import {
getDigitalEmployeeSyncStatus, getDigitalEmployeeSyncStatus,
pullDigitalEmployeePlanStepDispatchPolicy, pullDigitalEmployeePlanStepDispatchPolicy,
pullDigitalEmployeeSkillPolicies, pullDigitalEmployeeSkillPolicies,
pullDigitalEmployeeRiskApprovalPolicy,
} from "../services/digitalEmployee/syncService"; } from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService"; import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
import { import {
@@ -198,6 +200,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return pullDigitalEmployeeSkillPolicies({ force: true }); 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 () => { ipcMain.handle("digitalEmployee:getCronSettings", async () => {
return readDigitalEmployeeCronSettings(); 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 () => { it("records governance commands as formal runtime records", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService"); const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");

View File

@@ -387,6 +387,47 @@ export interface DigitalEmployeePlanStepDispatchPolicy {
last_pull_error?: string | null; 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 { export interface DigitalEmployeeRouteDecisionRecord {
id: string; id: string;
route_kind: string; route_kind: string;
@@ -545,6 +586,7 @@ export interface DigitalEmployeeUiState {
updated_at: string | null; updated_at: string | null;
}; };
planStepDispatchPolicy?: DigitalEmployeePlanStepDispatchPolicy; planStepDispatchPolicy?: DigitalEmployeePlanStepDispatchPolicy;
riskApprovalPolicy?: DigitalEmployeeRiskApprovalPolicy;
consoleRole?: string; consoleRole?: string;
rolePreferences?: Record<string, unknown>; rolePreferences?: Record<string, unknown>;
profile: { profile: {
@@ -723,6 +765,7 @@ function defaultUiState(): DigitalEmployeeUiState {
notificationPreferences: defaultNotificationPreferences(), notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(), approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
planStepDispatchPolicy: defaultPlanStepDispatchPolicy(), planStepDispatchPolicy: defaultPlanStepDispatchPolicy(),
riskApprovalPolicy: defaultRiskApprovalPolicy(),
consoleRole: "sales", consoleRole: "sales",
rolePreferences: {}, rolePreferences: {},
profile: { 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[] { function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())))]; 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( function normalizeUiState(
value: Partial<DigitalEmployeeUiState>, value: Partial<DigitalEmployeeUiState>,
): DigitalEmployeeUiState { ): DigitalEmployeeUiState {
@@ -888,6 +966,7 @@ function normalizeUiState(
notificationPreferences: normalizeNotificationPreferences(value.notificationPreferences), notificationPreferences: normalizeNotificationPreferences(value.notificationPreferences),
approvalSubscriptionPreferences: normalizeApprovalSubscriptionPreferences(value.approvalSubscriptionPreferences), approvalSubscriptionPreferences: normalizeApprovalSubscriptionPreferences(value.approvalSubscriptionPreferences),
planStepDispatchPolicy: normalizePlanStepDispatchPolicy(value.planStepDispatchPolicy), planStepDispatchPolicy: normalizePlanStepDispatchPolicy(value.planStepDispatchPolicy),
riskApprovalPolicy: normalizeRiskApprovalPolicy(value.riskApprovalPolicy),
consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim() consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim()
? value.consoleRole ? value.consoleRole
: fallback.consoleRole, : fallback.consoleRole,
@@ -934,6 +1013,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
return "数字员工PlanStep派发策略已更新"; return "数字员工PlanStep派发策略已更新";
case "pull_plan_step_dispatch_policy": case "pull_plan_step_dispatch_policy":
return "数字员工PlanStep派发策略已拉取"; return "数字员工PlanStep派发策略已拉取";
case "pull_risk_approval_policy":
return "数字员工风险审批策略已拉取";
default: default:
return `数字员工页面状态已更新${suffix}`; return `数字员工页面状态已更新${suffix}`;
} }
@@ -967,6 +1048,10 @@ export function readDigitalEmployeePlanStepDispatchPolicy(): DigitalEmployeePlan
return readDigitalEmployeeUiState().planStepDispatchPolicy ?? defaultPlanStepDispatchPolicy(); return readDigitalEmployeeUiState().planStepDispatchPolicy ?? defaultPlanStepDispatchPolicy();
} }
export function readDigitalEmployeeRiskApprovalPolicy(): DigitalEmployeeRiskApprovalPolicy {
return readDigitalEmployeeUiState().riskApprovalPolicy ?? defaultRiskApprovalPolicy();
}
export function saveDigitalEmployeeUiState( export function saveDigitalEmployeeUiState(
update: DigitalEmployeeUiStateUpdate, update: DigitalEmployeeUiStateUpdate,
): DigitalEmployeeUiState { ): DigitalEmployeeUiState {
@@ -1274,6 +1359,183 @@ export function listDigitalEmployeeRouteDecisions(options: { limit?: number } =
.filter((decision): decision is DigitalEmployeeRouteDecisionRecord => Boolean(decision)); .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( export function recordDigitalEmployeeSkillCallAudit(
input: DigitalEmployeeSkillCallAuditInput, input: DigitalEmployeeSkillCallAuditInput,
): DigitalEmployeeSkillCallAuditRecord | null { ): 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([ it.each([
["skillPolicies"], ["skillPolicies"],
["policies"], ["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( mockState.fetch.mockResolvedValue(
jsonResponse({ jsonResponse({
success: true, 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-approval-subscriptions" },
{ entity_type: "event", entity_id: "event-plan-step-policy" }, { 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-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", remote_updated_at: "2026-06-07T08:08:00.000Z",
last_pulled_at: "2026-06-07T08:09: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({ expect(readEntity("event", "event-route")).toMatchObject({
sync_status: "synced", sync_status: "synced",
sync_error: null, sync_error: null,

View File

@@ -4,9 +4,11 @@ import { getDomainTokenKey } from "@shared/utils/domain";
import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db"; import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
import { import {
normalizePlanStepDispatchPolicy, normalizePlanStepDispatchPolicy,
normalizeRiskApprovalPolicy,
readDigitalEmployeeUiState, readDigitalEmployeeUiState,
saveDigitalEmployeeUiState, saveDigitalEmployeeUiState,
type DigitalEmployeePlanStepDispatchPolicy, type DigitalEmployeePlanStepDispatchPolicy,
type DigitalEmployeeRiskApprovalPolicy,
} from "./stateService"; } from "./stateService";
import { import {
SKILL_POLICIES_SETTING_KEY, SKILL_POLICIES_SETTING_KEY,
@@ -18,6 +20,7 @@ import {
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox"; 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_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
const DEFAULT_SKILL_POLICY_PATH = "/api/digital-employee/policies/skills"; 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 DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
const MANAGEMENT_SYNC_RECORD_PATH = const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync"; "/system/content/content-digital-employee-sync";
@@ -38,6 +41,8 @@ let policyPulling = false;
let lastPolicyPullAttemptMs = 0; let lastPolicyPullAttemptMs = 0;
let skillPolicyPulling = false; let skillPolicyPulling = false;
let lastSkillPolicyPullAttemptMs = 0; let lastSkillPolicyPullAttemptMs = 0;
let riskApprovalPolicyPulling = false;
let lastRiskApprovalPolicyPullAttemptMs = 0;
function getDigitalEmployeeDb(): ReturnType<typeof getDb> { function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
if (!ensureDigitalEmployeeSchema()) return null; if (!ensureDigitalEmployeeSchema()) return null;
@@ -49,6 +54,7 @@ interface DigitalEmployeeSyncConfig {
endpoint: string | null; endpoint: string | null;
policyEndpoint: string | null; policyEndpoint: string | null;
skillPolicyEndpoint: string | null; skillPolicyEndpoint: string | null;
riskApprovalPolicyEndpoint: string | null;
leaseEndpoint: string | null; leaseEndpoint: string | null;
clientKey: string | null; clientKey: string | null;
authToken: string | null; authToken: string | null;
@@ -73,6 +79,15 @@ export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
error?: string | null; 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 { export interface DigitalEmployeePlanStepRemoteLease {
lease_id: string; lease_id: string;
state: "active" | "released" | "expired"; 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( export async function acquireDigitalEmployeePlanStepRemoteLease(
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput, input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> { ): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
@@ -577,6 +649,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.skillPolicyEndpoint.trim() ? config.skillPolicyEndpoint.trim()
: null; : null;
const skillPolicyEndpoint = skillPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_SKILL_POLICY_PATH); 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 = const leaseEndpointOverride =
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim() typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
? config.planStepDispatchLeaseEndpoint.trim() ? config.planStepDispatchLeaseEndpoint.trim()
@@ -593,6 +670,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
endpoint, endpoint,
policyEndpoint, policyEndpoint,
skillPolicyEndpoint, skillPolicyEndpoint,
riskApprovalPolicyEndpoint,
leaseEndpoint, leaseEndpoint,
clientKey: readClientKey(serverHost), clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost), authToken: readAuthToken(serverHost),
@@ -746,6 +824,14 @@ function readRemoteSkillPolicyRecords(payload: Record<string, unknown>): Record<
?? (looksLikeSkillPolicyMap(payload) ? payload : null); ?? (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 { function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) { if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected"; 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; || "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 { function looksLikeSkillPolicyMap(record: Record<string, unknown>): boolean {
return Object.values(record).some((value) => { return Object.values(record).some((value) => {
if (typeof value === "boolean") return true; if (typeof value === "boolean") return true;
@@ -865,6 +960,35 @@ function recordSkillPolicyPullFailure(
return { ok: false, endpoint, pulled_at: pulledAt, policies, error }; 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> { function buildSyncItem(row: OutboxRow): Record<string, unknown> {
const payload = parsePayload(row.payload); const payload = parsePayload(row.payload);
const summary = readEntitySummary(row.entity_type, row.entity_id); const summary = readEntitySummary(row.entity_type, row.entity_id);
@@ -1058,6 +1182,7 @@ function eventSpecificBusinessView(
return artifactEventBusinessView(payload); return artifactEventBusinessView(payload);
} }
if (kind.startsWith("skill_call_")) return skillCallBusinessView(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 (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload); if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
if (isPlanStepLeaseEvent(kind)) return planStepLeaseBusinessView(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> { function artifactEventBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const retention = objectRecord(payload.retention_summary); const retention = objectRecord(payload.retention_summary);
return { return {
@@ -1266,6 +1409,7 @@ function notificationBusinessView(payload: Record<string, unknown>): Record<stri
function eventBusinessCategory(kind: string): string { function eventBusinessCategory(kind: string): string {
if (kind === "route_decision") return "route"; if (kind === "route_decision") return "route";
if (kind.startsWith("approval_")) return "approval"; if (kind.startsWith("approval_")) return "approval";
if (kind.startsWith("risk_")) return "approval";
if (isApprovalSubscriptionEvent(kind)) return "approval"; if (isApprovalSubscriptionEvent(kind)) return "approval";
if (kind.startsWith("artifact_")) return "artifact"; if (kind.startsWith("artifact_")) return "artifact";
if (kind.startsWith("skill_call_")) return "skill"; if (kind.startsWith("skill_call_")) return "skill";

View File

@@ -134,6 +134,12 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async pullSkillPolicies() { async pullSkillPolicies() {
return ipcRenderer.invoke("digitalEmployee: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() { async getCronSettings() {
return ipcRenderer.invoke("digitalEmployee:getCronSettings"); return ipcRenderer.invoke("digitalEmployee:getCronSettings");
}, },

View File

@@ -451,6 +451,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 数字员工调试存储、dashboard、flow 和产物列表通过 qimingclaw adapter 的 `/api/debug/store``/api/dashboard``/api/debug/plan/:id/flow``/api/debug/task/:id/flow``/api/artifact` 投射本地 Plan / Task / Run / Event / Artifact / ApprovalRun/Event payload 中的 `artifacts``outputs``files``outputPath``uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。 - 数字员工调试存储、dashboard、flow 和产物列表通过 qimingclaw adapter 的 `/api/debug/store``/api/dashboard``/api/debug/plan/:id/flow``/api/debug/task/:id/flow``/api/artifact` 投射本地 Plan / Task / Run / Event / Artifact / ApprovalRun/Event payload 中的 `artifacts``outputs``files``outputPath``uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。
- qimingclaw 主进程记录 `/computer/chat` 结果和 runtime event 时,会从 payload 的 `artifacts``outputs``files``attachments``outputPath``uri` 以及 `approvals``approval_requests``needApproval` 等字段轻量提取正式 `digital_artifacts` / `digital_approvals` 记录,并进入 outbox 同步。 - qimingclaw 主进程记录 `/computer/chat` 结果和 runtime event 时,会从 payload 的 `artifacts``outputs``files``attachments``outputPath``uri` 以及 `approvals``approval_requests``needApproval` 等字段轻量提取正式 `digital_artifacts` / `digital_approvals` 记录,并进入 outbox 同步。
- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action把处理结果写入 SQLite UI state回写正式 approval 状态并进入 approval outbox同时生成 `digital_workday_decide_approval` runtime event。同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。 - `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action把处理结果写入 SQLite UI state回写正式 approval 状态并进入 approval outbox同时生成 `digital_workday_decide_approval` runtime event。同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。
- 高风险动作审批策略已沉淀到 UI state 的 `riskApprovalPolicy`,可通过 `POST /api/risk-approval/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/risk-approval` 下发策略;受控的服务重启、产物访问/保留、审批动作、治理命令和 ready PlanStep 派发前会先评估风险策略,命中审批会写入 `risk_approval_required` approval/event命中拒绝会写入 `risk_policy_rejected` event管理端同步业务视图会提炼 action、risk level、decision、approval ID 和策略来源。
- 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan``submit_plan``approve_plan``activate_plan``create_schedule``run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。 - 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan``submit_plan``approve_plan``activate_plan``create_schedule``run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。
- 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。 - 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。
- 任务中心和委托详情不再使用 sgRobot demo mission fallback没有 qimingclaw Plan / Task / Run / Event 时展示真实空态或错误态,避免示例任务冒充本地任务记录。 - 任务中心和委托详情不再使用 sgRobot demo mission fallback没有 qimingclaw Plan / Task / Run / Event 时展示真实空态或错误态,避免示例任务冒充本地任务记录。
@@ -472,6 +473,8 @@ digitalEmployee:getUiState
digitalEmployee:saveUiState digitalEmployee:saveUiState
digitalEmployee:getSkillCapabilities digitalEmployee:getSkillCapabilities
digitalEmployee:pullSkillPolicies digitalEmployee:pullSkillPolicies
digitalEmployee:pullRiskApprovalPolicy
digitalEmployee:evaluateRiskPolicy
window.QimingClawBridge.digital.getSyncStatus() window.QimingClawBridge.digital.getSyncStatus()
window.QimingClawBridge.digital.flushSync() window.QimingClawBridge.digital.flushSync()
window.QimingClawBridge.digital.getRuntimeRecords() window.QimingClawBridge.digital.getRuntimeRecords()
@@ -479,6 +482,8 @@ window.QimingClawBridge.digital.getUiState()
window.QimingClawBridge.digital.saveUiState(update) window.QimingClawBridge.digital.saveUiState(update)
window.QimingClawBridge.digital.getSkillCapabilities() window.QimingClawBridge.digital.getSkillCapabilities()
window.QimingClawBridge.digital.pullSkillPolicies() window.QimingClawBridge.digital.pullSkillPolicies()
window.QimingClawBridge.digital.pullRiskApprovalPolicy()
window.QimingClawBridge.digital.evaluateRiskPolicy(input)
``` ```
当前边界: 当前边界:
@@ -543,8 +548,8 @@ GET /api/digital-employee/approvals
1. **PlanStep 与依赖图(依赖图调度已开始)** 1. **PlanStep 与依赖图(依赖图调度已开始)**
- 已吸收Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务PlanStep 已开始作为正式本地实体落入 `digital_plan_steps` - 已吸收Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`
- 当前能力PlanStep 会进入 `digital_sync_outbox`entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox前置步骤跳过、取消或重试后会重新评估后继步骤推进为 ready / blocked / pending 并写入依赖图事件scheduler sweep 会识别可派发的 ready PlanStep派发前先通过管理端 `POST /api/digital-employee/leases/plan-step-dispatch/acquire` 做跨设备强租约仲裁,管理端拒绝会以 `remote_lease_rejected` 跳过,管理端异常会降级为 5 分钟本地软租约 `payload.dispatchLease.source = local_fallback` 并记录 `remote_error`;调用本地 `/computer/chat` 后会写入 `plan_step_dispatch_*``governance_start_run` 事件,派发完成或失败会先尝试 `release` 管理端租约再释放本地 lease 并写入 `plan_step_lease_*` 事件;已有未过期租约的步骤仍会以 `lease_active` 跳过auto sweep 会按节流从管理端 `GET /api/digital-employee/policies/plan-step-dispatch` 拉取远端派发策略,拉取失败时保留本地策略并记录 `last_pull_error`embedded 已提供 `/api/digital-employee/plan-steps``/api/digital-employee/plan-step-graph``/api/digital-employee/plans/:planId/dispatch-ready-steps``/api/plan-step/dispatch-policy``/api/plan-step/dispatch-policy/pull`ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地/远端派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。 - 当前能力PlanStep 会进入 `digital_sync_outbox`entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox前置步骤跳过、取消或重试后会重新评估后继步骤推进为 ready / blocked / pending 并写入依赖图事件scheduler sweep 会识别可派发的 ready PlanStep派发前先通过管理端 `POST /api/digital-employee/leases/plan-step-dispatch/acquire` 做跨设备强租约仲裁,管理端拒绝会以 `remote_lease_rejected` 跳过,管理端异常会降级为 5 分钟本地软租约 `payload.dispatchLease.source = local_fallback` 并记录 `remote_error`;调用本地 `/computer/chat` 后会写入 `plan_step_dispatch_*``governance_start_run` 事件,派发完成或失败会先尝试 `release` 管理端租约再释放本地 lease 并写入 `plan_step_lease_*` 事件;已有未过期租约的步骤仍会以 `lease_active` 跳过auto sweep 会按节流从管理端 `GET /api/digital-employee/policies/plan-step-dispatch` 拉取远端派发策略,拉取失败时保留本地策略并记录 `last_pull_error`embedded 已提供 `/api/digital-employee/plan-steps``/api/digital-employee/plan-step-graph``/api/digital-employee/plans/:planId/dispatch-ready-steps``/api/plan-step/dispatch-policy``/api/plan-step/dispatch-policy/pull`ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地/远端派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
- 后续缺口:管理端正式路由 UI、风险策略下发和高风险动作审批策略仍待吸收。 - 后续缺口:管理端正式路由 UI、派发策略管理 UI 和多步审批流仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 或高风险动作审批策略,把本地可治理调度继续推进到跨端治理策略。 - 建议:下一轮围绕管理端正式路由 UI 或多步审批流,把本地可治理调度继续推进到跨端治理策略。
2. **调度 / 定时 / 周期任务** 2. **调度 / 定时 / 周期任务**
- 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。 - 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
@@ -580,9 +585,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端批量治理和跨设备版本聚合,把可操作交付视图推进到完整生命周期管理。 - 建议:下一轮围绕管理端批量治理和跨设备版本聚合,把可操作交付视图推进到完整生命周期管理。
8. **审批 / 人工介入增强** 8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要、管理端 `/api/digital-employee/approval-history` 历史视图、`/api/approval/subscriptions` 订阅策略和 notification 投影,审批动作历史与通知联动已开始闭环。 - 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要、管理端 `/api/digital-employee/approval-history` 历史视图、`/api/approval/subscriptions` 订阅策略和 notification 投影;风险审批策略已支持管理端远端下发、手动拉取、本地策略评估和高风险动作前置拦截,审批动作历史、风险策略与通知联动已开始闭环。
- 缺口:缺少系统级桌面通知、风险策略下发和多步审批流。 - 缺口:缺少系统级桌面通知、多步审批流、审批策略管理 UI 和跨端审批处理回写
- 建议:下一轮先补风险策略下发和多步审批流,再评估系统级桌面通知的跨端策略。 - 建议:下一轮先补多步审批流和跨端审批处理回写,再评估系统级桌面通知的跨端策略。
9. **通知 / Inbox / 用户消息** 9. **通知 / Inbox / 用户消息**
- 已吸收运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知本地通知订阅偏好已接入 `/api/notifications/preferences`,可按总开关、等级和常用类型过滤 Inbox`/api/digital-employee/notifications` 已提供管理端可消费的本地通知业务视图,通知动作和订阅偏好事件的 sync `business_view` 会归类为 notification 并提炼状态字段;`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox管理端 sync `business_view` 只同步来源、通知 ID 和输入长度,通知投影、本地订阅策略与任务输入链路已开始闭环。 - 已吸收运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知本地通知订阅偏好已接入 `/api/notifications/preferences`,可按总开关、等级和常用类型过滤 Inbox`/api/digital-employee/notifications` 已提供管理端可消费的本地通知业务视图,通知动作和订阅偏好事件的 sync `business_view` 会归类为 notification 并提炼状态字段;`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox管理端 sync `business_view` 只同步来源、通知 ID 和输入长度,通知投影、本地订阅策略与任务输入链路已开始闭环。
@@ -592,8 +597,8 @@ GET /api/digital-employee/approvals
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)** 10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions``/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。 - 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions``/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decisionscheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要和通知联动已开始闭环。 - 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decisionscheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要和通知联动已开始闭环。
- 后续缺口:管理端正式路由 UI、风险策略下发和高风险动作审批策略仍待吸收。 - 后续缺口:管理端正式路由 UI、路由策略下发和跨端路由干预入口仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 和策略下发,把入口路由从本地可观测推进到跨端治理策略。 - 建议:下一轮围绕管理端正式路由 UI 和路由策略下发,把入口路由从本地可观测推进到跨端治理策略。
11. **诊断 / 成本 / 审计视图** 11. **诊断 / 成本 / 审计视图**
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成只读诊断、估算成本和审计投影;数字员工首页已展示诊断/审计/成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。 - 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成只读诊断、估算成本和审计投影;数字员工首页已展示诊断/审计/成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。