吸收数字员工多步审批跨端回写
This commit is contained in:
@@ -861,7 +861,7 @@ export function setArtifactRetention(
|
||||
|
||||
export function recordApprovalAction(
|
||||
approvalId: string,
|
||||
action: 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk',
|
||||
action: 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'approve' | 'reject',
|
||||
options: Record<string, string | null | undefined> = {},
|
||||
): Promise<any> {
|
||||
return apiFetch(`/api/digital-employee/approvals/${encodeURIComponent(approvalId)}/actions`, {
|
||||
@@ -870,6 +870,13 @@ export function recordApprovalAction(
|
||||
});
|
||||
}
|
||||
|
||||
export function pullApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
|
||||
return apiFetch<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>('/api/approval/updates/pull', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDebugConversations(): Promise<any[]> {
|
||||
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
|
||||
.then((data) => data.conversations ?? []);
|
||||
|
||||
@@ -56,6 +56,8 @@ declare global {
|
||||
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 }>;
|
||||
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
||||
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
||||
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
|
||||
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
|
||||
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
|
||||
@@ -323,6 +325,16 @@ interface QimingclawApprovalActionResult {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ApprovalWorkflowSummary {
|
||||
workflow_id: string | null;
|
||||
current_step_id: string | null;
|
||||
current_step_label: string | null;
|
||||
current_participant: string | null;
|
||||
step_index: number | null;
|
||||
step_count: number | null;
|
||||
next_step_label: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawManagedServiceActionResponse {
|
||||
ok: boolean;
|
||||
service_id: string;
|
||||
@@ -1010,6 +1022,9 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'POST' && pathname === '/api/risk-approval/policy/pull') {
|
||||
return await pullBridgeRiskApprovalPolicy() as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/approval/updates/pull') {
|
||||
return await pullBridgeApprovalUpdates() 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);
|
||||
@@ -1162,6 +1177,25 @@ async function pullBridgeRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?:
|
||||
}
|
||||
}
|
||||
|
||||
async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.pullApprovalUpdates) {
|
||||
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
|
||||
}
|
||||
try {
|
||||
const result = await bridge.pullApprovalUpdates();
|
||||
return {
|
||||
ok: result?.ok !== false,
|
||||
applied: Number(result?.applied ?? 0),
|
||||
ignored: Number(result?.ignored ?? 0),
|
||||
...(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) {
|
||||
@@ -3281,6 +3315,7 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
|
||||
const approvalId = stringValue(approval.approval_id);
|
||||
const runtime = runtimeById.get(approvalId);
|
||||
const summary = approvalActionSummary(snapshot, approvalId, approval);
|
||||
const workflow = approvalWorkflowSummary(approval);
|
||||
const entity = businessEntityFromRefs({
|
||||
plan_id: approval.plan_id,
|
||||
task_id: approval.task_id,
|
||||
@@ -3302,6 +3337,13 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
|
||||
last_actor: summary.last_actor,
|
||||
comment_count: summary.comment_count,
|
||||
delegate_to: summary.delegate_to,
|
||||
workflow_id: workflow.workflow_id,
|
||||
current_step_id: workflow.current_step_id,
|
||||
current_step_label: workflow.current_step_label,
|
||||
current_participant: workflow.current_participant,
|
||||
approval_step_index: workflow.step_index,
|
||||
approval_step_count: workflow.step_count,
|
||||
next_step_label: workflow.next_step_label,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3423,6 +3465,25 @@ function filterApprovalHistoryRows(rows: BusinessViewRow[], searchParams: URLSea
|
||||
.filter((row) => !riskLevel || stringValue(row.risk_level) === riskLevel);
|
||||
}
|
||||
|
||||
function approvalWorkflowSummary(approval: Record<string, unknown>): ApprovalWorkflowSummary {
|
||||
const payload = asRecord(approval.payload) ?? {};
|
||||
const workflow = asRecord(payload.workflow) ?? {};
|
||||
const steps = Array.isArray(workflow.steps) ? workflow.steps.map((step) => asRecord(step) ?? {}) : [];
|
||||
const currentStepId = stringValue(workflow.current_step_id ?? workflow.currentStepId ?? payload.current_step_id ?? payload.currentStepId);
|
||||
const currentIndex = Math.max(0, steps.findIndex((step) => stringValue(step.step_id ?? step.stepId ?? step.id) === currentStepId));
|
||||
const current = steps[currentIndex] ?? null;
|
||||
const next = steps.slice(currentIndex + 1).find((step) => ['pending', 'delegated', 'timeout'].includes(stringValue(step.status) || 'pending')) ?? null;
|
||||
return {
|
||||
workflow_id: stringValue(workflow.workflow_id ?? workflow.workflowId) || null,
|
||||
current_step_id: currentStepId || null,
|
||||
current_step_label: stringValue(current?.label ?? current?.title ?? current?.name) || null,
|
||||
current_participant: stringValue(current?.participant_label ?? current?.participantLabel ?? current?.participant_id ?? current?.participantId) || null,
|
||||
step_index: steps.length > 0 ? currentIndex + 1 : null,
|
||||
step_count: steps.length || null,
|
||||
next_step_label: stringValue(next?.label ?? next?.title ?? next?.name) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalActionSummary(
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
approvalId: string,
|
||||
@@ -4581,6 +4642,13 @@ function buildWorkdayDecisions(
|
||||
last_actor: stringValue(summary?.last_actor) || null,
|
||||
comment_count: numberValue(summary?.comment_count) ?? 0,
|
||||
delegate_to: stringValue(summary?.delegate_to) || null,
|
||||
workflow_id: stringValue(summary?.workflow_id) || null,
|
||||
current_step_id: stringValue(summary?.current_step_id) || null,
|
||||
current_step_label: stringValue(summary?.current_step_label) || null,
|
||||
current_participant: stringValue(summary?.current_participant) || null,
|
||||
approval_step_index: numberValue(summary?.approval_step_index),
|
||||
approval_step_count: numberValue(summary?.approval_step_count),
|
||||
next_step_label: stringValue(summary?.next_step_label) || null,
|
||||
actions: isDecisionOpenStatus(status)
|
||||
? [
|
||||
{ id: 'approved', label: '同意', tone: 'primary' },
|
||||
@@ -5375,6 +5443,16 @@ async function handleWorkdayAction(body: DigitalEmployeeWorkdayActionRequest, sn
|
||||
: undefined,
|
||||
);
|
||||
const nextSnapshot = { ...(snapshot ?? { generatedAt: new Date().toISOString(), services: {}, sessions: { total: 0, active: 0, items: [] } }), uiState: savedState };
|
||||
if (body.action === 'decide_approval') {
|
||||
await window.QimingClawBridge?.digital?.submitApprovalDecision?.({
|
||||
approval_id: body.decision_id,
|
||||
decision: body.decision,
|
||||
date,
|
||||
actor: 'digital_employee_operator',
|
||||
source: 'sgrobot-digital-adapter',
|
||||
acp_permission: acpPermissionResult,
|
||||
}).catch(() => null);
|
||||
}
|
||||
const projection = buildWorkday(date, nextSnapshot);
|
||||
if (body.action === 'generate_daily_report' && projection.daily_report) {
|
||||
await recordDailyReportArtifact(projection.daily_report);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSchedulerJobs,
|
||||
governanceCommand,
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
pullApprovalUpdates,
|
||||
pullPlanStepDispatchPolicy,
|
||||
pullRiskApprovalPolicy,
|
||||
recordApprovalAction,
|
||||
@@ -1413,6 +1414,27 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
const refreshApprovalUpdates = useCallback(async () => {
|
||||
setActionBusy('pull_approval_updates');
|
||||
setActionError(null);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const result = await pullApprovalUpdates();
|
||||
if (!mountedRef.current) return;
|
||||
await fetchProjection();
|
||||
if (!mountedRef.current) return;
|
||||
setActionNotice(result.ok
|
||||
? `审批更新已拉取:应用 ${result.applied ?? 0} 条,忽略 ${result.ignored ?? 0} 条。`
|
||||
: `审批更新拉取失败:${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 {
|
||||
@@ -2081,6 +2103,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<ShieldAlert size={13} aria-hidden="true" />
|
||||
<span>{actionBusy === 'pull_risk_approval_policy' ? '拉取中...' : '风险策略'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={projectionLoading || actionBusy === 'pull_approval_updates'}
|
||||
onClick={refreshApprovalUpdates}
|
||||
title="从管理端拉取审批更新"
|
||||
>
|
||||
<RefreshCw size={13} aria-hidden="true" />
|
||||
<span>{actionBusy === 'pull_approval_updates' ? '拉取中...' : '审批更新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}>
|
||||
@@ -3227,6 +3259,10 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<span>{decision.comment_count ? `评论 ${decision.comment_count} 条` : '暂无评论'}</span>
|
||||
<span>{decision.delegate_to ? `转交 ${decision.delegate_to}` : '未转交'}</span>
|
||||
<span>{decision.last_action ? `最近 ${approvalActionLabel(decision.last_action)} · ${decision.last_actor || '未知'}` : '暂无审批动作'}</span>
|
||||
<span>{decision.approval_step_count ? `步骤 ${decision.approval_step_index || 1}/${decision.approval_step_count}` : '单步审批'}</span>
|
||||
<span>{decision.current_step_label ? `当前 ${decision.current_step_label}` : '当前步骤待确认'}</span>
|
||||
<span>{decision.current_participant ? `处理人 ${decision.current_participant}` : '未指定处理人'}</span>
|
||||
<span>{decision.next_step_label ? `下一步 ${decision.next_step_label}` : '无后续步骤'}</span>
|
||||
</div>
|
||||
<div className="de-approval-action-fields">
|
||||
<input
|
||||
|
||||
@@ -874,6 +874,13 @@ export interface DigitalEmployeeDecision {
|
||||
last_actor?: string | null;
|
||||
comment_count?: number | null;
|
||||
delegate_to?: string | null;
|
||||
workflow_id?: string | null;
|
||||
current_step_id?: string | null;
|
||||
current_step_label?: string | null;
|
||||
current_participant?: string | null;
|
||||
approval_step_index?: number | null;
|
||||
approval_step_count?: number | null;
|
||||
next_step_label?: string | null;
|
||||
actions: DigitalEmployeeDecisionAction[];
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-COcOCjV1.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-DxdWKD94.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -283,6 +283,62 @@ function checkDigitalApprovalHistorySubscriptions() {
|
||||
console.log("[DigitalEmployeeCheck] approval history/subscription hooks OK");
|
||||
}
|
||||
|
||||
function checkDigitalApprovalWorkflowSync() {
|
||||
console.log("\n[DigitalEmployeeCheck] Verify approval workflow/sync 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 typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "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 types = fs.readFileSync(typesPath, "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, "DigitalEmployeeApprovalWorkflow", "approval workflow type"],
|
||||
[stateService, "normalizeDigitalEmployeeApprovalWorkflow", "approval workflow normalizer"],
|
||||
[stateService, "applyApprovalWorkflowDecision", "approval workflow decision advancer"],
|
||||
[stateService, "applyApprovalWorkflowAction", "approval workflow action updater"],
|
||||
[stateService, "upsertDigitalEmployeeApprovalFromRemote", "remote approval update upsert helper"],
|
||||
[stateService, "decision_history", "approval workflow decision history field"],
|
||||
[syncService, "pullDigitalEmployeeApprovalUpdates", "remote approval updates pull service"],
|
||||
[syncService, "submitDigitalEmployeeApprovalDecision", "approval decision submit service"],
|
||||
[syncService, "/api/digital-employee/approvals/updates", "remote approval updates default path"],
|
||||
[syncService, "/api/digital-employee/approvals/decisions", "remote approval decisions default path"],
|
||||
[syncService, "approvalUpdatesEndpoint", "approval updates endpoint config"],
|
||||
[syncService, "approvalDecisionsEndpoint", "approval decisions endpoint config"],
|
||||
[syncService, "approvalSyncBusinessView", "approval sync business view"],
|
||||
[syncService, "workflow_id", "approval workflow sync business field"],
|
||||
[ipcHandlers, "digitalEmployee:pullApprovalUpdates", "IPC approval updates pull handler"],
|
||||
[ipcHandlers, "digitalEmployee:submitApprovalDecision", "IPC approval decision submit handler"],
|
||||
[preload, "pullApprovalUpdates", "preload approval updates bridge"],
|
||||
[preload, "submitApprovalDecision", "preload approval decision bridge"],
|
||||
[adapter, "pullApprovalUpdates", "adapter approval updates bridge declaration"],
|
||||
[adapter, "submitApprovalDecision", "adapter approval decision bridge declaration"],
|
||||
[adapter, "/api/approval/updates/pull", "adapter approval updates pull endpoint"],
|
||||
[adapter, "approvalWorkflowSummary", "adapter approval workflow summary"],
|
||||
[api, "pullApprovalUpdates", "embedded approval updates API"],
|
||||
[types, "current_step_label", "approval decision current step type field"],
|
||||
[types, "approval_step_count", "approval decision step count type field"],
|
||||
[commandDeck, "pull_approval_updates", "home approval updates pull action"],
|
||||
[commandDeck, "审批更新", "home approval updates pull button"],
|
||||
];
|
||||
const missing = requiredSnippets
|
||||
.filter(([content, snippet]) => !content.includes(snippet))
|
||||
.map(([, , label]) => label);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing approval workflow/sync hooks: ${missing.join(", ")}`);
|
||||
}
|
||||
console.log("[DigitalEmployeeCheck] approval workflow/sync hooks OK");
|
||||
}
|
||||
|
||||
function checkDigitalPlanStepGraphDispatchPolicy() {
|
||||
console.log("\n[DigitalEmployeeCheck] Verify PlanStep graph/dispatch policy hooks");
|
||||
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
|
||||
@@ -557,6 +613,7 @@ function main() {
|
||||
checkDigitalDiagnosticAuditProjection();
|
||||
checkDigitalNotificationPreferences();
|
||||
checkDigitalApprovalHistorySubscriptions();
|
||||
checkDigitalApprovalWorkflowSync();
|
||||
checkDigitalPlanStepGraphDispatchPolicy();
|
||||
checkDigitalSkillRemotePolicy();
|
||||
checkDigitalRiskApprovalPolicy();
|
||||
|
||||
@@ -98,6 +98,8 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
|
||||
pullDigitalEmployeePlanStepDispatchPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null } })),
|
||||
pullDigitalEmployeeSkillPolicies: vi.fn(async () => ({ ok: true, policies: { version: 1, local_skills: {}, remote_skills: {}, skills: {} } })),
|
||||
pullDigitalEmployeeRiskApprovalPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, approval_required_risk_levels: ["high"], approval_required_actions: [], auto_reject_actions: [] } })),
|
||||
pullDigitalEmployeeApprovalUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
|
||||
submitDigitalEmployeeApprovalDecision: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
|
||||
@@ -311,4 +313,25 @@ describe("digital employee managed service actions", () => {
|
||||
comment: "需要复核",
|
||||
});
|
||||
});
|
||||
|
||||
it("registers approval update pull and decision submit through IPC", async () => {
|
||||
const electron = await import("electron");
|
||||
const syncService = await import("../services/digitalEmployee/syncService");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
registerDigitalEmployeeHandlers(createCtx());
|
||||
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
|
||||
const pullCall = calls.find(([channel]) => channel === "digitalEmployee:pullApprovalUpdates");
|
||||
const submitCall = calls.find(([channel]) => channel === "digitalEmployee:submitApprovalDecision");
|
||||
expect(pullCall).toBeTruthy();
|
||||
expect(submitCall).toBeTruthy();
|
||||
|
||||
const pullResult = await (pullCall?.[1] as (_event: unknown) => Promise<unknown>)({});
|
||||
const submitResult = await (submitCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { approval_id: "approval-1", decision: "approved" });
|
||||
|
||||
expect(pullResult).toMatchObject({ ok: true, applied: 1 });
|
||||
expect(submitResult).toMatchObject({ ok: true });
|
||||
expect(syncService.pullDigitalEmployeeApprovalUpdates).toHaveBeenCalledWith({ force: true });
|
||||
expect(syncService.submitDigitalEmployeeApprovalDecision).toHaveBeenCalledWith({ approval_id: "approval-1", decision: "approved" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,9 +58,11 @@ import {
|
||||
import {
|
||||
flushDigitalEmployeeSyncOutbox,
|
||||
getDigitalEmployeeSyncStatus,
|
||||
pullDigitalEmployeeApprovalUpdates,
|
||||
pullDigitalEmployeePlanStepDispatchPolicy,
|
||||
pullDigitalEmployeeSkillPolicies,
|
||||
pullDigitalEmployeeRiskApprovalPolicy,
|
||||
submitDigitalEmployeeApprovalDecision,
|
||||
} from "../services/digitalEmployee/syncService";
|
||||
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
||||
import {
|
||||
@@ -204,6 +206,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
return pullDigitalEmployeeRiskApprovalPolicy({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:pullApprovalUpdates", async () => {
|
||||
return pullDigitalEmployeeApprovalUpdates({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:submitApprovalDecision", async (_, input: unknown) => {
|
||||
return submitDigitalEmployeeApprovalDecision(typeof input === "object" && input ? input as Record<string, unknown> : {});
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:evaluateRiskPolicy", async (_, input: unknown) => {
|
||||
return evaluateDigitalEmployeeRiskPolicy(typeof input === "object" && input ? input as Parameters<typeof evaluateDigitalEmployeeRiskPolicy>[0] : {});
|
||||
});
|
||||
|
||||
@@ -783,6 +783,107 @@ describe("digital employee state service", () => {
|
||||
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("advances multi-step approval workflow without completing the approval early", async () => {
|
||||
const { saveDigitalEmployeeUiState } = await import("./stateService");
|
||||
mockState.db?.approvals.set("approval-workflow-1", {
|
||||
id: "approval-workflow-1",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
title: "高风险动作审批",
|
||||
status: "pending",
|
||||
payload: JSON.stringify({
|
||||
workflow: {
|
||||
workflow_id: "workflow-1",
|
||||
current_step_id: "step-1",
|
||||
steps: [
|
||||
{ step_id: "step-1", label: "主管审批", status: "pending", participant_id: "lead", participant_label: "主管" },
|
||||
{ step_id: "step-2", label: "风控复核", status: "pending", participant_id: "risk", participant_label: "风控" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:00:00.000Z",
|
||||
updated_at: "2026-06-07T07:00:00.000Z",
|
||||
});
|
||||
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "decide_approval",
|
||||
date: "2026-06-07",
|
||||
metadata: { decision_id: "approval-workflow-1", decision: "approved", actor: "lead" },
|
||||
state: {
|
||||
selectedDutyIdsByDate: {},
|
||||
confirmedDates: {},
|
||||
reportScheduleTime: "18:00",
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: { "2026-06-07": { "approval-workflow-1": "approved" } },
|
||||
profile: { name: "飞天数字员工", company: "qimingclaw 客户端", position: "客户端任务执行员", currentStatus: "working" },
|
||||
},
|
||||
});
|
||||
|
||||
const approval = mockState.db?.approvals.get("approval-workflow-1");
|
||||
const payload = JSON.parse(approval?.payload ?? "{}");
|
||||
expect(approval).toMatchObject({ status: "pending", sync_status: "pending" });
|
||||
expect(payload.workflow).toMatchObject({
|
||||
workflow_id: "workflow-1",
|
||||
status: "pending",
|
||||
current_step_id: "step-2",
|
||||
steps: [
|
||||
expect.objectContaining({ step_id: "step-1", status: "approved", actor: "lead" }),
|
||||
expect.objectContaining({ step_id: "step-2", status: "pending" }),
|
||||
],
|
||||
});
|
||||
const decisionEvent = Array.from(mockState.db?.events.values() ?? []).find((event) => event.kind === "approval_decision");
|
||||
expect(JSON.parse(decisionEvent?.payload ?? "{}")).toMatchObject({
|
||||
nextStatus: "pending",
|
||||
step_id: "step-1",
|
||||
current_step_id: "step-2",
|
||||
next_step_id: "step-2",
|
||||
participant_id: "lead",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates current approval workflow step when governance actions are recorded", async () => {
|
||||
const { recordDigitalEmployeeApprovalAction } = await import("./stateService");
|
||||
mockState.db?.approvals.set("approval-workflow-2", {
|
||||
id: "approval-workflow-2",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
title: "多步审批",
|
||||
status: "pending",
|
||||
payload: JSON.stringify({ workflow: { workflow_id: "workflow-2", current_step_id: "step-1", steps: [{ step_id: "step-1", label: "一审", status: "pending" }] } }),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:00:00.000Z",
|
||||
updated_at: "2026-06-07T07:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = recordDigitalEmployeeApprovalAction({
|
||||
approvalId: "approval-workflow-2",
|
||||
action: "delegate",
|
||||
delegateTo: "risk-owner",
|
||||
delegateLabel: "风控负责人",
|
||||
actor: "operator",
|
||||
occurredAt: "2026-06-07T08:05:00.000Z",
|
||||
});
|
||||
|
||||
const payload = JSON.parse(mockState.db?.approvals.get("approval-workflow-2")?.payload ?? "{}");
|
||||
expect(result?.kind).toBe("approval_action_recorded");
|
||||
expect(mockState.db?.approvals.get("approval-workflow-2")).toMatchObject({ status: "pending", sync_status: "pending" });
|
||||
expect(payload.workflow.steps[0]).toMatchObject({
|
||||
step_id: "step-1",
|
||||
status: "delegated",
|
||||
participant_id: "risk-owner",
|
||||
participant_label: "风控负责人",
|
||||
});
|
||||
expect(JSON.parse(mockState.db?.events.get(result!.eventId)?.payload ?? "{}")).toMatchObject({
|
||||
workflow: expect.objectContaining({ workflow_id: "workflow-2" }),
|
||||
step_id: "step-1",
|
||||
step_status: "delegated",
|
||||
participant_id: "risk-owner",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists console role and role preferences in UI state", async () => {
|
||||
const { readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService");
|
||||
|
||||
|
||||
@@ -265,7 +265,34 @@ export interface DigitalEmployeeArtifactLifecycleRecord {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type DigitalEmployeeApprovalActionKind = "comment" | "delegate" | "mark_timeout" | "mark_risk" | "clear_risk";
|
||||
export type DigitalEmployeeApprovalActionKind = "comment" | "delegate" | "mark_timeout" | "mark_risk" | "clear_risk" | "approve" | "reject";
|
||||
|
||||
export interface DigitalEmployeeApprovalWorkflowStep {
|
||||
step_id: string;
|
||||
label: string;
|
||||
status: string;
|
||||
participant_id: string | null;
|
||||
participant_label: string | null;
|
||||
decision: string | null;
|
||||
actor: string | null;
|
||||
decided_at: string | null;
|
||||
due_at: string | null;
|
||||
risk_level: string | null;
|
||||
sla_status: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeApprovalWorkflow {
|
||||
workflow_id: string;
|
||||
status: string;
|
||||
current_step_id: string | null;
|
||||
steps: DigitalEmployeeApprovalWorkflowStep[];
|
||||
participants: Array<{ participant_id: string; label: string | null }>;
|
||||
decision_history: Array<Record<string, unknown>>;
|
||||
updated_at: string | null;
|
||||
source?: string | null;
|
||||
remote_updated_at?: string | null;
|
||||
last_pull_error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeApprovalActionInput {
|
||||
approvalId?: string | null;
|
||||
@@ -291,6 +318,20 @@ export interface DigitalEmployeeApprovalActionRecord {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeApprovalUpdateInput {
|
||||
approvalId?: string | null;
|
||||
remoteId?: string | null;
|
||||
planId?: string | null;
|
||||
taskId?: string | null;
|
||||
runId?: string | null;
|
||||
title?: string | null;
|
||||
status?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
source?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeReadyPlanStepDispatchCandidate {
|
||||
stepId: string;
|
||||
planId: string;
|
||||
@@ -1015,6 +1056,14 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
|
||||
return "数字员工PlanStep派发策略已拉取";
|
||||
case "pull_risk_approval_policy":
|
||||
return "数字员工风险审批策略已拉取";
|
||||
case "approval_updates_pulled":
|
||||
return "数字员工审批更新已拉取";
|
||||
case "approval_updates_pull_failed":
|
||||
return "数字员工审批更新拉取失败";
|
||||
case "approval_decision_submitted":
|
||||
return "数字员工审批处理已回写管理端";
|
||||
case "approval_decision_submit_failed":
|
||||
return "数字员工审批处理回写失败";
|
||||
default:
|
||||
return `数字员工页面状态已更新${suffix}`;
|
||||
}
|
||||
@@ -1122,16 +1171,26 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
|
||||
const planId = stringField(existing, "plan_id");
|
||||
const taskId = stringField(existing, "task_id");
|
||||
const runId = stringField(existing, "run_id");
|
||||
const title = stringField(existing, "title") || "待确认事项";
|
||||
const title = existing ? stringField(existing, "title") || "待确认事项" : "待确认事项";
|
||||
const storedPayload = parseStoredPayload(existing["payload"]);
|
||||
const storedPayloadRecord = objectRecord(storedPayload);
|
||||
const acpPermission = objectRecord(metadata?.acp_permission);
|
||||
const actor = stringValue(metadata?.actor) || "digital_employee_operator";
|
||||
const workflowDecision = applyApprovalWorkflowDecision(
|
||||
normalizeDigitalEmployeeApprovalWorkflow(storedPayloadRecord ?? {}, decisionId, title, now),
|
||||
decision,
|
||||
actor,
|
||||
now,
|
||||
);
|
||||
const payload = storedPayloadRecord
|
||||
? {
|
||||
...storedPayloadRecord,
|
||||
decision,
|
||||
decidedAt: now,
|
||||
previousStatus,
|
||||
workflow: workflowDecision.workflow,
|
||||
current_step_id: workflowDecision.workflow.current_step_id,
|
||||
decision_history: workflowDecision.workflow.decision_history,
|
||||
...(acpPermission ? { acpPermission } : {}),
|
||||
}
|
||||
: {
|
||||
@@ -1139,6 +1198,9 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
|
||||
decision,
|
||||
decidedAt: now,
|
||||
previousStatus,
|
||||
workflow: workflowDecision.workflow,
|
||||
current_step_id: workflowDecision.workflow.current_step_id,
|
||||
decision_history: workflowDecision.workflow.decision_history,
|
||||
...(acpPermission ? { acpPermission } : {}),
|
||||
};
|
||||
upsertApproval({
|
||||
@@ -1147,7 +1209,7 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
|
||||
taskId,
|
||||
runId,
|
||||
title,
|
||||
status: nextStatus,
|
||||
status: workflowDecision.nextStatus || nextStatus,
|
||||
payload,
|
||||
now,
|
||||
});
|
||||
@@ -1156,7 +1218,15 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
|
||||
approvalId: decisionId,
|
||||
decision,
|
||||
previousStatus,
|
||||
nextStatus,
|
||||
nextStatus: workflowDecision.nextStatus || nextStatus,
|
||||
workflow: approvalWorkflowSnapshot(workflowDecision.workflow),
|
||||
step_id: workflowDecision.step?.step_id ?? null,
|
||||
step_status: workflowDecision.step?.status ?? null,
|
||||
current_step_id: workflowDecision.workflow.current_step_id,
|
||||
next_step_id: workflowDecision.nextStep?.step_id ?? null,
|
||||
participant_id: workflowDecision.step?.participant_id ?? null,
|
||||
participant_label: workflowDecision.step?.participant_label ?? null,
|
||||
actor,
|
||||
title,
|
||||
planId,
|
||||
taskId,
|
||||
@@ -1186,6 +1256,218 @@ function approvalDecisionStatus(decision: string): string {
|
||||
return "completed";
|
||||
}
|
||||
|
||||
export function normalizeDigitalEmployeeApprovalWorkflow(
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
approvalId: string,
|
||||
title = "待确认事项",
|
||||
now = new Date().toISOString(),
|
||||
): DigitalEmployeeApprovalWorkflow {
|
||||
const workflow = objectRecord(payload?.workflow ?? payload?.approval_workflow) ?? {};
|
||||
const rawSteps = Array.isArray(workflow.steps) ? workflow.steps : Array.isArray(payload?.steps) ? payload.steps : [];
|
||||
const steps = rawSteps
|
||||
.map((step, index) => normalizeApprovalWorkflowStep(step, index, title))
|
||||
.filter((step): step is DigitalEmployeeApprovalWorkflowStep => Boolean(step));
|
||||
const safeSteps = steps.length > 0
|
||||
? steps
|
||||
: [normalizeApprovalWorkflowStep({ step_id: `${approvalId}:step:1`, label: title }, 0, title)!];
|
||||
const currentStepId = stringValue(workflow.current_step_id ?? workflow.currentStepId ?? payload?.current_step_id ?? payload?.currentStepId);
|
||||
const current = safeSteps.find((step) => step.step_id === currentStepId)
|
||||
?? safeSteps.find((step) => isApprovalWorkflowStepOpen(step.status))
|
||||
?? safeSteps[safeSteps.length - 1];
|
||||
const participants = Array.isArray(workflow.participants)
|
||||
? workflow.participants
|
||||
.map((participant) => objectRecord(participant))
|
||||
.filter((participant): participant is Record<string, unknown> => Boolean(participant))
|
||||
.map((participant) => ({
|
||||
participant_id: stringValue(participant.participant_id ?? participant.participantId ?? participant.id) || "operator",
|
||||
label: stringValue(participant.label ?? participant.name) || null,
|
||||
}))
|
||||
: approvalWorkflowParticipants(safeSteps);
|
||||
const history = Array.isArray(workflow.decision_history ?? workflow.decisionHistory ?? payload?.decision_history ?? payload?.decisionHistory)
|
||||
? [...(workflow.decision_history ?? workflow.decisionHistory ?? payload?.decision_history ?? payload?.decisionHistory) as unknown[]]
|
||||
.map((item) => objectRecord(item) ?? { value: item })
|
||||
: [];
|
||||
return {
|
||||
workflow_id: stringValue(workflow.workflow_id ?? workflow.workflowId ?? payload?.workflow_id ?? payload?.workflowId) || `approval-workflow:${safeId(approvalId)}`,
|
||||
status: normalizeApprovalWorkflowStatus(stringValue(workflow.status ?? payload?.workflow_status ?? payload?.status) || "pending"),
|
||||
current_step_id: current?.step_id ?? null,
|
||||
steps: safeSteps,
|
||||
participants,
|
||||
decision_history: history,
|
||||
updated_at: stringValue(workflow.updated_at ?? workflow.updatedAt ?? payload?.updated_at ?? payload?.updatedAt) || now,
|
||||
source: stringValue(workflow.source ?? payload?.source) || null,
|
||||
remote_updated_at: stringValue(workflow.remote_updated_at ?? workflow.remoteUpdatedAt ?? payload?.remote_updated_at ?? payload?.remoteUpdatedAt) || null,
|
||||
last_pull_error: stringValue(workflow.last_pull_error ?? workflow.lastPullError ?? payload?.last_pull_error ?? payload?.lastPullError) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApprovalWorkflowStep(
|
||||
value: unknown,
|
||||
index: number,
|
||||
fallbackLabel: string,
|
||||
): DigitalEmployeeApprovalWorkflowStep | null {
|
||||
const record = objectRecord(value) ?? {};
|
||||
const stepId = stringValue(record.step_id ?? record.stepId ?? record.id) || `step-${index + 1}`;
|
||||
return {
|
||||
step_id: stepId,
|
||||
label: stringValue(record.label ?? record.title ?? record.name) || (index === 0 ? fallbackLabel : `审批步骤 ${index + 1}`),
|
||||
status: normalizeApprovalWorkflowStatus(stringValue(record.status) || "pending"),
|
||||
participant_id: stringValue(record.participant_id ?? record.participantId ?? record.assignee_id ?? record.assigneeId) || null,
|
||||
participant_label: stringValue(record.participant_label ?? record.participantLabel ?? record.assignee_label ?? record.assigneeLabel ?? record.assignee) || null,
|
||||
decision: stringValue(record.decision) || null,
|
||||
actor: stringValue(record.actor) || null,
|
||||
decided_at: stringValue(record.decided_at ?? record.decidedAt) || null,
|
||||
due_at: stringValue(record.due_at ?? record.dueAt) || null,
|
||||
risk_level: stringValue(record.risk_level ?? record.riskLevel) || null,
|
||||
sla_status: stringValue(record.sla_status ?? record.slaStatus) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApprovalWorkflowStatus(status: string): string {
|
||||
const normalized = status.toLowerCase();
|
||||
if (["approve", "approved", "accept", "accepted"].includes(normalized)) return "approved";
|
||||
if (["reject", "rejected", "deny", "denied"].includes(normalized)) return "rejected";
|
||||
if (["dismiss", "dismissed", "skip", "skipped"].includes(normalized)) return "dismissed";
|
||||
if (["timeout", "timed_out", "overdue"].includes(normalized)) return "timeout";
|
||||
if (["delegate", "delegated"].includes(normalized)) return "delegated";
|
||||
if (["completed", "done"].includes(normalized)) return "completed";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function isApprovalWorkflowStepOpen(status: string): boolean {
|
||||
return ["pending", "delegated", "timeout"].includes(normalizeApprovalWorkflowStatus(status));
|
||||
}
|
||||
|
||||
function approvalWorkflowParticipants(steps: DigitalEmployeeApprovalWorkflowStep[]): Array<{ participant_id: string; label: string | null }> {
|
||||
const map = new Map<string, string | null>();
|
||||
for (const step of steps) {
|
||||
if (!step.participant_id) continue;
|
||||
map.set(step.participant_id, step.participant_label);
|
||||
}
|
||||
return Array.from(map, ([participant_id, label]) => ({ participant_id, label }));
|
||||
}
|
||||
|
||||
function applyApprovalWorkflowDecision(
|
||||
workflow: DigitalEmployeeApprovalWorkflow,
|
||||
decision: string,
|
||||
actor: string,
|
||||
now: string,
|
||||
): { workflow: DigitalEmployeeApprovalWorkflow; nextStatus: string; step: DigitalEmployeeApprovalWorkflowStep | null; nextStep: DigitalEmployeeApprovalWorkflowStep | null } {
|
||||
const decisionStatus = approvalDecisionStatus(decision);
|
||||
const currentIndex = Math.max(0, workflow.steps.findIndex((step) => step.step_id === workflow.current_step_id));
|
||||
const steps = workflow.steps.map((step) => ({ ...step }));
|
||||
const step = steps[currentIndex] ?? null;
|
||||
if (step) {
|
||||
step.status = decisionStatus;
|
||||
step.decision = decision;
|
||||
step.actor = actor || null;
|
||||
step.decided_at = now;
|
||||
}
|
||||
let nextStep: DigitalEmployeeApprovalWorkflowStep | null = null;
|
||||
let nextStatus = decisionStatus;
|
||||
if (decisionStatus === "approved") {
|
||||
nextStep = steps.slice(currentIndex + 1).find((candidate) => isApprovalWorkflowStepOpen(candidate.status)) ?? null;
|
||||
nextStatus = nextStep ? "pending" : "approved";
|
||||
}
|
||||
const historyEntry = {
|
||||
action: "decision",
|
||||
decision,
|
||||
status: decisionStatus,
|
||||
step_id: step?.step_id ?? null,
|
||||
participant_id: step?.participant_id ?? null,
|
||||
actor: actor || null,
|
||||
occurred_at: now,
|
||||
};
|
||||
return {
|
||||
workflow: {
|
||||
...workflow,
|
||||
status: nextStatus,
|
||||
current_step_id: nextStep?.step_id ?? (nextStatus === "pending" ? step?.step_id ?? null : null),
|
||||
steps,
|
||||
participants: approvalWorkflowParticipants(steps),
|
||||
decision_history: [...workflow.decision_history, historyEntry],
|
||||
updated_at: now,
|
||||
},
|
||||
nextStatus,
|
||||
step,
|
||||
nextStep,
|
||||
};
|
||||
}
|
||||
|
||||
function applyApprovalWorkflowAction(
|
||||
workflow: DigitalEmployeeApprovalWorkflow,
|
||||
action: string,
|
||||
input: DigitalEmployeeApprovalActionInput,
|
||||
now: string,
|
||||
): { workflow: DigitalEmployeeApprovalWorkflow; step: DigitalEmployeeApprovalWorkflowStep | null } {
|
||||
const currentIndex = Math.max(0, workflow.steps.findIndex((step) => step.step_id === workflow.current_step_id));
|
||||
const steps = workflow.steps.map((step) => ({ ...step }));
|
||||
const step = steps[currentIndex] ?? null;
|
||||
if (step) {
|
||||
if (action === "delegate") {
|
||||
step.status = "delegated";
|
||||
step.participant_id = stringValue(input.delegateTo) || step.participant_id;
|
||||
step.participant_label = stringValue(input.delegateLabel) || stringValue(input.delegateTo) || step.participant_label;
|
||||
}
|
||||
if (action === "mark_timeout") {
|
||||
step.sla_status = "timeout";
|
||||
step.due_at = stringValue(input.dueAt) || step.due_at;
|
||||
}
|
||||
if (action === "mark_risk") step.risk_level = stringValue(input.riskLevel) || "high";
|
||||
if (action === "clear_risk") step.risk_level = null;
|
||||
}
|
||||
return {
|
||||
workflow: {
|
||||
...workflow,
|
||||
status: workflow.status === "approved" || workflow.status === "rejected" ? workflow.status : "pending",
|
||||
current_step_id: step?.step_id ?? workflow.current_step_id,
|
||||
steps,
|
||||
participants: approvalWorkflowParticipants(steps),
|
||||
decision_history: [...workflow.decision_history, {
|
||||
action,
|
||||
step_id: step?.step_id ?? null,
|
||||
participant_id: step?.participant_id ?? null,
|
||||
actor: stringValue(input.actor) || null,
|
||||
occurred_at: now,
|
||||
}],
|
||||
updated_at: now,
|
||||
},
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalWorkflowSnapshot(workflow: DigitalEmployeeApprovalWorkflow): Record<string, unknown> {
|
||||
return {
|
||||
workflow_id: workflow.workflow_id,
|
||||
status: workflow.status,
|
||||
current_step_id: workflow.current_step_id,
|
||||
step_count: workflow.steps.length,
|
||||
steps: workflow.steps,
|
||||
participants: workflow.participants,
|
||||
decision_history: workflow.decision_history,
|
||||
updated_at: workflow.updated_at,
|
||||
source: workflow.source ?? null,
|
||||
remote_updated_at: workflow.remote_updated_at ?? null,
|
||||
last_pull_error: workflow.last_pull_error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function payloadWithApprovalWorkflow(
|
||||
payload: unknown,
|
||||
approvalId: string,
|
||||
title: string,
|
||||
now: string,
|
||||
): Record<string, unknown> {
|
||||
const record = objectRecord(payload) ?? { value: payload };
|
||||
const workflow = normalizeDigitalEmployeeApprovalWorkflow(record, approvalId, title, now);
|
||||
return {
|
||||
...record,
|
||||
workflow: approvalWorkflowSnapshot(workflow),
|
||||
current_step_id: workflow.current_step_id,
|
||||
decision_history: workflow.decision_history,
|
||||
};
|
||||
}
|
||||
|
||||
export function readDigitalEmployeeRuntimeRecords(
|
||||
limit = 120,
|
||||
): DigitalEmployeeRuntimeRecords {
|
||||
@@ -1684,6 +1966,28 @@ export function recordDigitalEmployeeApprovalAction(
|
||||
? "approval_action_recorded"
|
||||
: "approval_action_rejected";
|
||||
const eventId = `approval-action:${safeId(approvalId)}:${safeId(action)}:${safeId(now)}`;
|
||||
const existing = db.prepare(`
|
||||
SELECT id, plan_id, task_id, run_id, title, status, payload, created_at
|
||||
FROM digital_approvals
|
||||
WHERE id = ?
|
||||
`).get(approvalId) as Record<string, unknown> | undefined;
|
||||
const existingPayload = objectRecord(parseStoredPayload(existing?.payload));
|
||||
const title = existing ? stringField(existing, "title") || "待确认事项" : "待确认事项";
|
||||
const workflowChange = accepted
|
||||
? action === "approve" || action === "reject"
|
||||
? applyApprovalWorkflowDecision(
|
||||
normalizeDigitalEmployeeApprovalWorkflow(existingPayload ?? {}, approvalId, title, now),
|
||||
action,
|
||||
stringValue(input.actor) || "digital_employee_operator",
|
||||
now,
|
||||
)
|
||||
: applyApprovalWorkflowAction(
|
||||
normalizeDigitalEmployeeApprovalWorkflow(existingPayload ?? {}, approvalId, title, now),
|
||||
action,
|
||||
input,
|
||||
now,
|
||||
)
|
||||
: null;
|
||||
const payload: Record<string, unknown> = {
|
||||
approval_id: approvalId,
|
||||
action,
|
||||
@@ -1698,7 +2002,32 @@ export function recordDigitalEmployeeApprovalAction(
|
||||
plan_id: stringValue(input.planId) || null,
|
||||
task_id: stringValue(input.taskId) || null,
|
||||
run_id: stringValue(input.runId) || null,
|
||||
workflow: workflowChange ? approvalWorkflowSnapshot(workflowChange.workflow) : null,
|
||||
step_id: workflowChange?.step?.step_id ?? null,
|
||||
step_status: workflowChange?.step?.status ?? null,
|
||||
participant_id: workflowChange?.step?.participant_id ?? null,
|
||||
participant_label: workflowChange?.step?.participant_label ?? null,
|
||||
};
|
||||
if (accepted && existing && workflowChange) {
|
||||
const nextPayload = {
|
||||
...(existingPayload ?? {}),
|
||||
workflow: approvalWorkflowSnapshot(workflowChange.workflow),
|
||||
current_step_id: workflowChange.workflow.current_step_id,
|
||||
decision_history: workflowChange.workflow.decision_history,
|
||||
last_action: action,
|
||||
last_action_at: now,
|
||||
};
|
||||
upsertApproval({
|
||||
id: approvalId,
|
||||
planId: stringValue(input.planId) || stringField(existing, "plan_id"),
|
||||
taskId: stringValue(input.taskId) || stringField(existing, "task_id"),
|
||||
runId: stringValue(input.runId) || stringField(existing, "run_id"),
|
||||
title,
|
||||
status: workflowChange.workflow.status,
|
||||
payload: nextPayload,
|
||||
now,
|
||||
});
|
||||
}
|
||||
insertEvent(
|
||||
{
|
||||
event_id: eventId,
|
||||
@@ -1714,6 +2043,46 @@ export function recordDigitalEmployeeApprovalAction(
|
||||
return { eventId, kind, payload };
|
||||
}
|
||||
|
||||
export function upsertDigitalEmployeeApprovalFromRemote(
|
||||
input: DigitalEmployeeApprovalUpdateInput,
|
||||
): { ok: boolean; skipped?: boolean; approvalId?: string; reason?: string } {
|
||||
const db = getDigitalEmployeeDb();
|
||||
if (!db) return { ok: false, reason: "database_unavailable" };
|
||||
const approvalId = stringValue(input.approvalId ?? input.remoteId) || "";
|
||||
if (!approvalId) return { ok: false, reason: "approval_id_required" };
|
||||
const existing = db.prepare(`
|
||||
SELECT id, plan_id, task_id, run_id, title, status, payload, created_at, updated_at
|
||||
FROM digital_approvals
|
||||
WHERE id = ?
|
||||
`).get(approvalId) as Record<string, unknown> | undefined;
|
||||
const remoteUpdatedAt = stringValue(input.updatedAt) || new Date().toISOString();
|
||||
const existingUpdatedAt = existing ? stringField(existing, "updated_at") : "";
|
||||
if (existingUpdatedAt && Date.parse(existingUpdatedAt) > Date.parse(remoteUpdatedAt)) {
|
||||
return { ok: true, skipped: true, approvalId, reason: "local_newer" };
|
||||
}
|
||||
const existingPayload = objectRecord(parseStoredPayload(existing?.payload)) ?? {};
|
||||
const remotePayload = objectRecord(input.payload) ?? {};
|
||||
const title = stringValue(input.title) || (existing ? stringField(existing, "title") : "") || "待确认事项";
|
||||
const payload = {
|
||||
...existingPayload,
|
||||
...remotePayload,
|
||||
source: stringValue(input.source) || stringValue(remotePayload.source) || "management-api",
|
||||
remote_id: stringValue(input.remoteId) || stringValue(remotePayload.remote_id) || null,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
};
|
||||
upsertApproval({
|
||||
id: approvalId,
|
||||
planId: stringValue(input.planId) || (existing ? stringField(existing, "plan_id") : ""),
|
||||
taskId: stringValue(input.taskId) || (existing ? stringField(existing, "task_id") : ""),
|
||||
runId: stringValue(input.runId) || (existing ? stringField(existing, "run_id") : ""),
|
||||
title,
|
||||
status: stringValue(input.status) || (existing ? stringField(existing, "status") : "pending") || "pending",
|
||||
payload,
|
||||
now: remoteUpdatedAt,
|
||||
});
|
||||
return { ok: true, approvalId };
|
||||
}
|
||||
|
||||
export function activeDigitalEmployeePlanStepLease(
|
||||
payload: unknown,
|
||||
now = new Date().toISOString(),
|
||||
@@ -2167,7 +2536,7 @@ function planStepDispatchMessage(status: string, stepId: string, reason: string)
|
||||
}
|
||||
|
||||
function isDigitalEmployeeApprovalAction(action: string): action is DigitalEmployeeApprovalActionKind {
|
||||
return ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk"].includes(action);
|
||||
return ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk", "approve", "reject"].includes(action);
|
||||
}
|
||||
|
||||
function sanitizeApprovalComment(comment: string | null | undefined): Record<string, unknown> | null {
|
||||
@@ -5113,6 +5482,7 @@ function upsertApproval(input: {
|
||||
}): void {
|
||||
const db = getDigitalEmployeeDb();
|
||||
if (!db) return;
|
||||
const payload = payloadWithApprovalWorkflow(input.payload, input.id, input.title, input.now);
|
||||
db.prepare(`
|
||||
INSERT INTO digital_approvals (
|
||||
id, plan_id, task_id, run_id, title, status, payload, sync_status, created_at, updated_at
|
||||
@@ -5136,11 +5506,11 @@ function upsertApproval(input: {
|
||||
input.runId,
|
||||
input.title,
|
||||
input.status,
|
||||
stringify(input.payload),
|
||||
stringify(payload),
|
||||
input.now,
|
||||
input.now,
|
||||
);
|
||||
markOutbox("approval", input.id, "upsert", input, input.now);
|
||||
markOutbox("approval", input.id, "upsert", { ...input, payload }, input.now);
|
||||
}
|
||||
|
||||
function insertEvent(
|
||||
|
||||
@@ -297,6 +297,84 @@ describe("digital employee sync service", () => {
|
||||
expect(result.policy.approval_required_actions).toEqual(["managed_service.restart.fileServer"]);
|
||||
});
|
||||
|
||||
it("pulls remote approval updates into local approval workflow records", async () => {
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
approvals: [
|
||||
{
|
||||
approval_id: "approval-remote-1",
|
||||
title: "高风险动作审批",
|
||||
status: "pending",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
updated_at: "2026-06-07T08:03:00.000Z",
|
||||
payload: {
|
||||
workflow: {
|
||||
workflow_id: "workflow-remote-1",
|
||||
current_step_id: "step-2",
|
||||
steps: [
|
||||
{ step_id: "step-1", label: "主管审批", status: "approved" },
|
||||
{ step_id: "step-2", label: "风控复核", status: "pending", participant_label: "风控" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const { pullDigitalEmployeeApprovalUpdates } = await import("./syncService");
|
||||
const result = await pullDigitalEmployeeApprovalUpdates({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/approvals/updates",
|
||||
applied: 1,
|
||||
ignored: 0,
|
||||
});
|
||||
expect(mockState.fetch).toHaveBeenCalledWith(
|
||||
"https://manage.example.com/api/digital-employee/approvals/updates",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
const approval = mockState.db?.approvals.get("approval-remote-1");
|
||||
expect(approval).toMatchObject({ title: "高风险动作审批", status: "pending", sync_status: "pending" });
|
||||
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({
|
||||
workflow: {
|
||||
workflow_id: "workflow-remote-1",
|
||||
current_step_id: "step-2",
|
||||
},
|
||||
remote_updated_at: "2026-06-07T08:03:00.000Z",
|
||||
});
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "digital_workday_approval_updates_pulled" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("submits approval decisions to management and records submission events", async () => {
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({ success: true, data: { accepted: true } }));
|
||||
|
||||
const { submitDigitalEmployeeApprovalDecision } = await import("./syncService");
|
||||
const result = await submitDigitalEmployeeApprovalDecision({ approval_id: "approval-1", decision: "approved" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/approvals/decisions",
|
||||
submitted_at: "2026-06-07T08:00:00.000Z",
|
||||
});
|
||||
expect(mockState.fetch).toHaveBeenCalledWith(
|
||||
"https://manage.example.com/api/digital-employee/approvals/decisions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining("approval-1"),
|
||||
}),
|
||||
);
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "digital_workday_approval_decision_submitted" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["skillPolicies"],
|
||||
["policies"],
|
||||
@@ -1501,6 +1579,8 @@ interface TestSyncEntityRow {
|
||||
sync_status: string;
|
||||
sync_error: string | null;
|
||||
last_synced_at: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface TestAttemptRow {
|
||||
@@ -1646,6 +1726,11 @@ class TestDb {
|
||||
return this.approvals.get(id);
|
||||
}
|
||||
|
||||
if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload, created_at, updated_at FROM digital_approvals")) {
|
||||
const [id] = args as [string];
|
||||
return this.approvals.get(id);
|
||||
}
|
||||
|
||||
if (sql.startsWith("SELECT key AS title, status, content AS summary, payload FROM digital_memories")) {
|
||||
const [id] = args as [string];
|
||||
return this.memories.get(id);
|
||||
@@ -1817,6 +1902,37 @@ class TestDb {
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_approvals")) {
|
||||
const [id, planId, taskId, runId, title, status, payload, createdAt, updatedAt] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.approvals.get(id);
|
||||
this.approvals.set(id, {
|
||||
id,
|
||||
plan_id: planId,
|
||||
task_id: taskId,
|
||||
run_id: runId,
|
||||
title,
|
||||
status,
|
||||
payload,
|
||||
remote_id: previous?.remote_id ?? null,
|
||||
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||
sync_error: previous?.sync_error ?? null,
|
||||
last_synced_at: previous?.last_synced_at ?? null,
|
||||
created_at: previous?.created_at ?? createdAt,
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
|
||||
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
|
||||
string,
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
normalizeRiskApprovalPolicy,
|
||||
readDigitalEmployeeUiState,
|
||||
saveDigitalEmployeeUiState,
|
||||
upsertDigitalEmployeeApprovalFromRemote,
|
||||
type DigitalEmployeeApprovalUpdateInput,
|
||||
type DigitalEmployeePlanStepDispatchPolicy,
|
||||
type DigitalEmployeeRiskApprovalPolicy,
|
||||
} from "./stateService";
|
||||
@@ -21,6 +23,8 @@ const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
|
||||
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
|
||||
const DEFAULT_SKILL_POLICY_PATH = "/api/digital-employee/policies/skills";
|
||||
const DEFAULT_RISK_APPROVAL_POLICY_PATH = "/api/digital-employee/policies/risk-approval";
|
||||
const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
|
||||
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
|
||||
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
|
||||
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||
"/system/content/content-digital-employee-sync";
|
||||
@@ -43,6 +47,8 @@ let skillPolicyPulling = false;
|
||||
let lastSkillPolicyPullAttemptMs = 0;
|
||||
let riskApprovalPolicyPulling = false;
|
||||
let lastRiskApprovalPolicyPullAttemptMs = 0;
|
||||
let approvalUpdatesPulling = false;
|
||||
let lastApprovalUpdatesPullAttemptMs = 0;
|
||||
|
||||
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||
if (!ensureDigitalEmployeeSchema()) return null;
|
||||
@@ -55,6 +61,8 @@ interface DigitalEmployeeSyncConfig {
|
||||
policyEndpoint: string | null;
|
||||
skillPolicyEndpoint: string | null;
|
||||
riskApprovalPolicyEndpoint: string | null;
|
||||
approvalUpdatesEndpoint: string | null;
|
||||
approvalDecisionsEndpoint: string | null;
|
||||
leaseEndpoint: string | null;
|
||||
clientKey: string | null;
|
||||
authToken: string | null;
|
||||
@@ -88,6 +96,23 @@ export interface DigitalEmployeeRiskApprovalPolicyPullResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeApprovalUpdatesPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint: string | null;
|
||||
pulled_at: string | null;
|
||||
applied: number;
|
||||
ignored: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeApprovalDecisionSubmitResult {
|
||||
ok: boolean;
|
||||
endpoint: string | null;
|
||||
submitted_at: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePlanStepRemoteLease {
|
||||
lease_id: string;
|
||||
state: "active" | "released" | "expired";
|
||||
@@ -440,6 +465,121 @@ export async function pullDigitalEmployeeRiskApprovalPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullDigitalEmployeeApprovalUpdates(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DigitalEmployeeApprovalUpdatesPullResult> {
|
||||
if (approvalUpdatesPulling) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().approvalUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (!options.force && nowMs - lastApprovalUpdatesPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().approvalUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
|
||||
}
|
||||
lastApprovalUpdatesPullAttemptMs = nowMs;
|
||||
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
if (!config.approvalUpdatesEndpoint || missingCredentials.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
endpoint: config.approvalUpdatesEndpoint,
|
||||
pulled_at: new Date().toISOString(),
|
||||
applied: 0,
|
||||
ignored: 0,
|
||||
error: !config.approvalUpdatesEndpoint ? "management approval updates endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
approvalUpdatesPulling = true;
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.approvalUpdatesEndpoint);
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
const updates = readRemoteApprovalUpdates(response);
|
||||
const pulledAt = new Date().toISOString();
|
||||
let applied = 0;
|
||||
let ignored = 0;
|
||||
for (const update of updates) {
|
||||
const result = upsertDigitalEmployeeApprovalFromRemote({ ...update, source: update.source ?? "management-api" });
|
||||
if (result.ok && !result.skipped) applied += 1;
|
||||
else ignored += 1;
|
||||
}
|
||||
recordApprovalSyncEvent("approval_updates_pulled", pulledAt, {
|
||||
action: "pull_approval_updates",
|
||||
endpoint: config.approvalUpdatesEndpoint,
|
||||
ok: true,
|
||||
applied,
|
||||
ignored,
|
||||
source: "management-api",
|
||||
});
|
||||
return { ok: true, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, error: null };
|
||||
} catch (error) {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const message = normalizeError(error);
|
||||
recordApprovalSyncEvent("approval_updates_pull_failed", pulledAt, {
|
||||
action: "pull_approval_updates",
|
||||
endpoint: config.approvalUpdatesEndpoint,
|
||||
ok: false,
|
||||
error: message,
|
||||
source: "management-api",
|
||||
});
|
||||
return { ok: false, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error: message };
|
||||
} finally {
|
||||
approvalUpdatesPulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitDigitalEmployeeApprovalDecision(
|
||||
input: Record<string, unknown>,
|
||||
): Promise<DigitalEmployeeApprovalDecisionSubmitResult> {
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
const submittedAt = new Date().toISOString();
|
||||
if (!config.approvalDecisionsEndpoint || missingCredentials.length > 0) {
|
||||
const error = !config.approvalDecisionsEndpoint ? "management approval decisions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
||||
recordApprovalSyncEvent("approval_decision_submit_failed", submittedAt, {
|
||||
action: "submit_approval_decision",
|
||||
ok: false,
|
||||
endpoint: config.approvalDecisionsEndpoint,
|
||||
error,
|
||||
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
|
||||
decision: stringField(input.decision) || null,
|
||||
source: "qimingclaw-approval-sync",
|
||||
});
|
||||
return { ok: false, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error };
|
||||
}
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.approvalDecisionsEndpoint, {
|
||||
...input,
|
||||
submitted_at: submittedAt,
|
||||
device_id: getDeviceId(),
|
||||
});
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
recordApprovalSyncEvent("approval_decision_submitted", submittedAt, {
|
||||
action: "submit_approval_decision",
|
||||
ok: true,
|
||||
endpoint: config.approvalDecisionsEndpoint,
|
||||
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
|
||||
decision: stringField(input.decision) || null,
|
||||
source: "qimingclaw-approval-sync",
|
||||
});
|
||||
return { ok: true, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error: null };
|
||||
} catch (error) {
|
||||
const message = normalizeError(error);
|
||||
recordApprovalSyncEvent("approval_decision_submit_failed", submittedAt, {
|
||||
action: "submit_approval_decision",
|
||||
ok: false,
|
||||
endpoint: config.approvalDecisionsEndpoint,
|
||||
error: message,
|
||||
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
|
||||
decision: stringField(input.decision) || null,
|
||||
source: "qimingclaw-approval-sync",
|
||||
});
|
||||
return { ok: false, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireDigitalEmployeePlanStepRemoteLease(
|
||||
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
|
||||
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
||||
@@ -654,6 +794,16 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
? config.riskApprovalPolicyEndpoint.trim()
|
||||
: null;
|
||||
const riskApprovalPolicyEndpoint = riskApprovalPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_RISK_APPROVAL_POLICY_PATH);
|
||||
const approvalUpdatesEndpointOverride =
|
||||
typeof config.approvalUpdatesEndpoint === "string" && config.approvalUpdatesEndpoint.trim()
|
||||
? config.approvalUpdatesEndpoint.trim()
|
||||
: null;
|
||||
const approvalUpdatesEndpoint = approvalUpdatesEndpointOverride || buildEndpoint(serverHost, DEFAULT_APPROVAL_UPDATES_PATH);
|
||||
const approvalDecisionsEndpointOverride =
|
||||
typeof config.approvalDecisionsEndpoint === "string" && config.approvalDecisionsEndpoint.trim()
|
||||
? config.approvalDecisionsEndpoint.trim()
|
||||
: null;
|
||||
const approvalDecisionsEndpoint = approvalDecisionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_APPROVAL_DECISIONS_PATH);
|
||||
const leaseEndpointOverride =
|
||||
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
|
||||
? config.planStepDispatchLeaseEndpoint.trim()
|
||||
@@ -671,6 +821,8 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
policyEndpoint,
|
||||
skillPolicyEndpoint,
|
||||
riskApprovalPolicyEndpoint,
|
||||
approvalUpdatesEndpoint,
|
||||
approvalDecisionsEndpoint,
|
||||
leaseEndpoint,
|
||||
clientKey: readClientKey(serverHost),
|
||||
authToken: readAuthToken(serverHost),
|
||||
@@ -832,6 +984,34 @@ function readRemoteRiskApprovalPolicy(response: Record<string, unknown>): Record
|
||||
?? (hasRiskApprovalPolicyFields(data) ? data : null);
|
||||
}
|
||||
|
||||
function readRemoteApprovalUpdates(response: Record<string, unknown>): DigitalEmployeeApprovalUpdateInput[] {
|
||||
const data = objectRecord(response.data) ?? response;
|
||||
const raw = Array.isArray(data.approvals)
|
||||
? data.approvals
|
||||
: Array.isArray(data.updates)
|
||||
? data.updates
|
||||
: Array.isArray(data.items)
|
||||
? data.items
|
||||
: [];
|
||||
return raw
|
||||
.map((item) => objectRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||
.map((item) => ({
|
||||
approvalId: stringField(item.approval_id ?? item.approvalId ?? item.id) || null,
|
||||
remoteId: stringField(item.remote_id ?? item.remoteId) || null,
|
||||
planId: stringField(item.plan_id ?? item.planId) || null,
|
||||
taskId: stringField(item.task_id ?? item.taskId) || null,
|
||||
runId: stringField(item.run_id ?? item.runId) || null,
|
||||
title: stringField(item.title) || null,
|
||||
status: stringField(item.status) || null,
|
||||
payload: objectRecord(item.payload) ?? item,
|
||||
updatedAt: stringField(item.updated_at ?? item.updatedAt ?? item.remote_updated_at ?? item.remoteUpdatedAt) || null,
|
||||
createdAt: stringField(item.created_at ?? item.createdAt) || null,
|
||||
source: stringField(item.source) || "management-api",
|
||||
}))
|
||||
.filter((item) => Boolean(item.approvalId || item.remoteId));
|
||||
}
|
||||
|
||||
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
|
||||
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
|
||||
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
|
||||
@@ -989,6 +1169,15 @@ function recordRiskApprovalPolicyPullFailure(
|
||||
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
|
||||
}
|
||||
|
||||
function recordApprovalSyncEvent(action: string, occurredAt: string, metadata: Record<string, unknown>): void {
|
||||
const state = readDigitalEmployeeUiState();
|
||||
saveDigitalEmployeeUiState({
|
||||
action,
|
||||
metadata: { ...metadata, occurred_at: occurredAt },
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
|
||||
const payload = parsePayload(row.payload);
|
||||
const summary = readEntitySummary(row.entity_type, row.entity_id);
|
||||
@@ -1177,6 +1366,7 @@ function eventSpecificBusinessView(
|
||||
if (kind === "route_decision") return routeDecisionBusinessView(payload, eventId);
|
||||
if (kind === "approval_decision") return approvalDecisionBusinessView(payload);
|
||||
if (kind.startsWith("approval_action_")) return approvalActionBusinessView(payload);
|
||||
if (isApprovalSyncEvent(kind)) return approvalSyncBusinessView(payload);
|
||||
if (isApprovalSubscriptionEvent(kind)) return approvalSubscriptionBusinessView(payload);
|
||||
if (kind.startsWith("artifact_access_") || kind.startsWith("artifact_retention_") || kind === "artifact_version_observed") {
|
||||
return artifactEventBusinessView(payload);
|
||||
@@ -1214,11 +1404,17 @@ function approvalActionBusinessView(payload: Record<string, unknown>): Record<st
|
||||
const delegate = objectRecord(payload.delegate_summary);
|
||||
const sla = objectRecord(payload.sla_summary);
|
||||
const risk = objectRecord(payload.risk_summary);
|
||||
const workflow = objectRecord(payload.workflow) ?? {};
|
||||
return {
|
||||
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
|
||||
action: stringField(payload.action) || null,
|
||||
status: stringField(payload.status) || null,
|
||||
actor: stringField(payload.actor) || null,
|
||||
workflow_id: stringField(workflow.workflow_id ?? workflow.workflowId) || null,
|
||||
step_id: stringField(payload.step_id ?? payload.stepId) || null,
|
||||
step_status: stringField(payload.step_status ?? payload.stepStatus) || null,
|
||||
participant_id: stringField(payload.participant_id ?? payload.participantId) || null,
|
||||
participant_label: stringField(payload.participant_label ?? payload.participantLabel) || null,
|
||||
risk_level: stringField(risk?.risk_level ?? payload.risk_level) || null,
|
||||
due_at: stringField(sla?.due_at ?? payload.due_at) || null,
|
||||
delegate_to: stringField(delegate?.target ?? delegate?.label ?? payload.delegate_to) || null,
|
||||
@@ -1227,12 +1423,20 @@ function approvalActionBusinessView(payload: Record<string, unknown>): Record<st
|
||||
}
|
||||
|
||||
function approvalDecisionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const workflow = objectRecord(payload.workflow) ?? {};
|
||||
return {
|
||||
approval_id: stringField(payload.approvalId ?? payload.approval_id) || null,
|
||||
action: "decision",
|
||||
decision: stringField(payload.decision) || null,
|
||||
previous_status: stringField(payload.previousStatus ?? payload.previous_status) || null,
|
||||
next_status: stringField(payload.nextStatus ?? payload.next_status) || null,
|
||||
workflow_id: stringField(workflow.workflow_id ?? workflow.workflowId) || null,
|
||||
step_id: stringField(payload.step_id ?? payload.stepId) || null,
|
||||
step_status: stringField(payload.step_status ?? payload.stepStatus) || null,
|
||||
current_step_id: stringField(payload.current_step_id ?? payload.currentStepId) || null,
|
||||
next_step_id: stringField(payload.next_step_id ?? payload.nextStepId) || null,
|
||||
participant_id: stringField(payload.participant_id ?? payload.participantId) || null,
|
||||
participant_label: stringField(payload.participant_label ?? payload.participantLabel) || null,
|
||||
actor: stringField(payload.actor ?? payload.source) || null,
|
||||
decided_at: stringField(payload.decidedAt ?? payload.decided_at) || null,
|
||||
plan_id: stringField(payload.planId ?? payload.plan_id) || null,
|
||||
@@ -1241,6 +1445,21 @@ function approvalDecisionBusinessView(payload: Record<string, unknown>): Record<
|
||||
};
|
||||
}
|
||||
|
||||
function approvalSyncBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const metadata = objectRecord(payload.metadata) ?? payload;
|
||||
return {
|
||||
action: stringField(metadata.action ?? payload.action) || null,
|
||||
approval_id: stringField(metadata.approval_id ?? metadata.approvalId) || null,
|
||||
decision: stringField(metadata.decision) || null,
|
||||
source: stringField(metadata.source) || null,
|
||||
endpoint: stringField(metadata.endpoint) || null,
|
||||
ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
|
||||
applied: numberField(metadata.applied),
|
||||
ignored: numberField(metadata.ignored),
|
||||
error: stringField(metadata.error) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalSubscriptionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const metadata = objectRecord(payload.metadata) ?? {};
|
||||
const state = objectRecord(payload.state) ?? {};
|
||||
@@ -1410,6 +1629,7 @@ function eventBusinessCategory(kind: string): string {
|
||||
if (kind === "route_decision") return "route";
|
||||
if (kind.startsWith("approval_")) return "approval";
|
||||
if (kind.startsWith("risk_")) return "approval";
|
||||
if (isApprovalSyncEvent(kind)) return "approval";
|
||||
if (isApprovalSubscriptionEvent(kind)) return "approval";
|
||||
if (kind.startsWith("artifact_")) return "artifact";
|
||||
if (kind.startsWith("skill_call_")) return "skill";
|
||||
@@ -1426,6 +1646,13 @@ function isApprovalSubscriptionEvent(kind: string): boolean {
|
||||
return kind === "digital_workday_update_approval_subscriptions";
|
||||
}
|
||||
|
||||
function isApprovalSyncEvent(kind: string): boolean {
|
||||
return kind === "digital_workday_approval_updates_pulled"
|
||||
|| kind === "digital_workday_approval_updates_pull_failed"
|
||||
|| kind === "digital_workday_approval_decision_submitted"
|
||||
|| kind === "digital_workday_approval_decision_submit_failed";
|
||||
}
|
||||
|
||||
function isPlanStepDependencyEvent(kind: string): boolean {
|
||||
return kind === "plan_step_ready" || kind === "plan_step_blocked" || kind === "plan_step_waiting";
|
||||
}
|
||||
|
||||
@@ -137,6 +137,12 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async pullRiskApprovalPolicy() {
|
||||
return ipcRenderer.invoke("digitalEmployee:pullRiskApprovalPolicy");
|
||||
},
|
||||
async pullApprovalUpdates() {
|
||||
return ipcRenderer.invoke("digitalEmployee:pullApprovalUpdates");
|
||||
},
|
||||
async submitApprovalDecision(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:submitApprovalDecision", input);
|
||||
},
|
||||
async evaluateRiskPolicy(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:evaluateRiskPolicy", input);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user