diff --git a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.test.ts b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.test.ts index 997ba89b..16273f1e 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.test.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.test.ts @@ -139,6 +139,80 @@ describe("digital employee sync service", () => { expect(mockState.ensureSchemaCalls).toBeGreaterThan(0); }); + it("accepts backend ReqResult acknowledgements", async () => { + insertPlan("plan-reqresult-ok"); + insertOutbox("plan:upsert:plan-reqresult-ok", "plan", "plan-reqresult-ok"); + mockState.fetch.mockResolvedValue( + jsonResponse({ + code: "0000", + message: "success", + data: { + acked: [ + { + outbox_id: "plan:upsert:plan-reqresult-ok", + entity_type: "plan", + entity_id: "plan-reqresult-ok", + remote_id: "remote-plan-reqresult-ok", + }, + ], + }, + }), + ); + + const { flushDigitalEmployeeSyncOutbox } = await import("./syncService"); + const status = await flushDigitalEmployeeSyncOutbox({ force: true }); + + expect(status.lastSyncError).toBeNull(); + expect(status.pending).toBe(0); + expect(status.failed).toBe(0); + expect(readOutbox("plan:upsert:plan-reqresult-ok")).toMatchObject({ + status: "synced", + error: null, + attempts: 1, + }); + expect(readPlan("plan-reqresult-ok")).toMatchObject({ + sync_status: "synced", + remote_id: "remote-plan-reqresult-ok", + sync_error: null, + }); + }); + + it("marks backend ReqResult business failures as failed", async () => { + insertPlan("plan-rejected"); + insertOutbox("plan:upsert:plan-rejected", "plan", "plan-rejected"); + mockState.fetch.mockResolvedValue( + jsonResponse({ + code: "0001", + message: "客户端 Key header 缺失", + data: null, + }), + ); + + const { flushDigitalEmployeeSyncOutbox } = await import("./syncService"); + const status = await flushDigitalEmployeeSyncOutbox({ force: true }); + + expect(status.lastSyncError).toBe("客户端 Key header 缺失"); + expect(status.pending).toBe(0); + expect(status.failed).toBe(1); + expect(readOutbox("plan:upsert:plan-rejected")).toMatchObject({ + status: "failed", + error: "客户端 Key header 缺失", + attempts: 1, + }); + expect(readPlan("plan-rejected")).toMatchObject({ + sync_status: "failed", + sync_error: "客户端 Key header 缺失", + }); + expect(readAttempts()).toEqual([ + expect.objectContaining({ + outbox_id: "plan:upsert:plan-rejected", + attempt_no: 1, + status: "failed", + error: "客户端 Key header 缺失", + }), + ]); + }); + it("keeps unacknowledged rows retryable after a partial backend ack", async () => { insertPlan("plan-1"); insertPlan("plan-2"); diff --git a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.ts b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.ts index 7d142259..38cb9045 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.ts @@ -96,6 +96,8 @@ interface SyncAck { interface SyncResponse { success?: boolean; + code?: string | number; + status?: string | number; acked?: SyncAck[]; data?: { acked?: SyncAck[]; @@ -185,9 +187,8 @@ export async function flushDigitalEmployeeSyncOutbox( const response = await postOutbox(config, rows); const finishedAt = new Date().toISOString(); const acked = response.data?.acked || response.acked || []; - if (response.success === false) { - throw new Error(response.message || "digital employee sync rejected"); - } + const responseError = readSyncResponseError(response); + if (responseError) throw new Error(responseError); const syncedRows = acked.length > 0 ? rows.filter((row) => acked.some((ack) => ackMatchesRow(ack, row))) : rows; @@ -236,6 +237,35 @@ export async function flushDigitalEmployeeSyncOutbox( return getDigitalEmployeeSyncStatus(); } +function readSyncResponseError(response: SyncResponse): string | null { + if (response.success === false) { + return response.message || "digital employee sync rejected"; + } + + const code = normalizeResponseCode(response.code); + if (code && code !== "0000") { + return response.message || `digital employee sync rejected: ${code}`; + } + + const status = normalizeResponseCode(response.status); + if (status && ["error", "fail", "failed", "failure"].includes(status.toLowerCase())) { + return response.message || "digital employee sync rejected"; + } + + return null; +} + +function normalizeResponseCode(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || null; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return null; +} + function resolveSyncConfig(): DigitalEmployeeSyncConfig { const config = (readSetting("digital_employee_sync_config") || {}) as Record< string,