feat(client): define digital sync contract
This commit is contained in:
@@ -89,6 +89,33 @@ describe("digital employee sync service", () => {
|
|||||||
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
||||||
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
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.pending).toBe(0);
|
||||||
expect(status.failed).toBe(0);
|
expect(status.failed).toBe(0);
|
||||||
expect(readOutbox("plan:upsert:plan-1")).toMatchObject({
|
expect(readOutbox("plan:upsert:plan-1")).toMatchObject({
|
||||||
@@ -292,6 +319,11 @@ function jsonResponse(body: unknown): Response {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lastSyncRequestBody(): Record<string, unknown> {
|
||||||
|
const init = mockState.fetch.mock.calls.at(-1)?.[1] as RequestInit | undefined;
|
||||||
|
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
interface TestOutboxRow {
|
interface TestOutboxRow {
|
||||||
id: string;
|
id: string;
|
||||||
entity_type: string;
|
entity_type: string;
|
||||||
@@ -427,6 +459,16 @@ class TestDb {
|
|||||||
return this.plans.get(id);
|
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 (
|
if (
|
||||||
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
|
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db";
|
|||||||
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
|
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
|
||||||
const MANAGEMENT_SYNC_RECORD_PATH =
|
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||||
"/system/content/content-digital-employee-sync";
|
"/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_INTERVAL_MS = 30_000;
|
||||||
const DEFAULT_BATCH_SIZE = 50;
|
const DEFAULT_BATCH_SIZE = 50;
|
||||||
const REQUEST_TIMEOUT_MS = 15_000;
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
@@ -357,13 +358,8 @@ async function postOutbox(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_key: config.clientKey,
|
client_key: config.clientKey,
|
||||||
device_id: getDeviceId(),
|
device_id: getDeviceId(),
|
||||||
items: rows.map((row) => ({
|
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
|
||||||
outbox_id: row.id,
|
items: rows.map(buildSyncItem),
|
||||||
entity_type: row.entity_type,
|
|
||||||
entity_id: row.entity_id,
|
|
||||||
operation: row.operation,
|
|
||||||
payload: parsePayload(row.payload),
|
|
||||||
})),
|
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
@@ -378,6 +374,90 @@ async function postOutbox(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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 {
|
function markRowsSyncing(rows: OutboxRow[], now: string): void {
|
||||||
const db = getDigitalEmployeeDb();
|
const db = getDigitalEmployeeDb();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
@@ -654,6 +734,12 @@ function stringField(value: unknown): string {
|
|||||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function objectRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function readAttemptHistory(
|
function readAttemptHistory(
|
||||||
outboxId: string,
|
outboxId: string,
|
||||||
limit = 5,
|
limit = 5,
|
||||||
|
|||||||
@@ -577,6 +577,21 @@ sync_error
|
|||||||
|
|
||||||
管理端 API 尚未在 qiming-backend 中确认最终路径,因此当前 qimingclaw 先落本地同步契约,不硬编码远端接口。
|
管理端 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` 保留客户端原始上下文,便于后续扩展和审计。
|
||||||
|
|
||||||
### 产出
|
### 产出
|
||||||
|
|
||||||
- 数字员工页面可以从源码重新构建。
|
- 数字员工页面可以从源码重新构建。
|
||||||
|
|||||||
Reference in New Issue
Block a user