From 3bab6af63cd5e5177c027f687addc06d57708e93 Mon Sep 17 00:00:00 2001 From: baiyanyun Date: Mon, 8 Jun 2026 17:10:38 +0800 Subject: [PATCH] feat(client): define digital sync contract --- .../digitalEmployee/syncService.test.ts | 42 ++++++++ .../services/digitalEmployee/syncService.ts | 100 ++++++++++++++++-- ...ital-employee-frontend-integration-plan.md | 15 +++ 3 files changed, 150 insertions(+), 7 deletions(-) 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 2848ed75..997ba89b 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 @@ -89,6 +89,33 @@ describe("digital employee sync service", () => { const { flushDigitalEmployeeSyncOutbox } = await import("./syncService"); const status = await flushDigitalEmployeeSyncOutbox({ force: true }); + expect(lastSyncRequestBody()).toMatchObject({ + client_key: "client-key-001", + device_id: "device-001", + contract_version: "qimingclaw.digital_employee.sync.v1", + items: [ + expect.objectContaining({ + outbox_id: "plan:upsert:plan-1", + entity_type: "plan", + entity_id: "plan-1", + operation: "upsert", + contract_version: "qimingclaw.digital_employee.sync.v1", + entity_summary: { + title: "同步测试计划 plan-1", + status: "pending", + summary: "用于验证同步失败诊断的业务摘要", + }, + business_view: expect.objectContaining({ + entity_type: "plan", + local_id: "plan-1", + title: "同步测试计划 plan-1", + status: "pending", + objective: "用于验证同步失败诊断的业务摘要", + }), + payload: { ok: true }, + }), + ], + }); expect(status.pending).toBe(0); expect(status.failed).toBe(0); expect(readOutbox("plan:upsert:plan-1")).toMatchObject({ @@ -292,6 +319,11 @@ function jsonResponse(body: unknown): Response { } as unknown as Response; } +function lastSyncRequestBody(): Record { + const init = mockState.fetch.mock.calls.at(-1)?.[1] as RequestInit | undefined; + return JSON.parse(String(init?.body ?? "{}")) as Record; +} + interface TestOutboxRow { id: string; entity_type: string; @@ -427,6 +459,16 @@ class TestDb { return this.plans.get(id); } + if (sql.startsWith("SELECT name AS title, kind AS status, uri AS summary, payload FROM digital_artifacts")) { + const [id] = args as [string]; + return this.artifacts.get(id); + } + + if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_approvals")) { + const [id] = args as [string]; + return this.approvals.get(id); + } + if ( sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?") ) { 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 950f8db7..7d142259 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 @@ -6,6 +6,7 @@ import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db"; const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox"; const MANAGEMENT_SYNC_RECORD_PATH = "/system/content/content-digital-employee-sync"; +const DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION = "qimingclaw.digital_employee.sync.v1"; const DEFAULT_INTERVAL_MS = 30_000; const DEFAULT_BATCH_SIZE = 50; const REQUEST_TIMEOUT_MS = 15_000; @@ -357,13 +358,8 @@ async function postOutbox( body: JSON.stringify({ client_key: config.clientKey, device_id: getDeviceId(), - items: rows.map((row) => ({ - outbox_id: row.id, - entity_type: row.entity_type, - entity_id: row.entity_id, - operation: row.operation, - payload: parsePayload(row.payload), - })), + contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION, + items: rows.map(buildSyncItem), }), signal: controller.signal, }); @@ -378,6 +374,90 @@ async function postOutbox( } } +function buildSyncItem(row: OutboxRow): Record { + const payload = parsePayload(row.payload); + const summary = readEntitySummary(row.entity_type, row.entity_id); + return { + outbox_id: row.id, + entity_type: row.entity_type, + entity_id: row.entity_id, + operation: row.operation, + contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION, + entity_summary: summary, + business_view: buildBusinessView(row, payload, summary), + payload, + }; +} + +function buildBusinessView( + row: OutboxRow, + payload: unknown, + summary: DigitalEmployeeSyncEntitySummary | null, +): Record { + const record = objectRecord(payload) ?? {}; + const base = { + entity_type: row.entity_type, + local_id: row.entity_id, + operation: row.operation, + title: stringField(record.title) || summary?.title || null, + status: stringField(record.status) || summary?.status || null, + summary: stringField(record.summary) || summary?.summary || null, + }; + + switch (row.entity_type) { + case "plan": + return { + ...base, + objective: stringField(record.objective) || summary?.summary || null, + source: stringField(record.source) || "qimingclaw", + }; + case "task": + return { + ...base, + plan_id: stringField(record.planId ?? record.plan_id) || null, + assigned_skill_id: stringField(record.assignedSkillId ?? record.assigned_skill_id) || null, + }; + case "run": + return { + ...base, + plan_id: stringField(record.planId ?? record.plan_id) || null, + task_id: stringField(record.taskId ?? record.task_id) || null, + started_at: stringField(record.startedAt ?? record.started_at ?? record.now) || null, + finished_at: stringField(record.finishedAt ?? record.finished_at) || null, + }; + case "event": + return { + ...base, + plan_id: stringField(record.plan_id ?? record.planId) || null, + task_id: stringField(record.task_id ?? record.taskId) || null, + run_id: stringField(record.run_id ?? record.runId) || null, + kind: stringField(record.kind) || summary?.status || null, + message: stringField(record.message) || summary?.summary || null, + occurred_at: stringField(record.occurred_at ?? record.occurredAt) || null, + }; + case "artifact": + return { + ...base, + plan_id: stringField(record.planId ?? record.plan_id) || null, + task_id: stringField(record.taskId ?? record.task_id) || null, + run_id: stringField(record.runId ?? record.run_id) || null, + kind: stringField(record.kind) || summary?.status || null, + name: stringField(record.name) || summary?.title || null, + uri: stringField(record.uri) || summary?.summary || null, + }; + case "approval": + return { + ...base, + plan_id: stringField(record.planId ?? record.plan_id) || null, + task_id: stringField(record.taskId ?? record.task_id) || null, + run_id: stringField(record.runId ?? record.run_id) || null, + decision: stringField(record.decision) || null, + }; + default: + return base; + } +} + function markRowsSyncing(rows: OutboxRow[], now: string): void { const db = getDigitalEmployeeDb(); if (!db) return; @@ -654,6 +734,12 @@ function stringField(value: unknown): string { return typeof value === "string" && value.trim() ? value.trim() : ""; } +function objectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + function readAttemptHistory( outboxId: string, limit = 5, diff --git a/qimingclaw/docs/digital-employee-frontend-integration-plan.md b/qimingclaw/docs/digital-employee-frontend-integration-plan.md index d7c0fc20..f8aba2ff 100644 --- a/qimingclaw/docs/digital-employee-frontend-integration-plan.md +++ b/qimingclaw/docs/digital-employee-frontend-integration-plan.md @@ -577,6 +577,21 @@ sync_error 管理端 API 尚未在 qiming-backend 中确认最终路径,因此当前 qimingclaw 先落本地同步契约,不硬编码远端接口。 +当前同步 item 契约: + +```text +contract_version = qimingclaw.digital_employee.sync.v1 +outbox_id +entity_type +entity_id +operation +entity_summary { title, status, summary } +business_view { entity_type, local_id, title, status, ...typed fields } +payload +``` + +`business_view` 按 Plan / Task / Run / Event / Artifact / Approval 归一常用业务字段,管理端可以先消费该稳定视图;`payload` 保留客户端原始上下文,便于后续扩展和审计。 + ### 产出 - 数字员工页面可以从源码重新构建。