吸收数字员工审批跨端冲突可视化

This commit is contained in:
baiyanyun
2026-06-11 21:53:08 +08:00
parent 84d34236e8
commit e57c94c19d
15 changed files with 625 additions and 208 deletions

View File

@@ -565,6 +565,154 @@ describe("digital employee state service", () => {
});
});
it("keeps local terminal approval status and records remote terminal conflicts", async () => {
const { upsertDigitalEmployeeApprovalFromRemote } = await import("./stateService");
mockState.db?.approvals.set("approval-conflict-1", {
id: "approval-conflict-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "高风险审批",
status: "approved",
payload: JSON.stringify({ workflow: { current_step_id: "step-1", decision_history: [{ decision: "approved" }] } }),
sync_status: "pending",
created_at: "2026-06-07T07:50:00.000Z",
updated_at: "2026-06-07T08:05:00.000Z",
});
const result = upsertDigitalEmployeeApprovalFromRemote({
approvalId: "approval-conflict-1",
title: "高风险审批",
status: "rejected",
payload: {
workflow: { current_step_id: "step-1", decision_history: [{ decision: "rejected" }] },
remote_actor: "management",
},
updatedAt: "2026-06-07T08:06:00.000Z",
source: "management-api",
});
expect(result).toMatchObject({ ok: true, conflict: true, approvalId: "approval-conflict-1", reason: "terminal_status_mismatch" });
const approval = mockState.db?.approvals.get("approval-conflict-1");
expect(approval).toMatchObject({ status: "approved", updated_at: "2026-06-07T08:05:00.000Z" });
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({
conflict: {
active: true,
reason: "terminal_status_mismatch",
local_status: "approved",
remote_status: "rejected",
local_updated_at: "2026-06-07T08:05:00.000Z",
remote_updated_at: "2026-06-07T08:06:00.000Z",
local_current_step_id: "step-1",
remote_current_step_id: "step-1",
remote_snapshot: { status: "rejected", payload: { remote_actor: "management" } },
detected_at: "2026-06-07T08:00:00.000Z",
},
});
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "approval_conflict_detected", plan_id: "plan-1", task_id: "task-1", run_id: "run-1" }),
]));
});
it("records local-newer remote approval conflicts instead of skipping silently", async () => {
const { upsertDigitalEmployeeApprovalFromRemote } = await import("./stateService");
mockState.db?.approvals.set("approval-conflict-local-newer", {
id: "approval-conflict-local-newer",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "本地新审批",
status: "pending",
payload: JSON.stringify({}),
sync_status: "pending",
created_at: "2026-06-07T07:50:00.000Z",
updated_at: "2026-06-07T08:10:00.000Z",
});
const result = upsertDigitalEmployeeApprovalFromRemote({
approvalId: "approval-conflict-local-newer",
status: "approved",
updatedAt: "2026-06-07T08:03:00.000Z",
source: "management-api",
});
expect(result).toMatchObject({ ok: true, conflict: true, reason: "local_newer_status_mismatch" });
const approval = mockState.db?.approvals.get("approval-conflict-local-newer");
expect(approval).toMatchObject({ status: "pending", updated_at: "2026-06-07T08:10:00.000Z" });
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({
conflict: {
active: true,
reason: "local_newer_status_mismatch",
local_status: "pending",
remote_status: "approved",
},
});
});
it("does not mark matching remote approval status as a conflict", async () => {
const { upsertDigitalEmployeeApprovalFromRemote } = await import("./stateService");
mockState.db?.approvals.set("approval-no-conflict", {
id: "approval-no-conflict",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "一致审批",
status: "approved",
payload: JSON.stringify({ conflict: { active: true, reason: "old" } }),
sync_status: "pending",
created_at: "2026-06-07T07:50:00.000Z",
updated_at: "2026-06-07T08:01:00.000Z",
});
const result = upsertDigitalEmployeeApprovalFromRemote({
approvalId: "approval-no-conflict",
status: "approved",
payload: { summary: "远端已一致" },
updatedAt: "2026-06-07T08:03:00.000Z",
source: "management-api",
});
expect(result).toMatchObject({ ok: true, approvalId: "approval-no-conflict" });
expect(result.conflict).toBeUndefined();
const approval = mockState.db?.approvals.get("approval-no-conflict");
expect(approval).toMatchObject({ status: "approved", updated_at: "2026-06-07T08:03:00.000Z" });
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({ conflict: { active: false }, summary: "远端已一致" });
});
it("records workflow step conflicts when decision history exists", async () => {
const { upsertDigitalEmployeeApprovalFromRemote } = await import("./stateService");
mockState.db?.approvals.set("approval-step-conflict", {
id: "approval-step-conflict",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "多步审批",
status: "pending",
payload: JSON.stringify({ workflow: { current_step_id: "step-local", decision_history: [{ step_id: "step-local", decision: "approved" }] } }),
sync_status: "pending",
created_at: "2026-06-07T07:50:00.000Z",
updated_at: "2026-06-07T08:01:00.000Z",
});
const result = upsertDigitalEmployeeApprovalFromRemote({
approvalId: "approval-step-conflict",
status: "pending",
payload: { workflow: { current_step_id: "step-remote", decision_history: [{ step_id: "step-remote", decision: "delegated" }] } },
updatedAt: "2026-06-07T08:04:00.000Z",
source: "management-api",
});
expect(result).toMatchObject({ ok: true, conflict: true, reason: "workflow_step_mismatch" });
expect(JSON.parse(mockState.db?.approvals.get("approval-step-conflict")?.payload ?? "{}")).toMatchObject({
conflict: {
active: true,
reason: "workflow_step_mismatch",
local_current_step_id: "step-local",
remote_current_step_id: "step-remote",
},
});
});
it("records and lists route decisions as syncable events", async () => {
const {
listDigitalEmployeeRouteDecisions,
@@ -2713,7 +2861,7 @@ class TestDb {
if (sql.includes("FROM digital_plan_steps") && sql.includes("WHERE id = ?")) {
return this.planSteps.get(args[0] as string);
}
if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload, created_at FROM digital_approvals")) {
if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload")) {
return this.approvals.get(args[0] as string);
}
if (sql.includes("FROM digital_schedules") && sql.includes("WHERE id = ?")) {

View File

@@ -2690,7 +2690,7 @@ export function recordDigitalEmployeeApprovalAction(
export function upsertDigitalEmployeeApprovalFromRemote(
input: DigitalEmployeeApprovalUpdateInput,
): { ok: boolean; skipped?: boolean; approvalId?: string; reason?: string } {
): { ok: boolean; skipped?: boolean; conflict?: boolean; approvalId?: string; reason?: string } {
const db = getDigitalEmployeeDb();
if (!db) return { ok: false, reason: "database_unavailable" };
const approvalId = stringValue(input.approvalId ?? input.remoteId) || "";
@@ -2702,15 +2702,48 @@ export function upsertDigitalEmployeeApprovalFromRemote(
`).get(approvalId) as Record<string, unknown> | undefined;
const remoteUpdatedAt = stringValue(input.updatedAt) || new Date().toISOString();
const existingUpdatedAt = existing ? stringField(existing, "updated_at") : "";
const existingPayload = objectRecord(parseStoredPayload(existing?.payload)) ?? {};
const remotePayload = objectRecord(input.payload) ?? {};
const remoteStatus = stringValue(input.status) || (existing ? stringField(existing, "status") : "pending") || "pending";
if (existing) {
const conflict = detectApprovalRemoteConflict({
approvalId,
existing,
existingPayload,
remotePayload,
remoteStatus,
remoteUpdatedAt,
source: stringValue(input.source) || stringValue(remotePayload.source) || "management-api",
});
if (conflict) {
const title = stringValue(input.title) || stringField(existing, "title") || "待确认事项";
const now = stringValue(conflict.detected_at) || new Date().toISOString();
const payload = {
...existingPayload,
conflict,
};
upsertApproval({
id: approvalId,
planId: stringField(existing, "plan_id"),
taskId: stringField(existing, "task_id"),
runId: stringField(existing, "run_id"),
title,
status: stringField(existing, "status") || "pending",
payload,
now: existingUpdatedAt || now,
});
insertApprovalConflictEvent(approvalId, title, existing, conflict, now);
return { ok: true, conflict: true, approvalId, reason: stringValue(conflict.reason) || "approval_conflict" };
}
}
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,
...(objectRecord(existingPayload.conflict) ? { conflict: { active: false } } : {}),
source: stringValue(input.source) || stringValue(remotePayload.source) || "management-api",
remote_id: stringValue(input.remoteId) || stringValue(remotePayload.remote_id) || null,
remote_updated_at: remoteUpdatedAt,
@@ -2721,13 +2754,106 @@ export function upsertDigitalEmployeeApprovalFromRemote(
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",
status: remoteStatus,
payload,
now: remoteUpdatedAt,
});
return { ok: true, approvalId };
}
function detectApprovalRemoteConflict(input: {
approvalId: string;
existing: Record<string, unknown>;
existingPayload: Record<string, unknown>;
remotePayload: Record<string, unknown>;
remoteStatus: string;
remoteUpdatedAt: string;
source: string;
}): Record<string, unknown> | null {
const localStatus = normalizeApprovalWorkflowStatus(stringField(input.existing, "status") || "pending");
const remoteStatus = normalizeApprovalWorkflowStatus(input.remoteStatus || "pending");
const localUpdatedAt = stringField(input.existing, "updated_at");
const localCurrentStepId = approvalCurrentStepId(input.existingPayload);
const remoteCurrentStepId = approvalCurrentStepId(input.remotePayload);
let reason = "";
if (isTerminalApprovalStatus(localStatus) && isTerminalApprovalStatus(remoteStatus) && localStatus !== remoteStatus) {
reason = "terminal_status_mismatch";
} else if (isAfter(localUpdatedAt, input.remoteUpdatedAt) && localStatus !== remoteStatus) {
reason = "local_newer_status_mismatch";
} else if (
localCurrentStepId && remoteCurrentStepId && localCurrentStepId !== remoteCurrentStepId &&
(approvalHasDecisionHistory(input.existingPayload) || approvalHasDecisionHistory(input.remotePayload))
) {
reason = "workflow_step_mismatch";
}
if (!reason) return null;
return {
active: true,
reason,
local_status: localStatus,
remote_status: remoteStatus,
local_updated_at: localUpdatedAt || null,
remote_updated_at: input.remoteUpdatedAt || null,
local_current_step_id: localCurrentStepId || null,
remote_current_step_id: remoteCurrentStepId || null,
remote_snapshot: {
approval_id: input.approvalId,
status: remoteStatus,
source: input.source,
updated_at: input.remoteUpdatedAt || null,
payload: input.remotePayload,
},
detected_at: new Date().toISOString(),
};
}
function insertApprovalConflictEvent(
approvalId: string,
title: string,
existing: Record<string, unknown>,
conflict: Record<string, unknown>,
now: string,
): void {
insertEvent(
{
event_id: `approval-conflict:${safeId(approvalId)}:${safeId(stringValue(conflict.reason) || "conflict")}:${safeId(now)}`,
kind: "approval_conflict_detected",
occurred_at: now,
message: `审批跨端冲突:${title}`,
plan_id: stringField(existing, "plan_id") || null,
task_id: stringField(existing, "task_id") || null,
},
stringField(existing, "run_id") || null,
{
source: "qimingclaw-approval-sync",
approval_id: approvalId,
title,
...conflict,
},
);
}
function approvalCurrentStepId(payload: Record<string, unknown>): string {
const workflow = objectRecord(payload.workflow ?? payload.approval_workflow) ?? {};
return stringValue(workflow.current_step_id ?? workflow.currentStepId ?? payload.current_step_id ?? payload.currentStepId);
}
function approvalHasDecisionHistory(payload: Record<string, unknown>): boolean {
const workflow = objectRecord(payload.workflow ?? payload.approval_workflow) ?? {};
const history = workflow.decision_history ?? workflow.decisionHistory ?? payload.decision_history ?? payload.decisionHistory;
return Array.isArray(history) && history.length > 0;
}
function isTerminalApprovalStatus(status: string): boolean {
return ["approved", "rejected", "cancelled"].includes(normalizeApprovalWorkflowStatus(status));
}
function isAfter(left: string, right: string): boolean {
const leftMs = Date.parse(left);
const rightMs = Date.parse(right);
return Number.isFinite(leftMs) && Number.isFinite(rightMs) && leftMs > rightMs;
}
export function activeDigitalEmployeePlanStepLease(
payload: unknown,
now = new Date().toISOString(),

View File

@@ -423,6 +423,59 @@ describe("digital employee sync service", () => {
]));
});
it("counts conflicted remote approval updates and records the pull summary", async () => {
mockState.db?.approvals.set("approval-conflict-1", {
id: "approval-conflict-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "本地已同意审批",
status: "approved",
payload: JSON.stringify({ workflow: { current_step_id: "step-1", decision_history: [{ decision: "approved" }] } }),
remote_id: null,
sync_status: "pending",
sync_error: null,
last_synced_at: null,
created_at: "2026-06-07T07:50:00.000Z",
updated_at: "2026-06-07T08:05:00.000Z",
});
mockState.fetch.mockResolvedValue(jsonResponse({
success: true,
data: {
approvals: [
{
approval_id: "approval-conflict-1",
title: "本地已同意审批",
status: "rejected",
updated_at: "2026-06-07T08:06:00.000Z",
payload: { workflow: { current_step_id: "step-1", decision_history: [{ decision: "rejected" }] } },
},
{
approval_id: "approval-remote-ok",
title: "新审批",
status: "pending",
updated_at: "2026-06-07T08:04:00.000Z",
},
],
},
}));
const { pullDigitalEmployeeApprovalUpdates } = await import("./syncService");
const result = await pullDigitalEmployeeApprovalUpdates({ force: true });
expect(result).toMatchObject({ ok: true, applied: 1, ignored: 0, conflicted: 1 });
expect(mockState.db?.approvals.get("approval-conflict-1")).toMatchObject({ status: "approved" });
const pulledEvent = Array.from(mockState.db?.events.values() ?? [])
.find((event) => event.kind === "digital_workday_approval_updates_pulled");
expect(JSON.parse(pulledEvent?.payload ?? "{}")).toMatchObject({
metadata: {
applied: 1,
ignored: 0,
conflicted: 1,
},
});
});
it("submits approval decisions to management and records submission events", async () => {
mockState.fetch.mockResolvedValue(jsonResponse({ success: true, data: { accepted: true } }));

View File

@@ -124,6 +124,7 @@ export interface DigitalEmployeeApprovalUpdatesPullResult {
pulled_at: string | null;
applied: number;
ignored: number;
conflicted?: number;
error?: string | null;
}
@@ -594,9 +595,11 @@ export async function pullDigitalEmployeeApprovalUpdates(
const pulledAt = new Date().toISOString();
let applied = 0;
let ignored = 0;
let conflicted = 0;
for (const update of updates) {
const result = upsertDigitalEmployeeApprovalFromRemote({ ...update, source: update.source ?? "management-api" });
if (result.ok && !result.skipped) applied += 1;
if (result.ok && result.conflict) conflicted += 1;
else if (result.ok && !result.skipped) applied += 1;
else ignored += 1;
}
recordApprovalSyncEvent("approval_updates_pulled", pulledAt, {
@@ -605,9 +608,10 @@ export async function pullDigitalEmployeeApprovalUpdates(
ok: true,
applied,
ignored,
conflicted,
source: "management-api",
});
return { ok: true, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, error: null };
return { ok: true, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, conflicted, error: null };
} catch (error) {
const pulledAt = new Date().toISOString();
const message = normalizeError(error);