feat(client): persist digital governance events

This commit is contained in:
baiyanyun
2026-06-07 18:59:59 +08:00
parent 5e76828afe
commit e37024bdbe
14 changed files with 925 additions and 158 deletions

View File

@@ -2,11 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
db: null as TestDb | null,
ensureSchemaCalls: 0,
settings: new Map<string, unknown>(),
}));
vi.mock("../../db", () => ({
ensureDigitalEmployeeSchema: () => true,
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),
@@ -17,6 +21,7 @@ describe("digital employee state service", () => {
vi.resetModules();
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
mockState.db = new TestDb();
mockState.ensureSchemaCalls = 0;
mockState.settings = new Map<string, unknown>();
});
@@ -75,6 +80,7 @@ describe("digital employee state service", () => {
entity_id: "computer-chat-run-req-1:approval:approval-1",
status: "pending",
});
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("updates formal approval records when UI approval decisions are saved", async () => {
@@ -135,9 +141,112 @@ describe("digital employee state service", () => {
entity_id: "approval-1",
status: "pending",
});
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("records governance commands as formal runtime records", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
const ids = recordDigitalEmployeeGovernanceCommand({
commandKind: "create_plan",
commandId: "command-1",
correlationId: "corr-1",
accepted: true,
message: "已创建计划草稿",
payload: {
title: "生成今日客户跟进计划",
objective: "把今日客户跟进事项整理成可执行计划",
},
result: {
plan_id: "plan-customer-followup",
status: "draft",
},
});
expect(ids).toEqual({
planId: "plan-customer-followup",
taskId: "governance-task-command-1",
runId: "governance-run-command-1",
});
expect(mockState.db?.plans.get("plan-customer-followup")).toMatchObject({
id: "plan-customer-followup",
title: "生成今日客户跟进计划",
status: "draft",
sync_status: "pending",
});
expect(mockState.db?.tasks.get("governance-task-command-1")).toMatchObject({
plan_id: "plan-customer-followup",
title: "创建数字员工计划",
assigned_skill_id: "qimingclaw-governance-command",
});
expect(mockState.db?.runs.get("governance-run-command-1")).toMatchObject({
plan_id: "plan-customer-followup",
task_id: "governance-task-command-1",
status: "draft",
finished_at: "2026-06-07T08:00:00.000Z",
});
expect(mockState.db?.events.get("governance-run-command-1:create_plan:command-1")).toMatchObject({
kind: "governance_create_plan",
message: "已创建计划草稿",
});
expect(mockState.db?.outbox.get("plan:upsert:plan-customer-followup")).toMatchObject({
entity_type: "plan",
entity_id: "plan-customer-followup",
status: "pending",
});
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
});
interface TestPlanRow {
id: string;
title: string;
objective: string;
status: string;
payload: string;
sync_status: string;
created_at: string;
updated_at: string;
}
interface TestTaskRow {
id: string;
plan_id: string;
title: string;
status: string;
assigned_skill_id: string;
payload: string;
sync_status: string;
created_at: string;
updated_at: string;
}
interface TestRunRow {
id: string;
plan_id: string;
task_id: string;
status: string;
started_at: string;
finished_at: string | null;
payload: string;
sync_status: string;
created_at: string;
updated_at: string;
}
interface TestEventRow {
id: string;
plan_id: string;
task_id: string;
run_id: string;
kind: string;
message: string;
payload: string;
sync_status: string;
occurred_at: string;
created_at: string;
}
interface TestArtifactRow {
id: string;
plan_id: string;
@@ -178,6 +287,10 @@ interface TestOutboxRow {
}
class TestDb {
plans = new Map<string, TestPlanRow>();
tasks = new Map<string, TestTaskRow>();
runs = new Map<string, TestRunRow>();
events = new Map<string, TestEventRow>();
artifacts = new Map<string, TestArtifactRow>();
approvals = new Map<string, TestApprovalRow>();
outbox = new Map<string, TestOutboxRow>();
@@ -207,11 +320,126 @@ class TestDb {
}
private run(sql: string, args: unknown[]): unknown {
if (sql.startsWith("INSERT INTO digital_plans")) return { changes: 1 };
if (sql.startsWith("INSERT INTO digital_tasks")) return { changes: 1 };
if (sql.startsWith("INSERT INTO digital_runs")) return { changes: 1 };
if (sql.startsWith("UPDATE digital_runs")) return { changes: 1 };
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) return { changes: 1 };
if (sql.startsWith("INSERT INTO digital_plans")) {
const [id, title, objective, status, payload, createdAt, updatedAt] = args as [
string,
string,
string,
string,
string,
string,
string,
];
const previous = this.plans.get(id);
this.plans.set(id, {
id,
title,
objective,
status,
payload,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
created_at: previous?.created_at ?? createdAt,
updated_at: updatedAt,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_tasks")) {
const [id, planId, title, status, assignedSkillId, payload, createdAt, updatedAt] = 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,
assigned_skill_id: assignedSkillId,
payload,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
created_at: previous?.created_at ?? createdAt,
updated_at: updatedAt,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_runs")) {
const [id, planId, taskId, status, startedAt, payload, createdAt, updatedAt] = 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,
started_at: previous?.started_at ?? startedAt,
finished_at: previous?.finished_at ?? null,
payload,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
created_at: previous?.created_at ?? createdAt,
updated_at: updatedAt,
});
return { changes: 1 };
}
if (sql.startsWith("UPDATE digital_runs")) {
const [status, finishedAt, updatedAt, id] = args as [string, string, string, string];
const previous = this.runs.get(id);
if (previous) {
this.runs.set(id, {
...previous,
status,
finished_at: finishedAt,
updated_at: updatedAt,
sync_status: "pending",
});
}
return { changes: previous ? 1 : 0 };
}
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) {
const [id, planId, taskId, runId, kind, message, payload, occurredAt, createdAt] = args as [
string,
string,
string,
string,
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,
sync_status: "pending",
occurred_at: occurredAt,
created_at: createdAt,
});
}
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_artifacts")) {
const [id, planId, taskId, runId, kind, name, uri, payload, createdAt] = args as [

View File

@@ -203,6 +203,19 @@ export interface DigitalEmployeeRuntimeEventRecord {
payload?: unknown;
}
export interface DigitalEmployeeGovernanceCommandRecord {
commandKind: string;
commandId: string;
correlationId?: string;
planId?: string | null;
scheduleId?: string | null;
accepted?: boolean;
message?: string;
error?: string | null;
payload?: Record<string, unknown> | null;
result?: Record<string, unknown> | null;
}
function defaultState(): DigitalEmployeeState {
return {
version: 1,
@@ -681,6 +694,140 @@ export function recordDigitalEmployeeRuntimeEvent(
tx();
}
export function recordDigitalEmployeeGovernanceCommand(
command: DigitalEmployeeGovernanceCommandRecord,
): { planId: string; taskId: string; runId: string } {
const now = new Date().toISOString();
const commandId = command.commandId || `governance-${Date.now()}`;
const result = objectRecord(command.result) ?? {};
const payload = objectRecord(command.payload) ?? {};
const planId = stringValue(command.planId)
|| stringValue(result.plan_id)
|| stringValue(payload.plan_id)
|| `governance-plan-${safeId(commandId)}`;
const ids = {
planId,
taskId: `governance-task-${safeId(commandId)}`,
runId: `governance-run-${safeId(commandId)}`,
};
const title = stringValue(payload.title ?? result.title)
|| governanceCommandTitle(command.commandKind);
const status = governanceCommandStatus(command);
const payloadEnvelope = {
source: "qimingclaw-governance-command",
commandKind: command.commandKind,
commandId,
correlationId: command.correlationId,
planId,
scheduleId: command.scheduleId || stringValue(result.schedule_id) || null,
accepted: command.accepted !== false,
message: command.message,
error: command.error,
payload,
result,
};
const db = getDigitalEmployeeDb();
if (!db) return ids;
const tx = db.transaction(() => {
upsertPlan({
id: ids.planId,
title,
objective: stringValue(payload.objective ?? payload.description ?? payload.requested_action)
|| command.message
|| title,
status,
payload: payloadEnvelope,
now,
});
upsertTask({
id: ids.taskId,
planId: ids.planId,
title: governanceCommandTitle(command.commandKind),
status,
assignedSkillId: "qimingclaw-governance-command",
payload: payloadEnvelope,
now,
});
upsertRun({
id: ids.runId,
planId: ids.planId,
taskId: ids.taskId,
status,
payload: payloadEnvelope,
now,
});
if (status !== "running" && status !== "pending") {
finishRun(ids.runId, status, now);
}
persistPayloadArtifactsAndApprovals(ids, payloadEnvelope, now);
insertEvent(
{
event_id: `${ids.runId}:${command.commandKind}:${safeId(commandId)}`,
kind: `governance_${command.commandKind}`,
occurred_at: now,
message: command.message || `${governanceCommandTitle(command.commandKind)} 已记录`,
plan_id: ids.planId,
task_id: ids.taskId,
},
ids.runId,
payloadEnvelope,
);
});
tx();
return ids;
}
function governanceCommandTitle(commandKind: string): string {
switch (commandKind) {
case "create_plan":
return "创建数字员工计划";
case "submit_plan":
return "提交数字员工计划";
case "approve_plan":
return "批准数字员工计划";
case "activate_plan":
return "激活数字员工计划";
case "create_schedule":
return "创建数字员工定时任务";
case "run_schedule_now":
return "立即运行数字员工计划";
case "pause_plan":
return "暂停数字员工计划";
case "cancel_plan":
return "取消数字员工计划";
default:
return `数字员工治理命令:${commandKind || "unknown"}`;
}
}
function governanceCommandStatus(
command: DigitalEmployeeGovernanceCommandRecord,
): string {
if (command.accepted === false || command.error) return "failed";
const resultStatus = stringValue(command.result?.status);
if (resultStatus) return resultStatus;
switch (command.commandKind) {
case "activate_plan":
case "run_schedule_now":
return "running";
case "create_plan":
return "draft";
case "submit_plan":
return "reviewing";
case "approve_plan":
return "approved";
case "pause_plan":
return "paused";
case "cancel_plan":
return "cancelled";
default:
return "completed";
}
}
function persistSnapshotTables(
snapshot: DigitalEmployeeSnapshot,
event: DigitalEmployeeStateEvent | null,

View File

@@ -2,6 +2,7 @@ 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(),
}));
@@ -20,7 +21,10 @@ vi.mock("../system/deviceId", () => ({
}));
vi.mock("../../db", () => ({
ensureDigitalEmployeeSchema: () => true,
ensureDigitalEmployeeSchema: () => {
mockState.ensureSchemaCalls += 1;
return true;
},
getDb: () => mockState.db,
readSetting: (key: string) => mockState.settings.get(key) ?? null,
}));
@@ -31,6 +35,7 @@ describe("digital employee sync service", () => {
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" }],
@@ -62,6 +67,7 @@ describe("digital employee sync service", () => {
status: "pending",
attempts: 0,
});
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("marks entity-only acknowledgements as synced", async () => {
@@ -103,6 +109,7 @@ describe("digital employee sync service", () => {
error: null,
}),
]);
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("keeps unacknowledged rows retryable after a partial backend ack", async () => {
@@ -146,6 +153,11 @@ describe("digital employee sync service", () => {
});
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",
@@ -216,6 +228,10 @@ function insertPlan(id: string): void {
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,
@@ -292,6 +308,10 @@ interface TestOutboxRow {
interface TestSyncEntityRow {
id: string;
title?: string;
status?: string;
objective?: string | null;
payload?: string;
remote_id: string | null;
sync_status: string;
sync_error: string | null;
@@ -402,6 +422,11 @@ class TestDb {
}
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.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
) {

View File

@@ -66,6 +66,7 @@ export interface DigitalEmployeeSyncFailure {
id: string;
entityType: string;
entityId: string;
entitySummary: DigitalEmployeeSyncEntitySummary | null;
operation: string;
attempts: number;
lastAttemptAt: string | null;
@@ -78,6 +79,12 @@ export interface DigitalEmployeeSyncFailure {
updatedAt: string;
}
export interface DigitalEmployeeSyncEntitySummary {
title: string | null;
status: string | null;
summary: string | null;
}
interface SyncAck {
outbox_id?: string;
id?: string;
@@ -580,6 +587,7 @@ function readRecentFailures(
id: row.id,
entityType: row.entity_type,
entityId: row.entity_id,
entitySummary: readEntitySummary(row.entity_type, row.entity_id),
operation: row.operation,
attempts: row.attempts,
lastAttemptAt: row.last_attempt_at,
@@ -594,6 +602,58 @@ function readRecentFailures(
});
}
function readEntitySummary(
entityType: string,
entityId: string,
): DigitalEmployeeSyncEntitySummary | null {
const table = entityTable(entityType);
const db = getDigitalEmployeeDb();
if (!db || !table) return null;
const select = entitySummarySelect(entityType);
if (!select) return null;
const row = db.prepare(select).get(entityId) as Record<string, unknown> | undefined;
if (!row) return null;
const payload = parsePayload(String(row.payload ?? "{}"));
const payloadSummary = payload && typeof payload === "object" && !Array.isArray(payload)
? payload as Record<string, unknown>
: {};
return {
title: stringField(row.title) || stringField(payloadSummary.title) || null,
status: stringField(row.status) || stringField(payloadSummary.status) || null,
summary:
stringField(row.summary) ||
stringField(row.objective) ||
stringField(row.message) ||
stringField(payloadSummary.objective) ||
stringField(payloadSummary.message) ||
stringField(payloadSummary.summary) ||
null,
};
}
function entitySummarySelect(entityType: string): string | null {
switch (entityType) {
case "plan":
return "SELECT title, status, objective, payload FROM digital_plans WHERE id = ?";
case "task":
return "SELECT title, status, NULL AS objective, payload FROM digital_tasks WHERE id = ?";
case "run":
return "SELECT id AS title, status, NULL AS objective, payload FROM digital_runs WHERE id = ?";
case "event":
return "SELECT kind AS title, kind AS status, message, payload FROM digital_events WHERE id = ?";
case "artifact":
return "SELECT name AS title, kind AS status, uri AS summary, payload FROM digital_artifacts WHERE id = ?";
case "approval":
return "SELECT title, status, NULL AS objective, payload FROM digital_approvals WHERE id = ?";
default:
return null;
}
}
function stringField(value: unknown): string {
return typeof value === "string" && value.trim() ? value.trim() : "";
}
function readAttemptHistory(
outboxId: string,
limit = 5,