吸收数字员工风险策略远端下发
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>> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user