吸收数字员工管理端事件视图

This commit is contained in:
baiyanyun
2026-06-10 15:16:23 +08:00
parent 4f489f402a
commit 13e48df63d
3 changed files with 344 additions and 13 deletions

View File

@@ -488,6 +488,126 @@ describe("digital employee sync service", () => {
});
});
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",
},
});
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-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-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-dispatch", "plan_step_dispatch_failed", {
step_id: "step-1",
status: "failed",
reason: "computer chat failed",
});
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{ entity_type: "event", entity_id: "event-route" },
{ entity_type: "event", entity_id: "event-approval" },
{ entity_type: "event", entity_id: "event-artifact" },
{ entity_type: "event", entity_id: "event-skill" },
{ entity_type: "event", entity_id: "event-dispatch" },
],
}),
);
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",
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-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-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-dispatch")).toMatchObject({
category: "dispatch",
step_id: "step-1",
status: "failed",
reason: "computer chat failed",
});
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");
@@ -524,17 +644,43 @@ function insertEntity(entityType: string, id: string): void {
});
}
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: "upsert",
operation,
payload,
status: "pending",
attempts: 0,
@@ -571,6 +717,7 @@ function entityMap(
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;
}
@@ -611,6 +758,12 @@ interface TestSyncEntityRow {
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;
@@ -640,6 +793,7 @@ class TestDb {
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;
@@ -760,6 +914,23 @@ class TestDb {
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 = ?")
) {
@@ -857,7 +1028,7 @@ class TestDb {
return { changes: before - this.attempts.length };
}
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
if (/^UPDATE digital_(plans|plan_steps|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) {
@@ -869,7 +1040,7 @@ class TestDb {
return { changes: row ? 1 : 0 };
}
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
if (/^UPDATE digital_(plans|plan_steps|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) {
@@ -886,6 +1057,7 @@ class TestDb {
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_schedule_runs")) return this.scheduleRuns;
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;