吸收数字员工多步审批跨端回写

This commit is contained in:
baiyanyun
2026-06-11 10:44:12 +08:00
parent e9c76c59ca
commit 43418d0453
16 changed files with 1240 additions and 200 deletions

View File

@@ -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");

View File

@@ -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(

View File

@@ -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,

View File

@@ -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";
}