吸收数字员工多步审批跨端回写
This commit is contained in:
@@ -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