2775 lines
95 KiB
TypeScript
2775 lines
95 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockState = vi.hoisted(() => ({
|
|
db: null as TestDb | null,
|
|
ensureSchemaCalls: 0,
|
|
settings: new Map<string, unknown>(),
|
|
fetch: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("electron-log", () => ({
|
|
default: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../system/deviceId", () => ({
|
|
getDeviceId: vi.fn(() => "device-001"),
|
|
}));
|
|
|
|
vi.mock("../../db", () => ({
|
|
ensureDigitalEmployeeSchema: () => {
|
|
mockState.ensureSchemaCalls += 1;
|
|
return true;
|
|
},
|
|
getDb: () => mockState.db,
|
|
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
|
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
|
}));
|
|
|
|
describe("digital employee sync service", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
|
|
mockState.fetch = vi.fn();
|
|
mockState.ensureSchemaCalls = 0;
|
|
vi.stubGlobal("fetch", mockState.fetch);
|
|
mockState.settings = new Map<string, unknown>([
|
|
["step1_config", { serverHost: "https://manage.example.com" }],
|
|
["auth.username", "operator"],
|
|
["auth.saved_key", "client-key-001"],
|
|
["auth.tokens.manage.example.com", "login-token-001"],
|
|
]);
|
|
mockState.db = new TestDb();
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockState.db = null;
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("skips flushing when management credentials are incomplete", async () => {
|
|
mockState.settings.delete("auth.saved_key");
|
|
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
|
|
expect(mockState.fetch).not.toHaveBeenCalled();
|
|
expect(status.missingCredentials).toEqual(["客户端 Key"]);
|
|
expect(status.pending).toBe(1);
|
|
expect(status.failed).toBe(0);
|
|
expect(readOutbox("plan:upsert:plan-1")).toMatchObject({
|
|
status: "pending",
|
|
attempts: 0,
|
|
});
|
|
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("pulls remote plan step dispatch policy and records policy state", async () => {
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
data: {
|
|
plan_step_dispatch_policy: {
|
|
enabled: true,
|
|
max_per_sweep: 5,
|
|
auto_dispatch_ready_steps: false,
|
|
updated_at: "2026-06-07T08:01:00.000Z",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const { pullDigitalEmployeePlanStepDispatchPolicy } = await import("./syncService");
|
|
const { readDigitalEmployeeUiState } = await import("./stateService");
|
|
const result = await pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
|
policy: {
|
|
source: "remote",
|
|
enabled: true,
|
|
max_per_sweep: 5,
|
|
auto_dispatch_ready_steps: false,
|
|
remote_updated_at: "2026-06-07T08:01:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
|
last_pull_error: null,
|
|
},
|
|
});
|
|
expect(mockState.fetch).toHaveBeenCalledWith(
|
|
"https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
|
expect.objectContaining({ method: "GET" }),
|
|
);
|
|
expect(readDigitalEmployeeUiState().planStepDispatchPolicy).toMatchObject(result.policy);
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "digital_workday_pull_plan_step_dispatch_policy" }),
|
|
]));
|
|
});
|
|
|
|
it("keeps the current plan step dispatch policy when remote policy pull fails", async () => {
|
|
mockState.fetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 503,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "policy service unavailable" })),
|
|
} as unknown as Response);
|
|
|
|
const { pullDigitalEmployeePlanStepDispatchPolicy } = await import("./syncService");
|
|
const result = await pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: "policy service unavailable",
|
|
policy: {
|
|
source: "local",
|
|
enabled: true,
|
|
max_per_sweep: 3,
|
|
auto_dispatch_ready_steps: true,
|
|
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
|
last_pull_error: "policy service unavailable",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("pulls remote route decision policy and records policy state", async () => {
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
data: {
|
|
routeDecisionPolicy: {
|
|
enabled: true,
|
|
autoCreateGovernanceCommands: false,
|
|
approvalRequiredIntents: ["Execute"],
|
|
blockedIntents: ["Schedule"],
|
|
preferredRouteKinds: ["PlanExecution"],
|
|
updatedAt: "2026-06-07T08:02:00.000Z",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const { pullDigitalEmployeeRouteDecisionPolicy } = await import("./syncService");
|
|
const { readDigitalEmployeeUiState } = await import("./stateService");
|
|
const result = await pullDigitalEmployeeRouteDecisionPolicy({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
endpoint: "https://manage.example.com/api/digital-employee/policies/route-decision",
|
|
policy: {
|
|
source: "remote",
|
|
enabled: true,
|
|
auto_create_governance_commands: false,
|
|
approval_required_intents: ["execute"],
|
|
blocked_intents: ["schedule"],
|
|
preferred_route_kinds: ["PlanExecution"],
|
|
remote_updated_at: "2026-06-07T08:02:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
|
last_pull_error: null,
|
|
},
|
|
});
|
|
expect(mockState.fetch).toHaveBeenCalledWith(
|
|
"https://manage.example.com/api/digital-employee/policies/route-decision",
|
|
expect.objectContaining({ method: "GET" }),
|
|
);
|
|
expect(readDigitalEmployeeUiState().routeDecisionPolicy).toMatchObject(result.policy);
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "digital_workday_pull_route_decision_policy" }),
|
|
]));
|
|
});
|
|
|
|
it("keeps the current route decision policy when remote policy pull fails", async () => {
|
|
mockState.fetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 503,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "route policy unavailable" })),
|
|
} as unknown as Response);
|
|
|
|
const { pullDigitalEmployeeRouteDecisionPolicy } = await import("./syncService");
|
|
const result = await pullDigitalEmployeeRouteDecisionPolicy({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: "route policy unavailable",
|
|
policy: {
|
|
source: "local",
|
|
enabled: true,
|
|
auto_create_governance_commands: true,
|
|
blocked_intents: [],
|
|
approval_required_intents: [],
|
|
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
|
last_pull_error: "route policy unavailable",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("pulls remote skill policies and preserves local skill preferences", async () => {
|
|
mockState.settings.set("digital_employee_skill_policies_v1", {
|
|
version: 1,
|
|
local_skills: {
|
|
"qimingclaw-acp-session": { enabled: false, updatedAt: "2026-06-07T07:00:00.000Z", source: "local" },
|
|
},
|
|
});
|
|
mockState.fetch.mockResolvedValue(jsonResponse({
|
|
success: true,
|
|
data: {
|
|
skill_policies: {
|
|
updated_at: "2026-06-07T08:01:00.000Z",
|
|
skills: {
|
|
"qimingclaw-mcp-tool-crm-crm-delete": {
|
|
enabled: false,
|
|
updated_at: "2026-06-07T08:01:00.000Z",
|
|
reason: "management_disabled",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
|
|
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
endpoint: "https://manage.example.com/api/digital-employee/policies/skills",
|
|
policies: {
|
|
source: "remote",
|
|
remote_updated_at: "2026-06-07T08:01:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
|
last_pull_error: null,
|
|
local_skills: {
|
|
"qimingclaw-acp-session": expect.objectContaining({ enabled: false, source: "local" }),
|
|
},
|
|
remote_skills: {
|
|
"qimingclaw-mcp-tool-crm-crm-delete": expect.objectContaining({ enabled: false, source: "remote", reason: "management_disabled" }),
|
|
},
|
|
},
|
|
});
|
|
expect(mockState.fetch).toHaveBeenCalledWith(
|
|
"https://manage.example.com/api/digital-employee/policies/skills",
|
|
expect.objectContaining({ method: "GET" }),
|
|
);
|
|
expect(mockState.settings.get("digital_employee_skill_policies_v1")).toMatchObject(result.policies);
|
|
});
|
|
|
|
it("keeps current skill policies when remote skill policy pull fails", async () => {
|
|
mockState.settings.set("digital_employee_skill_policies_v1", {
|
|
version: 1,
|
|
remote_skills: {
|
|
"qimingclaw-computer-control": { enabled: false, updatedAt: "2026-06-07T07:30:00.000Z", source: "remote" },
|
|
},
|
|
});
|
|
mockState.fetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 503,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "skill policy service unavailable" })),
|
|
} as unknown as Response);
|
|
|
|
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
|
|
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: "skill policy service unavailable",
|
|
policies: {
|
|
remote_skills: {
|
|
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "remote" }),
|
|
},
|
|
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
|
last_pull_error: "skill policy service unavailable",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("pulls remote risk approval policy and records UI state", async () => {
|
|
mockState.fetch.mockResolvedValue(jsonResponse({
|
|
success: true,
|
|
data: {
|
|
risk_approval_policy: {
|
|
enabled: true,
|
|
approval_required_risk_levels: ["critical"],
|
|
approval_required_actions: ["governance.run_schedule_now"],
|
|
auto_reject_actions: ["artifact.access.open_location"],
|
|
updated_at: "2026-06-07T08:02:00.000Z",
|
|
},
|
|
},
|
|
}));
|
|
|
|
const { pullDigitalEmployeeRiskApprovalPolicy } = await import("./syncService");
|
|
const { readDigitalEmployeeUiState } = await import("./stateService");
|
|
const result = await pullDigitalEmployeeRiskApprovalPolicy({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
endpoint: "https://manage.example.com/api/digital-employee/policies/risk-approval",
|
|
policy: {
|
|
source: "remote",
|
|
approval_required_risk_levels: ["critical"],
|
|
approval_required_actions: ["governance.run_schedule_now"],
|
|
auto_reject_actions: ["artifact.access.open_location"],
|
|
remote_updated_at: "2026-06-07T08:02:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
|
last_pull_error: null,
|
|
},
|
|
});
|
|
expect(mockState.fetch).toHaveBeenCalledWith(
|
|
"https://manage.example.com/api/digital-employee/policies/risk-approval",
|
|
expect.objectContaining({ method: "GET" }),
|
|
);
|
|
expect(readDigitalEmployeeUiState().riskApprovalPolicy).toMatchObject(result.policy);
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "digital_workday_pull_risk_approval_policy" }),
|
|
]));
|
|
});
|
|
|
|
it("keeps current risk approval policy when pull fails", async () => {
|
|
mockState.fetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 503,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "risk policy unavailable" })),
|
|
} as unknown as Response);
|
|
|
|
const { pullDigitalEmployeeRiskApprovalPolicy } = await import("./syncService");
|
|
const result = await pullDigitalEmployeeRiskApprovalPolicy({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: "risk policy unavailable",
|
|
policy: {
|
|
source: "local",
|
|
approval_required_risk_levels: ["high", "critical"],
|
|
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
|
last_pull_error: "risk policy unavailable",
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["riskApprovalPolicy"],
|
|
["policy"],
|
|
])("pulls remote risk approval policy from data.%s", async (fieldName) => {
|
|
mockState.fetch.mockResolvedValue(jsonResponse({
|
|
success: true,
|
|
data: {
|
|
[fieldName]: {
|
|
approvalRequiredActions: ["managed_service.restart.fileServer"],
|
|
},
|
|
},
|
|
}));
|
|
|
|
const { pullDigitalEmployeeRiskApprovalPolicy } = await import("./syncService");
|
|
const result = await pullDigitalEmployeeRiskApprovalPolicy({ force: true });
|
|
|
|
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("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 } }));
|
|
|
|
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("pulls remote notification updates into local notification overlays", async () => {
|
|
mockState.settings.set("digital_employee_ui_state_v1", {
|
|
notificationStateById: {
|
|
"task:existing": { status: "read", read_at: "2026-06-07T07:30:00.000Z" },
|
|
},
|
|
});
|
|
mockState.fetch.mockResolvedValue(jsonResponse({
|
|
success: true,
|
|
data: {
|
|
notifications: [
|
|
{
|
|
notification_id: "task:need-input:1",
|
|
status: "read",
|
|
read_at: "2026-06-07T08:01:00.000Z",
|
|
remote_updated_at: "2026-06-07T08:02:00.000Z",
|
|
source: "management-inbox",
|
|
},
|
|
{
|
|
notificationId: "task:reply:1",
|
|
status: "resolved",
|
|
reply: "已补充客户编号",
|
|
repliedAt: "2026-06-07T08:03:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
}));
|
|
|
|
const { pullDigitalEmployeeNotificationUpdates } = await import("./syncService");
|
|
const { readDigitalEmployeeUiState } = await import("./stateService");
|
|
const result = await pullDigitalEmployeeNotificationUpdates({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
endpoint: "https://manage.example.com/api/digital-employee/notifications/updates",
|
|
applied: 2,
|
|
ignored: 0,
|
|
pulled_at: "2026-06-07T08:00:00.000Z",
|
|
});
|
|
expect(mockState.fetch).toHaveBeenCalledWith(
|
|
"https://manage.example.com/api/digital-employee/notifications/updates",
|
|
expect.objectContaining({ method: "GET" }),
|
|
);
|
|
expect(readDigitalEmployeeUiState().notificationStateById).toMatchObject({
|
|
"task:existing": { status: "read", read_at: "2026-06-07T07:30:00.000Z" },
|
|
"task:need-input:1": {
|
|
status: "read",
|
|
read_at: "2026-06-07T08:01:00.000Z",
|
|
remote_updated_at: "2026-06-07T08:02:00.000Z",
|
|
source: "management-inbox",
|
|
},
|
|
"task:reply:1": {
|
|
status: "resolved",
|
|
reply: "已补充客户编号",
|
|
replied_at: "2026-06-07T08:03:00.000Z",
|
|
source: "management-api",
|
|
},
|
|
});
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "digital_workday_notification_updates_pulled" }),
|
|
]));
|
|
});
|
|
|
|
it("keeps local notification overlays when remote notification update pull fails", async () => {
|
|
mockState.settings.set("digital_employee_ui_state_v1", {
|
|
notificationStateById: {
|
|
"task:existing": { status: "read", read_at: "2026-06-07T07:30:00.000Z" },
|
|
},
|
|
});
|
|
mockState.fetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 503,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "notification service unavailable" })),
|
|
} as unknown as Response);
|
|
|
|
const { pullDigitalEmployeeNotificationUpdates } = await import("./syncService");
|
|
const { readDigitalEmployeeUiState } = await import("./stateService");
|
|
const result = await pullDigitalEmployeeNotificationUpdates({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
endpoint: "https://manage.example.com/api/digital-employee/notifications/updates",
|
|
applied: 0,
|
|
ignored: 0,
|
|
error: "notification service unavailable",
|
|
});
|
|
expect(readDigitalEmployeeUiState().notificationStateById).toMatchObject({
|
|
"task:existing": { status: "read", read_at: "2026-06-07T07:30:00.000Z" },
|
|
});
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "digital_workday_notification_updates_pull_failed" }),
|
|
]));
|
|
});
|
|
|
|
it("submits notification actions to management and records submission events", async () => {
|
|
mockState.fetch.mockResolvedValue(jsonResponse({ success: true, data: { accepted: true } }));
|
|
|
|
const { submitDigitalEmployeeNotificationAction } = await import("./syncService");
|
|
const result = await submitDigitalEmployeeNotificationAction({
|
|
notification_id: "task:need-input:1",
|
|
action: "reply",
|
|
status: "read",
|
|
reply: "已补充客户编号",
|
|
replied_at: "2026-06-07T08:03:00.000Z",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
endpoint: "https://manage.example.com/api/digital-employee/notifications/actions",
|
|
submitted_at: "2026-06-07T08:00:00.000Z",
|
|
});
|
|
expect(mockState.fetch).toHaveBeenCalledWith(
|
|
"https://manage.example.com/api/digital-employee/notifications/actions",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: expect.stringContaining("task:need-input:1"),
|
|
}),
|
|
);
|
|
expect(lastSyncRequestBody()).toMatchObject({
|
|
notification_id: "task:need-input:1",
|
|
action: "reply",
|
|
status: "read",
|
|
reply: "已补充客户编号",
|
|
submitted_at: "2026-06-07T08:00:00.000Z",
|
|
device_id: "device-001",
|
|
});
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "digital_workday_notification_action_submitted" }),
|
|
]));
|
|
});
|
|
|
|
it("records notification action submit failures without throwing", async () => {
|
|
mockState.fetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "notification action rejected" })),
|
|
} as unknown as Response);
|
|
|
|
const { submitDigitalEmployeeNotificationAction } = await import("./syncService");
|
|
const result = await submitDigitalEmployeeNotificationAction({ notification_id: "task:need-input:1", action: "read" });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
endpoint: "https://manage.example.com/api/digital-employee/notifications/actions",
|
|
submitted_at: "2026-06-07T08:00:00.000Z",
|
|
error: "notification action rejected",
|
|
});
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "digital_workday_notification_action_submit_failed" }),
|
|
]));
|
|
});
|
|
|
|
it("pulls low-risk admin actions, applies them locally, and reports results", async () => {
|
|
mockState.fetch
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
success: true,
|
|
data: {
|
|
records: [
|
|
{
|
|
id: 10,
|
|
entity_type: "artifact",
|
|
entity_id: "artifact-1",
|
|
action: "mark_expire",
|
|
request_payload: { reason: "管理端标记到期" },
|
|
},
|
|
],
|
|
},
|
|
}))
|
|
.mockResolvedValueOnce(jsonResponse({ success: true, data: { ok: true } }));
|
|
|
|
const { pullAndApplyDigitalEmployeeAdminActions } = await import("./syncService");
|
|
const result = await pullAndApplyDigitalEmployeeAdminActions({ force: true });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
endpoint: "https://manage.example.com/api/digital-employee/admin/actions/pending?device_id=device-001",
|
|
applied: 1,
|
|
rejected: 0,
|
|
failed: 0,
|
|
ignored: 0,
|
|
});
|
|
expect(mockState.fetch).toHaveBeenNthCalledWith(
|
|
1,
|
|
"https://manage.example.com/api/digital-employee/admin/actions/pending?device_id=device-001",
|
|
expect.objectContaining({ method: "GET" }),
|
|
);
|
|
expect(mockState.fetch).toHaveBeenNthCalledWith(
|
|
2,
|
|
"https://manage.example.com/api/digital-employee/admin/actions/10/result",
|
|
expect.objectContaining({ method: "POST", body: expect.stringContaining("admin_action_applied") }),
|
|
);
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "artifact_retention_marked" }),
|
|
expect.objectContaining({ kind: "admin_action_applied" }),
|
|
]));
|
|
});
|
|
|
|
it("rejects high-risk admin actions without changing local facts", async () => {
|
|
mockState.fetch
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
success: true,
|
|
data: { records: [{ id: 11, entity_type: "artifact", entity_id: "artifact-1", action: "cleanup_file_batch" }] },
|
|
}))
|
|
.mockResolvedValueOnce(jsonResponse({ success: true, data: { ok: true } }));
|
|
|
|
const { pullAndApplyDigitalEmployeeAdminActions } = await import("./syncService");
|
|
const result = await pullAndApplyDigitalEmployeeAdminActions({ force: true });
|
|
|
|
expect(result).toMatchObject({ ok: true, applied: 0, rejected: 1, failed: 0 });
|
|
expect(mockState.fetch).toHaveBeenNthCalledWith(
|
|
2,
|
|
"https://manage.example.com/api/digital-employee/admin/actions/11/result",
|
|
expect.objectContaining({ method: "POST", body: expect.stringContaining("admin_action_rejected") }),
|
|
);
|
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "admin_action_rejected" }),
|
|
]));
|
|
});
|
|
|
|
it.each([
|
|
["skillPolicies"],
|
|
["policies"],
|
|
])("pulls remote skill policies from data.%s", async (fieldName) => {
|
|
mockState.fetch.mockResolvedValue(jsonResponse({
|
|
success: true,
|
|
data: {
|
|
[fieldName]: {
|
|
"qimingclaw-acp-session": false,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
|
|
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
|
|
|
|
expect(result.policies.remote_skills["qimingclaw-acp-session"]).toEqual(expect.objectContaining({
|
|
enabled: false,
|
|
source: "remote",
|
|
}));
|
|
});
|
|
|
|
it("acquires a remote plan step dispatch lease through the management endpoint", async () => {
|
|
mockState.fetch.mockResolvedValue(jsonResponse({
|
|
ok: true,
|
|
lease: {
|
|
lease_id: "remote-lease-1",
|
|
owner_device_id: "device-001",
|
|
acquired_at: "2026-06-07T08:00:00.000Z",
|
|
expires_at: "2026-06-07T08:05:00.000Z",
|
|
},
|
|
}));
|
|
|
|
const { acquireDigitalEmployeePlanStepRemoteLease } = await import("./syncService");
|
|
const result = await acquireDigitalEmployeePlanStepRemoteLease({
|
|
planId: "plan-1",
|
|
stepId: "step-1",
|
|
taskId: "task-1",
|
|
triggerSource: "auto",
|
|
ttlMs: 300_000,
|
|
occurredAt: "2026-06-07T08:00:00.000Z",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
endpoint: "https://manage.example.com/api/digital-employee/leases/plan-step-dispatch/acquire",
|
|
lease: {
|
|
lease_id: "remote-lease-1",
|
|
remote_lease_id: "remote-lease-1",
|
|
owner_device_id: "device-001",
|
|
},
|
|
});
|
|
expect(mockState.fetch).toHaveBeenCalledWith(
|
|
"https://manage.example.com/api/digital-employee/leases/plan-step-dispatch/acquire",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer login-token-001",
|
|
"X-Qiming-Client-Key": "client-key-001",
|
|
"X-Qiming-Device-Id": "device-001",
|
|
}),
|
|
body: expect.stringContaining('"step_id":"step-1"'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns a remote lease rejection without falling back", async () => {
|
|
mockState.fetch.mockResolvedValue(jsonResponse({ ok: false, reason: "locked_by_other_device" }));
|
|
|
|
const { acquireDigitalEmployeePlanStepRemoteLease } = await import("./syncService");
|
|
const result = await acquireDigitalEmployeePlanStepRemoteLease({ stepId: "step-1", occurredAt: "2026-06-07T08:00:00.000Z" });
|
|
|
|
expect(result).toMatchObject({ ok: false, rejected: true, reason: "locked_by_other_device" });
|
|
});
|
|
|
|
it("falls back when remote plan step dispatch lease acquire fails", async () => {
|
|
mockState.fetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 503,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "lease service unavailable" })),
|
|
} as unknown as Response);
|
|
|
|
const { acquireDigitalEmployeePlanStepRemoteLease } = await import("./syncService");
|
|
const result = await acquireDigitalEmployeePlanStepRemoteLease({ stepId: "step-1", occurredAt: "2026-06-07T08:00:00.000Z" });
|
|
|
|
expect(result).toMatchObject({ ok: false, fallback: true, error: "lease service unavailable" });
|
|
});
|
|
|
|
it("reports remote release failures without throwing", async () => {
|
|
mockState.fetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "release failed" })),
|
|
} as unknown as Response);
|
|
|
|
const { releaseDigitalEmployeePlanStepRemoteLease } = await import("./syncService");
|
|
const result = await releaseDigitalEmployeePlanStepRemoteLease({
|
|
planId: "plan-1",
|
|
stepId: "step-1",
|
|
taskId: "task-1",
|
|
leaseId: "remote-lease-1",
|
|
remoteLeaseId: "remote-lease-1",
|
|
reason: "completed",
|
|
occurredAt: "2026-06-07T08:01:00.000Z",
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: false, endpoint: "https://manage.example.com/api/digital-employee/leases/plan-step-dispatch/release", error: "release failed" });
|
|
});
|
|
|
|
it("marks entity-only acknowledgements as synced", async () => {
|
|
insertPlan("plan-1");
|
|
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
acked: [
|
|
{
|
|
entity_type: "plan",
|
|
entity_id: "plan-1",
|
|
remote_id: "remote-plan-1",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
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({
|
|
status: "synced",
|
|
error: null,
|
|
attempts: 1,
|
|
});
|
|
expect(readPlan("plan-1")).toMatchObject({
|
|
sync_status: "synced",
|
|
remote_id: "remote-plan-1",
|
|
sync_error: null,
|
|
});
|
|
expect(readAttempts()).toEqual([
|
|
expect.objectContaining({
|
|
outbox_id: "plan:upsert:plan-1",
|
|
attempt_no: 1,
|
|
status: "synced",
|
|
error: null,
|
|
}),
|
|
]);
|
|
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");
|
|
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
|
|
insertOutbox("plan:upsert:plan-2", "plan", "plan-2");
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
acked: [
|
|
{
|
|
outbox_id: "plan:upsert:plan-1",
|
|
entity_type: "plan",
|
|
entity_id: "plan-1",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
|
|
expect(status.lastSyncError).toBe(
|
|
"management sync response did not acknowledge item",
|
|
);
|
|
expect(status.failed).toBe(1);
|
|
expect(status.failureSummary).toMatchObject({
|
|
total: 1,
|
|
dueForRetry: 0,
|
|
backoff: 1,
|
|
byEntityType: [{ entityType: "plan", count: 1 }],
|
|
});
|
|
expect(readOutbox("plan:upsert:plan-1")).toMatchObject({
|
|
status: "synced",
|
|
});
|
|
expect(readOutbox("plan:upsert:plan-2")).toMatchObject({
|
|
status: "failed",
|
|
attempts: 1,
|
|
error: "management sync response did not acknowledge item",
|
|
});
|
|
expect(status.recentFailures[0]).toMatchObject({
|
|
id: "plan:upsert:plan-2",
|
|
entitySummary: {
|
|
title: "同步测试计划 plan-2",
|
|
status: "pending",
|
|
summary: "用于验证同步失败诊断的业务摘要",
|
|
},
|
|
dueForRetry: false,
|
|
managementRecordUrl:
|
|
"https://manage.example.com/system/content/content-digital-employee-sync?device_id=device-001&entity_type=plan&entity_id=plan-2&outbox_id=plan%3Aupsert%3Aplan-2",
|
|
attemptHistory: [
|
|
expect.objectContaining({
|
|
attemptNo: 1,
|
|
status: "failed",
|
|
error: "management sync response did not acknowledge item",
|
|
endpoint: "https://manage.example.com/api/digital-employee/sync/outbox",
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("marks artifact and approval acknowledgements as synced", async () => {
|
|
insertEntity("artifact", "artifact-1");
|
|
insertEntity("approval", "approval-1");
|
|
insertOutbox("artifact:upsert:artifact-1", "artifact", "artifact-1");
|
|
insertOutbox("approval:upsert:approval-1", "approval", "approval-1");
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
acked: [
|
|
{
|
|
entity_type: "artifact",
|
|
entity_id: "artifact-1",
|
|
remote_id: "remote-artifact-1",
|
|
},
|
|
{
|
|
entity_type: "approval",
|
|
entity_id: "approval-1",
|
|
remote_id: "remote-approval-1",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
|
|
expect(status.pending).toBe(0);
|
|
expect(status.failed).toBe(0);
|
|
expect(readOutbox("artifact:upsert:artifact-1")).toMatchObject({
|
|
status: "synced",
|
|
attempts: 1,
|
|
});
|
|
expect(readOutbox("approval:upsert:approval-1")).toMatchObject({
|
|
status: "synced",
|
|
attempts: 1,
|
|
});
|
|
expect(readEntity("artifact", "artifact-1")).toMatchObject({
|
|
sync_status: "synced",
|
|
remote_id: "remote-artifact-1",
|
|
sync_error: null,
|
|
});
|
|
expect(readEntity("approval", "approval-1")).toMatchObject({
|
|
sync_status: "synced",
|
|
remote_id: "remote-approval-1",
|
|
sync_error: null,
|
|
});
|
|
});
|
|
|
|
it("syncs plan step business views and acknowledgements", async () => {
|
|
insertEntity("plan_step", "step-1");
|
|
insertOutbox(
|
|
"plan_step:upsert:step-1",
|
|
"plan_step",
|
|
"step-1",
|
|
JSON.stringify({
|
|
planId: "plan-1",
|
|
title: "整理客户列表",
|
|
status: "pending",
|
|
seq: 2,
|
|
dependsOn: ["step-0"],
|
|
preferredSkillIds: ["qimingclaw-mcp-tool-crm-crm-search"],
|
|
payload: {
|
|
dependencyGraph: {
|
|
status: "blocked",
|
|
blockedDependencies: ["step-0"],
|
|
waitingDependencies: ["step-2"],
|
|
},
|
|
lastDispatch: { status: "failed" },
|
|
skipReason: "approval_pending",
|
|
dispatchLease: {
|
|
lease_id: "lease-step-1",
|
|
state: "active",
|
|
owner_device_id: "device-001",
|
|
acquired_at: "2026-06-07T07:59:00.000Z",
|
|
expires_at: "2026-06-07T08:04:00.000Z",
|
|
trigger_source: "manual",
|
|
orchestration_id: "manual-ready-steps-plan-1",
|
|
candidate_count: 1,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
acked: [
|
|
{
|
|
entity_type: "plan_step",
|
|
entity_id: "step-1",
|
|
remote_id: "remote-step-1",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
|
|
expect(lastSyncRequestBody()).toMatchObject({
|
|
items: [
|
|
expect.objectContaining({
|
|
entity_type: "plan_step",
|
|
business_view: expect.objectContaining({
|
|
entity_type: "plan_step",
|
|
local_id: "step-1",
|
|
plan_id: "plan-1",
|
|
step_id: "step-1",
|
|
title: "整理客户列表",
|
|
status: "pending",
|
|
seq: 2,
|
|
depends_on: ["step-0"],
|
|
dependency_count: 1,
|
|
dependency_status: "blocked",
|
|
blocked_dependencies: ["step-0"],
|
|
blocked_dependency_count: 1,
|
|
waiting_dependencies: ["step-2"],
|
|
waiting_dependency_count: 1,
|
|
preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"],
|
|
dispatchable: false,
|
|
skip_reason: "lease_active",
|
|
last_dispatch_status: "failed",
|
|
lease_id: "lease-step-1",
|
|
lease_state: "active",
|
|
lease_owner_device_id: "device-001",
|
|
lease_acquired_at: "2026-06-07T07:59:00.000Z",
|
|
lease_expires_at: "2026-06-07T08:04:00.000Z",
|
|
lease_active: true,
|
|
}),
|
|
}),
|
|
],
|
|
});
|
|
expect(status.failed).toBe(0);
|
|
expect(readEntity("plan_step", "step-1")).toMatchObject({
|
|
sync_status: "synced",
|
|
remote_id: "remote-step-1",
|
|
sync_error: null,
|
|
});
|
|
});
|
|
|
|
it("marks plan step business failures as failed", async () => {
|
|
insertEntity("plan_step", "step-rejected");
|
|
insertOutbox("plan_step:upsert:step-rejected", "plan_step", "step-rejected");
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({ code: "0001", message: "步骤同步被拒绝", data: null }),
|
|
);
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
|
|
expect(status.failed).toBe(1);
|
|
expect(readEntity("plan_step", "step-rejected")).toMatchObject({
|
|
sync_status: "failed",
|
|
sync_error: "步骤同步被拒绝",
|
|
});
|
|
});
|
|
|
|
it("syncs schedule and schedule run business views", async () => {
|
|
insertEntity("schedule", "schedule-1");
|
|
insertEntity("schedule_run", "schedule-run-1");
|
|
insertOutbox("schedule:upsert:schedule-1", "schedule", "schedule-1");
|
|
insertOutbox("schedule_run:upsert:schedule-run-1", "schedule_run", "schedule-run-1");
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
acked: [
|
|
{ entity_type: "schedule", entity_id: "schedule-1", remote_id: "remote-schedule-1" },
|
|
{ entity_type: "schedule_run", entity_id: "schedule-run-1", remote_id: "remote-schedule-run-1" },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
|
|
expect(status.failed).toBe(0);
|
|
expect(lastSyncRequestBody().items).toEqual([
|
|
expect.objectContaining({
|
|
entity_type: "schedule",
|
|
business_view: expect.objectContaining({
|
|
title: "同步测试计划 schedule-1",
|
|
status: "pending",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
entity_type: "schedule_run",
|
|
business_view: expect.objectContaining({
|
|
title: "同步测试计划 schedule-run-1",
|
|
status: "pending",
|
|
}),
|
|
}),
|
|
]);
|
|
expect(readEntity("schedule", "schedule-1")).toMatchObject({
|
|
sync_status: "synced",
|
|
remote_id: "remote-schedule-1",
|
|
});
|
|
expect(readEntity("schedule_run", "schedule-run-1")).toMatchObject({
|
|
sync_status: "synced",
|
|
remote_id: "remote-schedule-run-1",
|
|
});
|
|
});
|
|
|
|
it("syncs memory business views and acknowledgements", async () => {
|
|
insertEntity("memory", "memory-1");
|
|
insertOutbox(
|
|
"memory:upsert:memory-1",
|
|
"memory",
|
|
"memory-1",
|
|
JSON.stringify({
|
|
key: "commit-language",
|
|
content: "用户要求提交信息使用中文,并保留相关上下文。",
|
|
category: "preference",
|
|
source: "qimingclaw-memory-service",
|
|
status: "active",
|
|
}),
|
|
);
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
acked: [{ entity_type: "memory", entity_id: "memory-1", remote_id: "remote-memory-1" }],
|
|
}),
|
|
);
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
|
|
expect(status.failed).toBe(0);
|
|
expect(lastSyncRequestBody()).toMatchObject({
|
|
items: [
|
|
expect.objectContaining({
|
|
entity_type: "memory",
|
|
business_view: expect.objectContaining({
|
|
entity_type: "memory",
|
|
local_id: "memory-1",
|
|
key: "commit-language",
|
|
category: "preference",
|
|
source: "qimingclaw-memory-service",
|
|
status: "active",
|
|
content_preview: "用户要求提交信息使用中文,并保留相关上下文。",
|
|
}),
|
|
}),
|
|
],
|
|
});
|
|
expect(readEntity("memory", "memory-1")).toMatchObject({
|
|
sync_status: "synced",
|
|
remote_id: "remote-memory-1",
|
|
sync_error: null,
|
|
});
|
|
});
|
|
|
|
it("syncs specialized event business views for management consumption", async () => {
|
|
insertEventEntity("event-route", "route_decision", {
|
|
source: "qimingclaw-route-decision",
|
|
routeDecision: {
|
|
id: "route-1",
|
|
route_kind: "PlanStepDispatch",
|
|
intent_kind: "schedule",
|
|
policy_result: "accepted",
|
|
reason_codes: ["ready_plan_step"],
|
|
candidate_skills: ["crm-search"],
|
|
created_command_id: "governance-command-1",
|
|
policy_source: "remote",
|
|
blocked_reason: null,
|
|
approval_id: "route-approval-1",
|
|
matched_intent: "schedule",
|
|
},
|
|
});
|
|
insertEventEntity("event-route-intervention", "route_decision_intervention_resolved", {
|
|
route_decision_id: "route-1",
|
|
approval_id: "route-approval-1",
|
|
route_kind: "PlanStepDispatch",
|
|
intent_kind: "schedule",
|
|
decision: "approved",
|
|
status: "executed",
|
|
policy_result: "approval_required",
|
|
policy_source: "remote",
|
|
created_command_id: "governance-command-restore",
|
|
command_accepted: true,
|
|
reason_codes: ["route_intent_requires_approval"],
|
|
});
|
|
insertEventEntity("event-approval", "approval_action_recorded", {
|
|
approval_id: "approval-1",
|
|
action: "mark_risk",
|
|
status: "recorded",
|
|
actor: "operator",
|
|
comment_summary: { preview: "存在风险", length: 4 },
|
|
delegate_summary: { target: "manager-1", label: "经理" },
|
|
sla_summary: { due_at: "2026-06-08T09:00:00.000Z", sla_status: "tracked" },
|
|
risk_summary: { risk_level: "high" },
|
|
});
|
|
insertEventEntity("event-approval-decision", "approval_decision", {
|
|
approvalId: "approval-1",
|
|
decision: "approved",
|
|
previousStatus: "pending",
|
|
nextStatus: "approved",
|
|
actor: "operator",
|
|
decidedAt: "2026-06-07T08:05:00.000Z",
|
|
planId: "plan-1",
|
|
taskId: "task-1",
|
|
runId: "run-1",
|
|
});
|
|
insertEventEntity("event-approval-conflict-resolved", "approval_conflict_resolved", {
|
|
approval_id: "approval-conflict-1",
|
|
resolution: "keep_local",
|
|
status: "approved",
|
|
actor: "lead",
|
|
reason_codes: ["conflict_resolved"],
|
|
previous_reason: "terminal_status_mismatch",
|
|
previous_local_status: "approved",
|
|
previous_remote_status: "rejected",
|
|
resolved_at: "2026-06-07T08:00:00.000Z",
|
|
});
|
|
insertEventEntity("event-approval-conflict-rejected", "approval_conflict_resolution_rejected", {
|
|
approval_id: "approval-conflict-2",
|
|
resolution: "accept_remote",
|
|
status: "rejected",
|
|
actor: "lead",
|
|
reason_codes: ["remote_snapshot_missing"],
|
|
});
|
|
insertEventEntity("event-artifact", "artifact_retention_marked", {
|
|
artifact_id: "artifact-1",
|
|
action: "mark_keep",
|
|
status: "recorded",
|
|
actor: "operator",
|
|
retention_status: "kept",
|
|
retention_until: "2026-07-01T00:00:00.000Z",
|
|
});
|
|
insertEventEntity("event-artifact-cleanup", "artifact_cleanup_executed", {
|
|
artifact_id: "artifact-1",
|
|
status: "deleted",
|
|
actor: "operator",
|
|
reason: "retention_expired",
|
|
reason_codes: ["retention_expired"],
|
|
deleted_at: "2026-06-07T08:06:00.000Z",
|
|
});
|
|
insertEventEntity("event-daily-report-send", "daily_report_send_recorded", {
|
|
report_id: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
|
|
action: "send",
|
|
status: "sent",
|
|
actor: "operator",
|
|
endpoint: "management-console",
|
|
recorded_at: "2026-06-07T08:05:00.000Z",
|
|
});
|
|
insertEventEntity("event-daily-report-confirm", "daily_report_confirmed", {
|
|
report_id: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
|
|
action: "confirm",
|
|
status: "confirmed",
|
|
actor: "management",
|
|
remote_id: "remote-report-1",
|
|
recorded_at: "2026-06-07T08:06:00.000Z",
|
|
});
|
|
insertEventEntity("event-skill", "skill_call_rejected", {
|
|
call_id: "call-1",
|
|
source: "qimingclaw-acp",
|
|
server_id: "server-1",
|
|
tool_name: "crm.search",
|
|
skill_id: "crm-search",
|
|
decision: "rejected",
|
|
reason_codes: ["skill_disabled"],
|
|
});
|
|
insertEventEntity("event-sandbox-audit", "sandbox_audit", {
|
|
session_id: "sandbox-session-1",
|
|
operation: "execute",
|
|
status: "failed",
|
|
exit_code: 126,
|
|
duration_ms: 2400,
|
|
command: "curl https://example.com --header Authorization: Bearer secret-token",
|
|
command_preview: "curl https://example.com",
|
|
reason_codes: ["permission_denied"],
|
|
authorization: "Bearer secret-token",
|
|
});
|
|
insertEventEntity("event-token-cost", "token_cost", {
|
|
session_id: "session-cost-1",
|
|
model: "gpt-4.1-mini",
|
|
input_tokens: 120,
|
|
output_tokens: 80,
|
|
total_tokens: 200,
|
|
estimated_cost_usd: 0.0125,
|
|
prompt: "完整客户资料和敏感输入不应同步",
|
|
raw_input: { token: "raw-secret" },
|
|
token: "secret-token",
|
|
});
|
|
insertEventEntity("event-tool-audit", "tool_call_audit", {
|
|
session_id: "session-tool-1",
|
|
server_id: "crm-server",
|
|
tool_name: "crm.search",
|
|
operation: "tool_call",
|
|
status: "allowed",
|
|
duration_ms: 640,
|
|
input_length: 36,
|
|
raw_input: { customer_phone: "13800000000" },
|
|
authorization: "Bearer tool-secret",
|
|
});
|
|
insertEventEntity("event-dispatch", "plan_step_dispatch_failed", {
|
|
step_id: "step-1",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
status: "failed",
|
|
reason: "computer chat failed",
|
|
trigger_source: "manual",
|
|
orchestration_id: "manual-ready-steps-plan-1",
|
|
});
|
|
insertEventEntity("event-lease", "plan_step_lease_released", {
|
|
step_id: "step-1",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
lease_id: "lease-step-1",
|
|
lease_state: "released",
|
|
lease_owner_device_id: "device-001",
|
|
lease_expires_at: "2026-06-07T08:04:00.000Z",
|
|
lease_source: "remote",
|
|
remote_lease_id: "remote-lease-step-1",
|
|
remote_release_error: "release failed",
|
|
reason: "completed",
|
|
lease: {
|
|
lease_id: "lease-step-1",
|
|
state: "released",
|
|
owner_device_id: "device-001",
|
|
acquired_at: "2026-06-07T07:59:00.000Z",
|
|
expires_at: "2026-06-07T08:04:00.000Z",
|
|
released_at: "2026-06-07T08:01:00.000Z",
|
|
release_reason: "completed",
|
|
source: "remote",
|
|
remote_lease_id: "remote-lease-step-1",
|
|
remote_release_error: "release failed",
|
|
},
|
|
});
|
|
insertEventEntity("event-plan-step-ready", "plan_step_ready", {
|
|
stepId: "step-2",
|
|
planId: "plan-1",
|
|
status: "ready",
|
|
dependsOn: ["step-1"],
|
|
dependencyGraph: {
|
|
status: "ready",
|
|
blockedDependencies: [],
|
|
waitingDependencies: [],
|
|
satisfiedDependencies: ["step-1"],
|
|
},
|
|
});
|
|
insertEventEntity("event-governance-input", "governance_provide_task_input", {
|
|
commandKind: "provide_task_input",
|
|
commandId: "command-provide-input",
|
|
planId: "plan-1",
|
|
taskId: "task-1",
|
|
runId: "run-1",
|
|
accepted: true,
|
|
payload: {
|
|
input: "请改用客户备用手机号",
|
|
source: "notification_reply",
|
|
notification_id: "input:task-1",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
},
|
|
result: {
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
source: "qimingclaw-task-agent",
|
|
},
|
|
});
|
|
insertEventEntity("event-governance-pause", "governance_pause_plan", {
|
|
commandKind: "pause_plan",
|
|
commandId: "command-pause-plan",
|
|
correlationId: "route-1",
|
|
planId: "plan-1",
|
|
accepted: true,
|
|
message: "暂停计划",
|
|
riskLevel: "normal",
|
|
payload: {
|
|
source: "qimingclaw-ingress-route",
|
|
route_decision_id: "route-1",
|
|
route_kind: "PlanControl",
|
|
intent_kind: "control",
|
|
plan_id: "plan-1",
|
|
},
|
|
result: {
|
|
plan_id: "plan-1",
|
|
status: "accepted",
|
|
source: "qimingclaw-adapter",
|
|
},
|
|
});
|
|
insertEventEntity("event-governance-skip", "governance_skip_task", {
|
|
command_kind: "skip_task",
|
|
command_id: "command-skip-task",
|
|
correlation_id: "route-2",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
step_id: "step-1",
|
|
accepted: false,
|
|
error: "policy_blocked",
|
|
risk_level: "medium",
|
|
payload: {
|
|
source: "qimingclaw-ingress-route",
|
|
route_decision_id: "route-2",
|
|
route_kind: "PlanControl",
|
|
intent_kind: "control",
|
|
task_id: "task-1",
|
|
},
|
|
result: {
|
|
status: "rejected",
|
|
error: "policy_blocked",
|
|
},
|
|
});
|
|
insertEventEntity("event-notification-read", "digital_workday_notification_read", {
|
|
metadata: {
|
|
notification_id: "task:task-1",
|
|
action: "read",
|
|
},
|
|
state: {
|
|
notificationStateById: {
|
|
"task:task-1": {
|
|
status: "read",
|
|
read_at: "2026-06-07T08:01:00.000Z",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-notification-dismiss", "digital_workday_notification_dismiss", {
|
|
metadata: {
|
|
notification_id: "task:task-2",
|
|
action: "dismiss",
|
|
},
|
|
state: {
|
|
notificationStateById: {
|
|
"task:task-2": {
|
|
status: "dismissed",
|
|
dismissed_at: "2026-06-07T08:02:00.000Z",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-notification-reply", "digital_workday_notification_reply", {
|
|
metadata: {
|
|
notification_id: "input:task-3",
|
|
action: "reply",
|
|
},
|
|
state: {
|
|
notificationStateById: {
|
|
"input:task-3": {
|
|
status: "read",
|
|
read_at: "2026-06-07T08:03:00.000Z",
|
|
reply: "请改用客户备用手机号",
|
|
replied_at: "2026-06-07T08:03:30.000Z",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-notification-preferences", "digital_workday_update_notification_preferences", {
|
|
metadata: {
|
|
notification_preferences: {
|
|
enabled: false,
|
|
muted_kinds: ["task_finished", "need_input"],
|
|
muted_levels: ["info"],
|
|
updated_at: "2026-06-07T08:04:00.000Z",
|
|
},
|
|
},
|
|
state: {
|
|
notificationPreferences: {
|
|
enabled: false,
|
|
muted_kinds: ["task_finished", "need_input"],
|
|
muted_levels: ["info"],
|
|
updated_at: "2026-06-07T08:04:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-approval-subscriptions", "digital_workday_update_approval_subscriptions", {
|
|
metadata: {
|
|
action: "update_approval_subscriptions",
|
|
approval_subscriptions: {
|
|
enabled: false,
|
|
subscribed_actions: ["mark_risk", "decision"],
|
|
subscribed_risk_levels: ["high", "critical"],
|
|
notify_on_timeout: false,
|
|
updated_at: "2026-06-07T08:06:00.000Z",
|
|
},
|
|
},
|
|
state: {
|
|
approvalSubscriptionPreferences: {
|
|
enabled: false,
|
|
subscribed_actions: ["mark_risk", "decision"],
|
|
subscribed_risk_levels: ["high", "critical"],
|
|
notify_on_timeout: false,
|
|
updated_at: "2026-06-07T08:06:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-plan-step-policy", "digital_workday_update_plan_step_dispatch_policy", {
|
|
metadata: {
|
|
action: "update_plan_step_dispatch_policy",
|
|
plan_step_dispatch_policy: {
|
|
enabled: false,
|
|
max_per_sweep: 1,
|
|
auto_dispatch_ready_steps: false,
|
|
updated_at: "2026-06-07T08:07:00.000Z",
|
|
},
|
|
},
|
|
state: {
|
|
planStepDispatchPolicy: {
|
|
enabled: false,
|
|
max_per_sweep: 1,
|
|
auto_dispatch_ready_steps: false,
|
|
updated_at: "2026-06-07T08:07:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-plan-step-policy-pull", "digital_workday_pull_plan_step_dispatch_policy", {
|
|
metadata: {
|
|
action: "pull_plan_step_dispatch_policy",
|
|
source: "management-api",
|
|
ok: false,
|
|
error: "policy service unavailable",
|
|
endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
|
plan_step_dispatch_policy: {
|
|
enabled: true,
|
|
max_per_sweep: 2,
|
|
auto_dispatch_ready_steps: true,
|
|
updated_at: "2026-06-07T08:08:00.000Z",
|
|
source: "remote",
|
|
remote_updated_at: "2026-06-07T08:08:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:09:00.000Z",
|
|
last_pull_error: "policy service unavailable",
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-risk-policy-pull", "digital_workday_pull_risk_approval_policy", {
|
|
metadata: {
|
|
action: "pull_risk_approval_policy",
|
|
source: "management-api",
|
|
ok: true,
|
|
risk_approval_policy: {
|
|
enabled: true,
|
|
source: "remote",
|
|
version: "risk-policy-v2",
|
|
approval_required_risk_levels: ["high", "critical"],
|
|
approval_required_actions: ["managed_service.restart.*"],
|
|
auto_reject_actions: ["service.stop.*"],
|
|
remote_updated_at: "2026-06-07T08:10:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:11:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-route-policy-pull", "digital_workday_pull_route_decision_policy", {
|
|
metadata: {
|
|
action: "pull_route_decision_policy",
|
|
source: "management-api",
|
|
ok: true,
|
|
route_decision_policy: {
|
|
enabled: true,
|
|
source: "remote",
|
|
version: "route-policy-v3",
|
|
auto_create_governance_commands: false,
|
|
blocked_intents: ["delete"],
|
|
approval_required_intents: ["restart_service"],
|
|
preferred_route_kinds: ["PlanStepDispatch"],
|
|
remote_updated_at: "2026-06-07T08:12:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:13:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
insertEventEntity("event-risk-approval", "risk_approval_required", {
|
|
action_kind: "managed_service.restart.crm-sync",
|
|
action_label: "重启 CRM 同步服务",
|
|
risk_level: "high",
|
|
decision: "approval_required",
|
|
status: "pending",
|
|
reason_codes: ["risk_level_requires_approval"],
|
|
policy_source: "remote",
|
|
approval_id: "risk-approval-1",
|
|
correlation_id: "restart-1",
|
|
actor: "operator",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
});
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({
|
|
success: true,
|
|
acked: [
|
|
{ entity_type: "event", entity_id: "event-route" },
|
|
{ entity_type: "event", entity_id: "event-route-intervention" },
|
|
{ entity_type: "event", entity_id: "event-approval" },
|
|
{ entity_type: "event", entity_id: "event-approval-decision" },
|
|
{ entity_type: "event", entity_id: "event-approval-conflict-resolved" },
|
|
{ entity_type: "event", entity_id: "event-approval-conflict-rejected" },
|
|
{ entity_type: "event", entity_id: "event-artifact" },
|
|
{ entity_type: "event", entity_id: "event-artifact-cleanup" },
|
|
{ entity_type: "event", entity_id: "event-daily-report-send" },
|
|
{ entity_type: "event", entity_id: "event-daily-report-confirm" },
|
|
{ entity_type: "event", entity_id: "event-skill" },
|
|
{ entity_type: "event", entity_id: "event-sandbox-audit" },
|
|
{ entity_type: "event", entity_id: "event-token-cost" },
|
|
{ entity_type: "event", entity_id: "event-tool-audit" },
|
|
{ entity_type: "event", entity_id: "event-dispatch" },
|
|
{ entity_type: "event", entity_id: "event-lease" },
|
|
{ entity_type: "event", entity_id: "event-plan-step-ready" },
|
|
{ entity_type: "event", entity_id: "event-governance-input" },
|
|
{ entity_type: "event", entity_id: "event-governance-pause" },
|
|
{ entity_type: "event", entity_id: "event-governance-skip" },
|
|
{ entity_type: "event", entity_id: "event-notification-read" },
|
|
{ entity_type: "event", entity_id: "event-notification-dismiss" },
|
|
{ entity_type: "event", entity_id: "event-notification-reply" },
|
|
{ entity_type: "event", entity_id: "event-notification-preferences" },
|
|
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
|
|
{ entity_type: "event", entity_id: "event-plan-step-policy" },
|
|
{ entity_type: "event", entity_id: "event-plan-step-policy-pull" },
|
|
{ entity_type: "event", entity_id: "event-risk-policy-pull" },
|
|
{ entity_type: "event", entity_id: "event-route-policy-pull" },
|
|
{ entity_type: "event", entity_id: "event-risk-approval" },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
const items = lastSyncRequestBody().items as Array<{ entity_id: string; business_view: Record<string, unknown> }>;
|
|
const businessView = (eventId: string) => items.find((item) => item.entity_id === eventId)?.business_view;
|
|
|
|
expect(status.failed).toBe(0);
|
|
expect(businessView("event-route")).toMatchObject({
|
|
entity_type: "event",
|
|
local_id: "event-route",
|
|
category: "route",
|
|
kind: "route_decision",
|
|
route_decision_id: "route-1",
|
|
route_kind: "PlanStepDispatch",
|
|
intent_kind: "schedule",
|
|
policy_result: "accepted",
|
|
reason_codes: ["ready_plan_step"],
|
|
candidate_skills: ["crm-search"],
|
|
created_command_id: "governance-command-1",
|
|
policy_source: "remote",
|
|
approval_id: "route-approval-1",
|
|
matched_intent: "schedule",
|
|
attention_level: "action",
|
|
});
|
|
expect(businessView("event-route-intervention")).toMatchObject({
|
|
category: "route",
|
|
route_decision_id: "route-1",
|
|
approval_id: "route-approval-1",
|
|
decision: "approved",
|
|
status: "executed",
|
|
created_command_id: "governance-command-restore",
|
|
policy_source: "remote",
|
|
command_accepted: true,
|
|
attention_level: "action",
|
|
});
|
|
expect(businessView("event-approval")).toMatchObject({
|
|
category: "approval",
|
|
approval_id: "approval-1",
|
|
action: "mark_risk",
|
|
status: "recorded",
|
|
actor: "operator",
|
|
risk_level: "high",
|
|
due_at: "2026-06-08T09:00:00.000Z",
|
|
delegate_to: "manager-1",
|
|
comment_length: 4,
|
|
});
|
|
expect(businessView("event-approval")).not.toHaveProperty("comment");
|
|
expect(businessView("event-approval")).not.toHaveProperty("comment_preview");
|
|
expect(businessView("event-approval-decision")).toMatchObject({
|
|
category: "approval",
|
|
approval_id: "approval-1",
|
|
action: "decision",
|
|
decision: "approved",
|
|
previous_status: "pending",
|
|
next_status: "approved",
|
|
actor: "operator",
|
|
decided_at: "2026-06-07T08:05:00.000Z",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
});
|
|
expect(businessView("event-approval-conflict-resolved")).toMatchObject({
|
|
category: "approval",
|
|
approval_id: "approval-conflict-1",
|
|
action: "conflict_resolution",
|
|
approval_conflict_resolution: "keep_local",
|
|
approval_conflict_resolution_actor: "lead",
|
|
approval_conflict_resolved_at: "2026-06-07T08:00:00.000Z",
|
|
status: "approved",
|
|
reason_codes: ["conflict_resolved"],
|
|
previous_reason: "terminal_status_mismatch",
|
|
previous_local_status: "approved",
|
|
previous_remote_status: "rejected",
|
|
});
|
|
expect(businessView("event-approval-conflict-rejected")).toMatchObject({
|
|
category: "approval",
|
|
approval_id: "approval-conflict-2",
|
|
action: "conflict_resolution",
|
|
approval_conflict_resolution: "accept_remote",
|
|
approval_conflict_resolution_actor: "lead",
|
|
status: "rejected",
|
|
reason_codes: ["remote_snapshot_missing"],
|
|
});
|
|
expect(businessView("event-artifact")).toMatchObject({
|
|
category: "artifact",
|
|
artifact_id: "artifact-1",
|
|
action: "mark_keep",
|
|
status: "recorded",
|
|
retention_status: "kept",
|
|
retention_until: "2026-07-01T00:00:00.000Z",
|
|
actor: "operator",
|
|
});
|
|
expect(businessView("event-artifact-cleanup")).toMatchObject({
|
|
category: "artifact",
|
|
artifact_id: "artifact-1",
|
|
status: "deleted",
|
|
cleanup_status: "deleted",
|
|
cleanup_reason: "retention_expired",
|
|
deleted_at: "2026-06-07T08:06:00.000Z",
|
|
reason_codes: ["retention_expired"],
|
|
actor: "operator",
|
|
});
|
|
expect(businessView("event-daily-report-send")).toMatchObject({
|
|
category: "daily_report",
|
|
report_id: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
|
|
action: "send",
|
|
status: "sent",
|
|
actor: "operator",
|
|
endpoint: "management-console",
|
|
});
|
|
expect(businessView("event-daily-report-confirm")).toMatchObject({
|
|
category: "daily_report",
|
|
report_id: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
|
|
action: "confirm",
|
|
status: "confirmed",
|
|
remote_id: "remote-report-1",
|
|
});
|
|
expect(businessView("event-skill")).toMatchObject({
|
|
category: "skill",
|
|
call_id: "call-1",
|
|
source: "qimingclaw-acp",
|
|
server_id: "server-1",
|
|
tool_name: "crm.search",
|
|
skill_id: "crm-search",
|
|
decision: "rejected",
|
|
reason_codes: ["skill_disabled"],
|
|
});
|
|
expect(businessView("event-sandbox-audit")).toMatchObject({
|
|
category: "sandbox_audit",
|
|
session_id: "sandbox-session-1",
|
|
operation: "execute",
|
|
status: "failed",
|
|
exit_code: 126,
|
|
duration_ms: 2400,
|
|
command_preview: "curl https://example.com",
|
|
reason_codes: ["permission_denied"],
|
|
});
|
|
expect(JSON.stringify(businessView("event-sandbox-audit"))).not.toContain("secret-token");
|
|
expect(JSON.stringify(businessView("event-sandbox-audit"))).not.toContain("Authorization");
|
|
expect(businessView("event-token-cost")).toMatchObject({
|
|
category: "token_cost",
|
|
session_id: "session-cost-1",
|
|
model: "gpt-4.1-mini",
|
|
input_tokens: 120,
|
|
output_tokens: 80,
|
|
total_tokens: 200,
|
|
estimated_cost_usd: 0.0125,
|
|
estimated: false,
|
|
});
|
|
expect(JSON.stringify(businessView("event-token-cost"))).not.toContain("完整客户资料");
|
|
expect(JSON.stringify(businessView("event-token-cost"))).not.toContain("raw-secret");
|
|
expect(businessView("event-tool-audit")).toMatchObject({
|
|
category: "tool_call_audit",
|
|
session_id: "session-tool-1",
|
|
server_id: "crm-server",
|
|
tool_name: "crm.search",
|
|
operation: "tool_call",
|
|
status: "allowed",
|
|
duration_ms: 640,
|
|
input_length: 36,
|
|
});
|
|
expect(JSON.stringify(businessView("event-tool-audit"))).not.toContain("13800000000");
|
|
expect(JSON.stringify(businessView("event-tool-audit"))).not.toContain("tool-secret");
|
|
expect(businessView("event-dispatch")).toMatchObject({
|
|
category: "dispatch",
|
|
step_id: "step-1",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
status: "failed",
|
|
reason: "computer chat failed",
|
|
trigger_source: "manual",
|
|
orchestration_id: "manual-ready-steps-plan-1",
|
|
});
|
|
expect(businessView("event-lease")).toMatchObject({
|
|
category: "dispatch",
|
|
step_id: "step-1",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
lease_id: "lease-step-1",
|
|
lease_state: "released",
|
|
lease_owner_device_id: "device-001",
|
|
lease_acquired_at: "2026-06-07T07:59:00.000Z",
|
|
lease_expires_at: "2026-06-07T08:04:00.000Z",
|
|
lease_released_at: "2026-06-07T08:01:00.000Z",
|
|
lease_release_reason: "completed",
|
|
lease_source: "remote",
|
|
remote_lease_id: "remote-lease-step-1",
|
|
remote_release_error: "release failed",
|
|
remote_lease_rejected: false,
|
|
reason: "completed",
|
|
});
|
|
expect(businessView("event-plan-step-ready")).toMatchObject({
|
|
category: "dispatch",
|
|
step_id: "step-2",
|
|
plan_id: "plan-1",
|
|
status: "ready",
|
|
dependency_count: 1,
|
|
blocked_dependencies: [],
|
|
waiting_dependencies: [],
|
|
satisfied_dependencies: ["step-1"],
|
|
});
|
|
expect(businessView("event-governance-input")).toMatchObject({
|
|
category: "governance",
|
|
command_kind: "provide_task_input",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
source: "notification_reply",
|
|
notification_id: "input:task-1",
|
|
input_length: 10,
|
|
});
|
|
expect(businessView("event-governance-input")).not.toHaveProperty("input");
|
|
expect(businessView("event-governance-pause")).toMatchObject({
|
|
category: "governance",
|
|
command_kind: "pause_plan",
|
|
accepted: true,
|
|
command_id: "command-pause-plan",
|
|
correlation_id: "route-1",
|
|
plan_id: "plan-1",
|
|
route_decision_id: "route-1",
|
|
route_kind: "PlanControl",
|
|
intent_kind: "control",
|
|
source: "qimingclaw-ingress-route",
|
|
status: "accepted",
|
|
risk_level: "normal",
|
|
});
|
|
expect(businessView("event-governance-skip")).toMatchObject({
|
|
category: "governance",
|
|
command_kind: "skip_task",
|
|
accepted: false,
|
|
error: "policy_blocked",
|
|
command_id: "command-skip-task",
|
|
correlation_id: "route-2",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
step_id: "step-1",
|
|
route_decision_id: "route-2",
|
|
route_kind: "PlanControl",
|
|
intent_kind: "control",
|
|
risk_level: "medium",
|
|
});
|
|
expect(businessView("event-notification-read")).toMatchObject({
|
|
category: "notification",
|
|
notification_id: "task:task-1",
|
|
action: "read",
|
|
status: "read",
|
|
read_at: "2026-06-07T08:01:00.000Z",
|
|
});
|
|
expect(businessView("event-notification-dismiss")).toMatchObject({
|
|
category: "notification",
|
|
notification_id: "task:task-2",
|
|
action: "dismiss",
|
|
status: "dismissed",
|
|
dismissed_at: "2026-06-07T08:02:00.000Z",
|
|
});
|
|
expect(businessView("event-notification-reply")).toMatchObject({
|
|
category: "notification",
|
|
notification_id: "input:task-3",
|
|
action: "reply",
|
|
status: "read",
|
|
read_at: "2026-06-07T08:03:00.000Z",
|
|
replied_at: "2026-06-07T08:03:30.000Z",
|
|
reply_length: 10,
|
|
});
|
|
expect(businessView("event-notification-reply")).not.toHaveProperty("reply");
|
|
expect(businessView("event-notification-preferences")).toMatchObject({
|
|
category: "notification",
|
|
preferences_enabled: false,
|
|
muted_kinds: ["task_finished", "need_input"],
|
|
muted_levels: ["info"],
|
|
});
|
|
expect(businessView("event-approval-subscriptions")).toMatchObject({
|
|
category: "approval",
|
|
action: "update_approval_subscriptions",
|
|
preferences_enabled: false,
|
|
subscribed_actions: ["mark_risk", "decision"],
|
|
subscribed_risk_levels: ["high", "critical"],
|
|
notify_on_timeout: false,
|
|
updated_at: "2026-06-07T08:06:00.000Z",
|
|
});
|
|
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("state");
|
|
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("approvalSubscriptionPreferences");
|
|
expect(businessView("event-plan-step-policy")).toMatchObject({
|
|
category: "policy_snapshot",
|
|
policy_kind: "dispatch",
|
|
policy_key: "dispatch",
|
|
action: "update_plan_step_dispatch_policy",
|
|
enabled: false,
|
|
max_per_sweep: 1,
|
|
auto_dispatch_ready_steps: false,
|
|
policy_version: "2026-06-07T08:07:00.000Z",
|
|
});
|
|
expect(businessView("event-plan-step-policy")).not.toHaveProperty("state");
|
|
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
|
|
expect(businessView("event-plan-step-policy-pull")).toMatchObject({
|
|
category: "policy_snapshot",
|
|
policy_kind: "dispatch",
|
|
policy_key: "dispatch",
|
|
action: "pull_plan_step_dispatch_policy",
|
|
source: "management-api",
|
|
pull_ok: false,
|
|
pull_error: "policy service unavailable",
|
|
enabled: true,
|
|
max_per_sweep: 2,
|
|
auto_dispatch_ready_steps: true,
|
|
remote_updated_at: "2026-06-07T08:08:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:09:00.000Z",
|
|
});
|
|
expect(businessView("event-risk-policy-pull")).toMatchObject({
|
|
category: "policy_snapshot",
|
|
policy_kind: "risk_approval",
|
|
policy_key: "risk_approval",
|
|
policy_version: "risk-policy-v2",
|
|
policy_source: "management-api",
|
|
enabled: true,
|
|
approval_required_risk_levels: ["high", "critical"],
|
|
approval_required_actions: ["managed_service.restart.*"],
|
|
auto_reject_actions: ["service.stop.*"],
|
|
remote_updated_at: "2026-06-07T08:10:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:11:00.000Z",
|
|
});
|
|
expect(businessView("event-route-policy-pull")).toMatchObject({
|
|
category: "policy_snapshot",
|
|
policy_kind: "route_decision",
|
|
policy_key: "route_decision",
|
|
policy_version: "route-policy-v3",
|
|
policy_source: "management-api",
|
|
enabled: true,
|
|
auto_create_governance_commands: false,
|
|
blocked_intents: ["delete"],
|
|
approval_required_intents: ["restart_service"],
|
|
preferred_route_kinds: ["PlanStepDispatch"],
|
|
remote_updated_at: "2026-06-07T08:12:00.000Z",
|
|
last_pulled_at: "2026-06-07T08:13:00.000Z",
|
|
});
|
|
expect(businessView("event-risk-approval")).toMatchObject({
|
|
category: "approval",
|
|
action_kind: "managed_service.restart.crm-sync",
|
|
action_label: "重启 CRM 同步服务",
|
|
risk_level: "high",
|
|
decision: "approval_required",
|
|
status: "pending",
|
|
reason_codes: ["risk_level_requires_approval"],
|
|
policy_source: "remote",
|
|
approval_id: "risk-approval-1",
|
|
correlation_id: "restart-1",
|
|
actor: "operator",
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
});
|
|
expect(readEntity("event", "event-route")).toMatchObject({
|
|
sync_status: "synced",
|
|
sync_error: null,
|
|
});
|
|
});
|
|
|
|
it("marks memory sync failures as failed", async () => {
|
|
insertEntity("memory", "memory-rejected");
|
|
insertOutbox("memory:upsert:memory-rejected", "memory", "memory-rejected");
|
|
mockState.fetch.mockResolvedValue(
|
|
jsonResponse({ code: "0001", message: "记忆同步被拒绝", data: null }),
|
|
);
|
|
|
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
|
|
|
expect(status.failed).toBe(1);
|
|
expect(readEntity("memory", "memory-rejected")).toMatchObject({
|
|
sync_status: "failed",
|
|
sync_error: "记忆同步被拒绝",
|
|
});
|
|
});
|
|
});
|
|
|
|
function insertPlan(id: string): void {
|
|
insertEntity("plan", id);
|
|
}
|
|
|
|
function insertEntity(entityType: string, id: string): void {
|
|
entityMap(entityType)?.set(id, {
|
|
id,
|
|
title: `同步测试计划 ${id}`,
|
|
status: "pending",
|
|
objective: "用于验证同步失败诊断的业务摘要",
|
|
payload: "{}",
|
|
remote_id: null,
|
|
sync_status: "pending",
|
|
sync_error: null,
|
|
last_synced_at: null,
|
|
});
|
|
}
|
|
|
|
function insertEventEntity(id: string, kind: string, payload: Record<string, unknown>): void {
|
|
mockState.db?.events.set(id, {
|
|
id,
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
run_id: "run-1",
|
|
kind,
|
|
message: `事件 ${kind}`,
|
|
payload: JSON.stringify(payload),
|
|
remote_id: null,
|
|
sync_status: "pending",
|
|
sync_error: null,
|
|
last_synced_at: null,
|
|
occurred_at: "2026-06-07T07:59:00.000Z",
|
|
});
|
|
insertOutbox(`event:insert:${id}`, "event", id, JSON.stringify({
|
|
event_id: id,
|
|
kind,
|
|
message: `事件 ${kind}`,
|
|
plan_id: "plan-1",
|
|
task_id: "task-1",
|
|
occurred_at: "2026-06-07T07:59:00.000Z",
|
|
}), "insert");
|
|
}
|
|
|
|
function insertOutbox(
|
|
id: string,
|
|
entityType: string,
|
|
entityId: string,
|
|
payload = '{"ok":true}',
|
|
operation = "upsert",
|
|
): void {
|
|
mockState.db?.outbox.set(id, {
|
|
id,
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
operation,
|
|
payload,
|
|
status: "pending",
|
|
attempts: 0,
|
|
last_attempt_at: null,
|
|
error: null,
|
|
created_at: "2026-06-07T07:55:00.000Z",
|
|
updated_at: "2026-06-07T07:55:00.000Z",
|
|
});
|
|
}
|
|
|
|
function readOutbox(id: string): Record<string, unknown> | undefined {
|
|
return mockState.db?.outbox.get(id);
|
|
}
|
|
|
|
function readPlan(id: string): Record<string, unknown> | undefined {
|
|
return readEntity("plan", id);
|
|
}
|
|
|
|
function readEntity(
|
|
entityType: string,
|
|
id: string,
|
|
): Record<string, unknown> | undefined {
|
|
return entityMap(entityType)?.get(id);
|
|
}
|
|
|
|
function entityMap(
|
|
entityType: string,
|
|
): Map<string, TestSyncEntityRow> | undefined {
|
|
if (!mockState.db) return undefined;
|
|
if (entityType === "plan") return mockState.db.plans;
|
|
if (entityType === "task") return mockState.db.tasks;
|
|
if (entityType === "plan_step") return mockState.db.planSteps;
|
|
if (entityType === "run") return mockState.db.runs;
|
|
if (entityType === "schedule") return mockState.db.schedules;
|
|
if (entityType === "schedule_run") return mockState.db.scheduleRuns;
|
|
if (entityType === "artifact") return mockState.db.artifacts;
|
|
if (entityType === "approval") return mockState.db.approvals;
|
|
if (entityType === "memory") return mockState.db.memories;
|
|
if (entityType === "event") return mockState.db.events;
|
|
return undefined;
|
|
}
|
|
|
|
function readAttempts(): Array<Record<string, unknown>> {
|
|
return mockState.db?.attempts.map((attempt) => ({ ...attempt })) ?? [];
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
|
} 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 {
|
|
id: string;
|
|
entity_type: string;
|
|
entity_id: string;
|
|
operation: string;
|
|
payload: string;
|
|
status: string;
|
|
attempts: number;
|
|
last_attempt_at: string | null;
|
|
error: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface TestSyncEntityRow {
|
|
id: string;
|
|
title?: string;
|
|
status?: string;
|
|
objective?: string | null;
|
|
payload?: string;
|
|
plan_id?: string | null;
|
|
task_id?: string | null;
|
|
run_id?: string | null;
|
|
kind?: string;
|
|
message?: string;
|
|
occurred_at?: string;
|
|
remote_id: string | null;
|
|
sync_status: string;
|
|
sync_error: string | null;
|
|
last_synced_at: string | null;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
}
|
|
|
|
interface TestAttemptRow {
|
|
id: number;
|
|
outbox_id: string;
|
|
entity_type: string;
|
|
entity_id: string;
|
|
operation: string;
|
|
attempt_no: number;
|
|
status: string;
|
|
error: string | null;
|
|
endpoint: string | null;
|
|
started_at: string;
|
|
finished_at: string;
|
|
}
|
|
|
|
class TestDb {
|
|
outbox = new Map<string, TestOutboxRow>();
|
|
plans = new Map<string, TestSyncEntityRow>();
|
|
tasks = new Map<string, TestSyncEntityRow>();
|
|
planSteps = new Map<string, TestSyncEntityRow>();
|
|
runs = new Map<string, TestSyncEntityRow>();
|
|
schedules = new Map<string, TestSyncEntityRow>();
|
|
scheduleRuns = new Map<string, TestSyncEntityRow>();
|
|
artifacts = new Map<string, TestSyncEntityRow>();
|
|
approvals = new Map<string, TestSyncEntityRow>();
|
|
memories = new Map<string, TestSyncEntityRow>();
|
|
events = new Map<string, TestSyncEntityRow>();
|
|
attempts: TestAttemptRow[] = [];
|
|
private nextAttemptId = 1;
|
|
|
|
transaction<T>(callback: () => T): () => T {
|
|
return callback;
|
|
}
|
|
|
|
prepare(sql: string): {
|
|
all: (...args: unknown[]) => unknown[];
|
|
get: (...args: unknown[]) => unknown;
|
|
run: (...args: unknown[]) => unknown;
|
|
} {
|
|
const normalized = sql.replace(/\s+/g, " ").trim();
|
|
return {
|
|
all: (...args: unknown[]) => this.all(normalized, args),
|
|
get: (...args: unknown[]) => this.get(normalized, args),
|
|
run: (...args: unknown[]) => this.run(normalized, args),
|
|
};
|
|
}
|
|
|
|
private all(sql: string, args: unknown[]): unknown[] {
|
|
if (
|
|
sql.includes("FROM digital_sync_outbox") &&
|
|
sql.includes("status IN ('pending', 'failed')")
|
|
) {
|
|
const limit = Number(args[0]);
|
|
return Array.from(this.outbox.values())
|
|
.filter((row) => row.status === "pending" || row.status === "failed")
|
|
.sort((left, right) => left.created_at.localeCompare(right.created_at))
|
|
.slice(0, limit)
|
|
.map(copyRow);
|
|
}
|
|
|
|
if (
|
|
sql.includes("FROM digital_sync_outbox") &&
|
|
sql.includes("WHERE status = 'failed'") &&
|
|
sql.includes("SELECT entity_type")
|
|
) {
|
|
return Array.from(this.outbox.values())
|
|
.filter((row) => row.status === "failed")
|
|
.map((row) => ({
|
|
entity_type: row.entity_type,
|
|
attempts: row.attempts,
|
|
last_attempt_at: row.last_attempt_at,
|
|
}));
|
|
}
|
|
|
|
if (
|
|
sql.includes("FROM digital_sync_outbox") &&
|
|
sql.includes("WHERE status = 'failed'") &&
|
|
sql.includes("ORDER BY COALESCE")
|
|
) {
|
|
const limit = Number(args[0]);
|
|
return Array.from(this.outbox.values())
|
|
.filter((row) => row.status === "failed")
|
|
.sort((left, right) =>
|
|
recentFailureSortKey(right).localeCompare(recentFailureSortKey(left)),
|
|
)
|
|
.slice(0, limit)
|
|
.map(copyRow);
|
|
}
|
|
|
|
if (sql.includes("FROM digital_sync_attempts")) {
|
|
const [outboxId, limit] = args as [string, number];
|
|
return this.attempts
|
|
.filter((row) => row.outbox_id === outboxId)
|
|
.sort((left, right) => {
|
|
const byTime = right.finished_at.localeCompare(left.finished_at);
|
|
return byTime || right.id - left.id;
|
|
})
|
|
.slice(0, limit)
|
|
.map((row) => ({
|
|
attempt_no: row.attempt_no,
|
|
status: row.status,
|
|
error: row.error,
|
|
endpoint: row.endpoint,
|
|
started_at: row.started_at,
|
|
finished_at: row.finished_at,
|
|
}));
|
|
}
|
|
|
|
throw new Error(`Unhandled all query: ${sql}`);
|
|
}
|
|
|
|
private get(sql: string, args: unknown[]): unknown {
|
|
if (sql.startsWith("SELECT title, status, objective, payload FROM digital_plans")) {
|
|
const [id] = args as [string];
|
|
return this.plans.get(id);
|
|
}
|
|
|
|
if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_tasks")) {
|
|
const [id] = args as [string];
|
|
return this.tasks.get(id);
|
|
}
|
|
|
|
if (sql.startsWith("SELECT id AS title, status, NULL AS objective, payload FROM digital_runs")) {
|
|
const [id] = args as [string];
|
|
const row = this.runs.get(id);
|
|
if (!row) return undefined;
|
|
return {
|
|
title: row.id,
|
|
status: row.status,
|
|
objective: null,
|
|
payload: row.payload,
|
|
};
|
|
}
|
|
|
|
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.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);
|
|
}
|
|
|
|
if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_plan_steps")) {
|
|
const [id] = args as [string];
|
|
return this.planSteps.get(id);
|
|
}
|
|
|
|
if (sql.startsWith("SELECT name AS title, status, description AS objective, payload FROM digital_schedules")) {
|
|
const [id] = args as [string];
|
|
return this.schedules.get(id);
|
|
}
|
|
|
|
if (sql.startsWith("SELECT id AS title, status, error AS objective, payload FROM digital_schedule_runs")) {
|
|
const [id] = args as [string];
|
|
return this.scheduleRuns.get(id);
|
|
}
|
|
|
|
if (sql.startsWith("SELECT kind AS title, kind AS status, message, payload FROM digital_events")) {
|
|
const [id] = args as [string];
|
|
const row = this.events.get(id);
|
|
if (!row) return undefined;
|
|
return {
|
|
title: row.kind,
|
|
status: row.kind,
|
|
message: row.message,
|
|
payload: row.payload,
|
|
};
|
|
}
|
|
|
|
if (sql.startsWith("SELECT plan_id, task_id, run_id, kind, message, payload, occurred_at FROM digital_events")) {
|
|
const [id] = args as [string];
|
|
return this.events.get(id);
|
|
}
|
|
|
|
if (
|
|
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
|
|
) {
|
|
const [status] = args;
|
|
return {
|
|
count: Array.from(this.outbox.values()).filter(
|
|
(row) => row.status === status,
|
|
).length,
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unhandled get query: ${sql}`);
|
|
}
|
|
|
|
private run(sql: string, args: unknown[]): unknown {
|
|
if (sql.startsWith("INSERT INTO digital_plans")) {
|
|
const [id, title, objective, status, payload, createdAt, updatedAt] = args as [
|
|
string,
|
|
string,
|
|
string | null,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
];
|
|
const previous = this.plans.get(id);
|
|
this.plans.set(id, {
|
|
id,
|
|
title,
|
|
objective,
|
|
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,
|
|
});
|
|
return { changes: 1 };
|
|
}
|
|
|
|
if (sql.startsWith("INSERT INTO digital_tasks")) {
|
|
const [id, planId, title, status, assignedSkillId, payload] = args as [
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
];
|
|
const previous = this.tasks.get(id);
|
|
this.tasks.set(id, {
|
|
id,
|
|
plan_id: planId,
|
|
title,
|
|
status,
|
|
payload: JSON.stringify({ ...JSON.parse(payload), assigned_skill_id: assignedSkillId }),
|
|
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,
|
|
});
|
|
return { changes: 1 };
|
|
}
|
|
|
|
if (sql.startsWith("INSERT INTO digital_runs")) {
|
|
const [id, planId, taskId, status, startedAt, payload] = args as [
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
];
|
|
const previous = this.runs.get(id);
|
|
this.runs.set(id, {
|
|
id,
|
|
plan_id: planId,
|
|
task_id: taskId,
|
|
status,
|
|
payload: JSON.stringify({ ...JSON.parse(payload), started_at: startedAt }),
|
|
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,
|
|
});
|
|
return { changes: 1 };
|
|
}
|
|
|
|
if (sql.startsWith("UPDATE digital_runs SET status = ?, finished_at = ?, updated_at = ?")) {
|
|
const [status, finishedAt, _updatedAt, id] = args as [string, string, string, string];
|
|
const row = this.runs.get(id);
|
|
if (row) {
|
|
row.status = status;
|
|
row.payload = JSON.stringify({ ...JSON.parse(row.payload ?? "{}"), finished_at: finishedAt });
|
|
row.sync_status = "pending";
|
|
}
|
|
return { changes: row ? 1 : 0 };
|
|
}
|
|
|
|
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) {
|
|
const [id, planId, taskId, runId, kind, message, payload, occurredAt] = args as [
|
|
string,
|
|
string | null,
|
|
string | null,
|
|
string | null,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
];
|
|
if (!this.events.has(id)) {
|
|
this.events.set(id, {
|
|
id,
|
|
plan_id: planId,
|
|
task_id: taskId,
|
|
run_id: runId,
|
|
kind,
|
|
message,
|
|
payload,
|
|
remote_id: null,
|
|
sync_status: "pending",
|
|
sync_error: null,
|
|
last_synced_at: null,
|
|
occurred_at: occurredAt,
|
|
});
|
|
}
|
|
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,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
];
|
|
const previous = this.outbox.get(id);
|
|
this.outbox.set(id, {
|
|
id,
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
operation,
|
|
payload,
|
|
status: "pending",
|
|
attempts: previous?.attempts ?? 0,
|
|
last_attempt_at: previous?.last_attempt_at ?? null,
|
|
error: null,
|
|
created_at: previous?.created_at ?? createdAt,
|
|
updated_at: updatedAt,
|
|
});
|
|
return { changes: 1 };
|
|
}
|
|
|
|
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'syncing'")) {
|
|
const [lastAttemptAt, updatedAt, id] = args as [string, string, string];
|
|
const row = this.outbox.get(id);
|
|
if (row) {
|
|
row.status = "syncing";
|
|
row.attempts += 1;
|
|
row.last_attempt_at = lastAttemptAt;
|
|
row.updated_at = updatedAt;
|
|
}
|
|
return { changes: row ? 1 : 0 };
|
|
}
|
|
|
|
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'synced'")) {
|
|
const [updatedAt, id] = args as [string, string];
|
|
const row = this.outbox.get(id);
|
|
if (row) {
|
|
row.status = "synced";
|
|
row.error = null;
|
|
row.updated_at = updatedAt;
|
|
}
|
|
return { changes: row ? 1 : 0 };
|
|
}
|
|
|
|
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'failed'")) {
|
|
const [error, updatedAt, id] = args as [string, string, string];
|
|
const row = this.outbox.get(id);
|
|
if (row) {
|
|
row.status = "failed";
|
|
row.error = error;
|
|
row.updated_at = updatedAt;
|
|
}
|
|
return { changes: row ? 1 : 0 };
|
|
}
|
|
|
|
if (sql.startsWith("INSERT INTO digital_sync_attempts")) {
|
|
const [
|
|
outboxId,
|
|
entityType,
|
|
entityId,
|
|
operation,
|
|
attemptNo,
|
|
status,
|
|
error,
|
|
endpoint,
|
|
startedAt,
|
|
finishedAt,
|
|
] = args as [
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
number,
|
|
string,
|
|
string | null,
|
|
string | null,
|
|
string,
|
|
string,
|
|
];
|
|
this.attempts.push({
|
|
id: this.nextAttemptId,
|
|
outbox_id: outboxId,
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
operation,
|
|
attempt_no: attemptNo,
|
|
status,
|
|
error,
|
|
endpoint,
|
|
started_at: startedAt,
|
|
finished_at: finishedAt,
|
|
});
|
|
this.nextAttemptId += 1;
|
|
return { changes: 1 };
|
|
}
|
|
|
|
if (sql.startsWith("DELETE FROM digital_sync_attempts WHERE finished_at < ?")) {
|
|
const [cutoff] = args as [string];
|
|
const before = this.attempts.length;
|
|
this.attempts = this.attempts.filter((row) => row.finished_at >= cutoff);
|
|
return { changes: before - this.attempts.length };
|
|
}
|
|
|
|
if (/^UPDATE digital_(plans|tasks|plan_steps|runs|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
|
|
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
|
|
const row = this.entityRowsForUpdate(sql).get(id);
|
|
if (row) {
|
|
row.sync_status = "synced";
|
|
row.remote_id = remoteId ?? row.remote_id;
|
|
row.last_synced_at = lastSyncedAt;
|
|
row.sync_error = null;
|
|
}
|
|
return { changes: row ? 1 : 0 };
|
|
}
|
|
|
|
if (/^UPDATE digital_(plans|tasks|plan_steps|runs|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
|
|
const [syncError, id] = args as [string, string];
|
|
const row = this.entityRowsForUpdate(sql).get(id);
|
|
if (row) {
|
|
row.sync_status = "failed";
|
|
row.sync_error = syncError;
|
|
}
|
|
return { changes: row ? 1 : 0 };
|
|
}
|
|
|
|
throw new Error(`Unhandled run query: ${sql}`);
|
|
}
|
|
|
|
private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> {
|
|
if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts;
|
|
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
|
|
if (sql.startsWith("UPDATE digital_memories")) return this.memories;
|
|
if (sql.startsWith("UPDATE digital_events")) return this.events;
|
|
if (sql.startsWith("UPDATE digital_runs")) return this.runs;
|
|
if (sql.startsWith("UPDATE digital_schedule_runs")) return this.scheduleRuns;
|
|
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
|
|
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
|
|
if (sql.startsWith("UPDATE digital_tasks")) return this.tasks;
|
|
return this.plans;
|
|
}
|
|
}
|
|
|
|
function copyRow<T extends object>(row: T): T {
|
|
return { ...row };
|
|
}
|
|
|
|
function recentFailureSortKey(row: TestOutboxRow): string {
|
|
return row.last_attempt_at || row.updated_at || row.created_at;
|
|
}
|