吸收数字员工风险策略远端下发
This commit is contained in:
@@ -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]) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 / Approval;Run/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 / Approval;Run/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 与 outbox,entity type 为 `schedule` / `schedule_run`;旧 `/api/cron`、`/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
|
- 已吸收:`digital_schedules`、`digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outbox,entity 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 decision;scheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要和通知联动已开始闭环。
|
- 当前能力:入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision,并写入 `digital_events.kind = route_decision` 与现有 event outbox;路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command,并把 `created_command_id` 回填到 route decision;scheduler 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` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。
|
||||||
|
|||||||
Reference in New Issue
Block a user