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

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

View File

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

View File

@@ -55,6 +55,8 @@ declare global {
getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>;
setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>;
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>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>;
@@ -353,6 +355,32 @@ interface QimingclawRouteDecisionInput {
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 {
generatedAt: string;
plans: QimingclawPlanRecord[];
@@ -979,6 +1007,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname === '/api/skill/policies/pull') {
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')) {
const skillId = decodeURIComponent(pathname.split('/')[3] || '');
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[] {
const seen = new Set<string>();
const result: SkillSpec[] = [];
@@ -1511,6 +1601,29 @@ async function handleManagedServiceRestart(
serviceId: string,
body: Record<string, unknown>,
): 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;
if (!bridge?.restartManagedService) {
const snapshot = await readQimingclawSnapshot();
@@ -1552,6 +1665,28 @@ async function handleArtifactAccess(
): Promise<QimingclawArtifactAccessResponse> {
const bridge = window.QimingClawBridge?.digital;
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) {
return {
ok: false,
@@ -1592,6 +1727,21 @@ async function handleArtifactRetention(
const bridge = window.QimingClawBridge?.digital;
const action = stringValue(body.action) || 'mark_review';
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) {
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 action = stringValue(body.action) || 'comment';
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) {
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 !== ''));
}
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(
body: GovernanceCommandRequest,
snapshot: QimingclawSnapshot | null,
@@ -5815,6 +5986,39 @@ async function handleGovernanceCommand(
const scheduleId = stringFromUnknown(body.context_refs?.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') {
const createdPlanId = `qimingclaw-local-${slugifyIdPart(stringFromUnknown(body.payload?.title) || planId || 'plan')}-${Date.now()}`;
const response = governanceAccepted(commandId, correlationId, '已在 qimingclaw 兼容层创建本地计划草稿。', {
@@ -6234,6 +6438,34 @@ async function dispatchPlanReadySteps(
snapshot: QimingclawSnapshot | null = null,
): Promise<ReadyStepDispatchResult> {
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;
if (bridgeResult) return bridgeResult;
const now = new Date().toISOString();

View File

@@ -12,6 +12,7 @@ import {
governanceCommand,
postDigitalEmployeeWorkdayAction,
pullPlanStepDispatchPolicy,
pullRiskApprovalPolicy,
recordApprovalAction,
restartManagedService,
} from '@/lib/api';
@@ -1391,6 +1392,27 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [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 diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
try {
@@ -2049,6 +2071,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<ShieldCheck size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_plan_step_dispatch_policy' ? '拉取中...' : '拉取策略'}</span>
</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 className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}>