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

@@ -34,6 +34,7 @@ declare global {
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
flushSync?: () => Promise<QimingclawSyncStatus>;
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
};
};
}
@@ -124,6 +125,19 @@ interface QimingclawSnapshot {
uiState?: AdapterState | null;
}
interface QimingclawGovernanceCommandRecord {
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;
}
interface QimingclawRuntimeRecords {
generatedAt: string;
plans: QimingclawPlanRecord[];
@@ -519,6 +533,34 @@ async function saveState(
}
}
async function recordGovernanceCommand(
body: GovernanceCommandRequest,
response: GovernanceCommandResponse,
): Promise<void> {
try {
await window.QimingClawBridge?.digital?.recordGovernanceCommand?.({
commandKind: body.command_kind,
commandId: response.command_id,
correlationId: response.correlation_id,
planId: stringFromUnknown(response.result?.plan_id)
|| stringFromUnknown(body.context_refs?.plan_id)
|| stringFromUnknown(body.payload?.plan_id)
|| null,
scheduleId: stringFromUnknown(response.result?.schedule_id)
|| stringFromUnknown(body.context_refs?.schedule_id)
|| stringFromUnknown(body.payload?.schedule_id)
|| null,
accepted: response.accepted,
message: response.message,
error: response.error ?? null,
payload: body.payload ?? null,
result: response.result ?? null,
});
} catch {
// Bridge persistence is best-effort; the adapter response should not block UI flow.
}
}
function parseJsonBody<T>(body: BodyInit | null | undefined): T {
if (typeof body !== 'string' || !body.trim()) return {} as T;
return JSON.parse(body) as T;
@@ -1689,37 +1731,43 @@ async function handleGovernanceCommand(
if (body.command_kind === 'create_plan') {
const createdPlanId = `qimingclaw-local-${slugifyIdPart(stringFromUnknown(body.payload?.title) || planId || 'plan')}-${Date.now()}`;
return governanceAccepted(commandId, correlationId, '已在 qimingclaw 兼容层创建本地计划草稿。', {
const response = governanceAccepted(commandId, correlationId, '已在 qimingclaw 兼容层创建本地计划草稿。', {
plan_id: createdPlanId,
status: 'draft',
source: 'qimingclaw-adapter',
requested_action: body.payload?.requested_action,
original_plan_id: body.payload?.original_plan_id ?? planId,
});
await recordGovernanceCommand(body, response);
return response;
}
if (body.command_kind === 'activate_plan' || body.command_kind === 'run_schedule_now') {
const targetPlanId = planId || runtimeDebugPlans(snapshot)[0]?.plan_id || PLANS[0]?.plan_id || 'qimingclaw-client-readiness';
const dispatch = await dispatchPlan(targetPlanId, snapshot);
return governanceAccepted(commandId, correlationId, '已映射为 qimingclaw 本地任务激活。', {
const response = governanceAccepted(commandId, correlationId, '已映射为 qimingclaw 本地任务激活。', {
plan_id: targetPlanId,
status: 'active',
dispatched_tasks: dispatch.dispatched_tasks,
source: 'qimingclaw-adapter',
});
await recordGovernanceCommand(body, response);
return response;
}
if (body.command_kind === 'create_schedule') {
return governanceAccepted(commandId, correlationId, '已记录 qimingclaw 兼容定时任务请求。', {
const response = governanceAccepted(commandId, correlationId, '已记录 qimingclaw 兼容定时任务请求。', {
schedule_id: scheduleId || `qimingclaw-schedule-${Date.now()}`,
plan_id: planId,
status: 'enabled',
source: 'qimingclaw-adapter',
});
await recordGovernanceCommand(body, response);
return response;
}
if (['submit_plan', 'approve_plan', 'amend_plan', 'resume_plan', 'pause_plan', 'cancel_plan'].includes(body.command_kind)) {
return governanceAccepted(commandId, correlationId, `已接受 ${body.command_kind},当前由 qimingclaw 本地 adapter 记录兼容状态。`, {
const response = governanceAccepted(commandId, correlationId, `已接受 ${body.command_kind},当前由 qimingclaw 本地 adapter 记录兼容状态。`, {
plan_id: planId,
status: body.command_kind === 'approve_plan'
? 'approved'
@@ -1728,15 +1776,19 @@ async function handleGovernanceCommand(
: 'accepted',
source: 'qimingclaw-adapter',
});
await recordGovernanceCommand(body, response);
return response;
}
return {
const response = {
command_id: commandId,
accepted: false,
correlation_id: correlationId,
error: 'missing_capability',
message: `qimingclaw 数字员工兼容层尚未支持 ${body.command_kind}`,
};
await recordGovernanceCommand(body, response);
return response;
}
function governanceAccepted(

View File

@@ -86,6 +86,7 @@ interface ManagementSyncFailure {
id: string;
entityType: string;
entityId: string;
entitySummary?: ManagementSyncEntitySummary | null;
operation: string;
attempts: number;
lastAttemptAt: string | null;
@@ -98,6 +99,12 @@ interface ManagementSyncFailure {
updatedAt: string;
}
interface ManagementSyncEntitySummary {
title?: string | null;
status?: string | null;
summary?: string | null;
}
// 技术侧 PlanTemplate 是任务模板来源;用户侧只展示为“任务”,运行时 Task/PlanStep 展示为“步骤”。
interface EmployeeVisualStateOption {
@@ -523,7 +530,9 @@ function failureGroupSummary(summary: ManagementSyncFailureSummary): string {
function syncFailureSummary(failure: ManagementSyncFailure): string {
const error = summarizeSyncError(failure.error);
const title = compactText(failure.entitySummary?.title);
return [
title,
`${entityTypeLabel(failure.entityType)} / ${failure.operation}`,
`重试 ${failure.attempts}`,
failure.dueForRetry ? '等待补偿' : `下次 ${syncFailureRetryTime(failure)}`,
@@ -2009,6 +2018,12 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<div className="de-event-detail-grid">
<div><span>Outbox</span><strong>{selectedSyncFailure.id}</strong></div>
<div><span></span><strong>{selectedSyncFailure.entityId}</strong></div>
{selectedSyncFailure.entitySummary?.title && (
<div><span></span><strong>{selectedSyncFailure.entitySummary.title}</strong></div>
)}
{selectedSyncFailure.entitySummary?.status && (
<div><span></span><strong>{selectedSyncFailure.entitySummary.status}</strong></div>
)}
<div><span></span><strong>{entityTypeLabel(selectedSyncFailure.entityType)}</strong></div>
<div><span></span><strong>{selectedSyncFailure.operation}</strong></div>
<div><span></span><strong>{selectedSyncFailure.attempts} </strong></div>
@@ -2020,6 +2035,12 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span></span>
<p>{selectedSyncFailure.error || '暂无错误详情。'}</p>
</div>
{selectedSyncFailure.entitySummary?.summary && (
<div className="de-event-detail-result">
<span></span>
<p>{selectedSyncFailure.entitySummary.summary}</p>
</div>
)}
{(selectedSyncFailure.attemptHistory?.length ?? 0) > 0 && (
<div className="de-sync-attempt-history">
<span></span>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-C2-aThxb.js"></script>
<script type="module" crossorigin src="./assets/index-DDA_E4Qy.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
</head>
<body>

View File

@@ -10,10 +10,12 @@ import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
import { isWindows } from "../services/system/shellEnv";
import {
recordDigitalEmployeeSnapshot,
recordDigitalEmployeeGovernanceCommand,
readDigitalEmployeeRuntimeRecords,
readDigitalEmployeeState,
readDigitalEmployeeUiState,
saveDigitalEmployeeUiState,
type DigitalEmployeeGovernanceCommandRecord,
type DigitalEmployeeServiceStatus,
type DigitalEmployeeSnapshot,
type DigitalEmployeeUiStateUpdate,
@@ -66,6 +68,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
ipcMain.handle("digitalEmployee:flushSync", async () => {
return flushDigitalEmployeeSyncOutbox({ force: true });
});
ipcMain.handle(
"digitalEmployee:recordGovernanceCommand",
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {
return recordDigitalEmployeeGovernanceCommand(command);
},
);
}
interface DigitalEmployeeSkillCapability {

View File

@@ -10,6 +10,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockDigitalEmployee = vi.hoisted(() => ({
recordRuntimeEvent: vi.fn(),
}));
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
@@ -23,6 +27,10 @@ vi.mock("../services/computerServer", () => ({
pushSseEvent: vi.fn(),
}));
vi.mock("../services/digitalEmployee/stateService", () => ({
recordDigitalEmployeeRuntimeEvent: mockDigitalEmployee.recordRuntimeEvent,
}));
// Use dynamic import to get the actual agentService (an EventEmitter) after mocking
// The unifiedAgent module exports agentService as a singleton EventEmitter
vi.mock("../services/engines/unifiedAgent", async () => {
@@ -60,6 +68,7 @@ describe("eventForwarders", () => {
(agentService as any).removeAllListeners();
unregisterEventForwarders();
vi.clearAllMocks();
mockDigitalEmployee.recordRuntimeEvent.mockClear();
});
it("registerEventForwarders registers listeners on agentService", () => {
@@ -159,4 +168,79 @@ describe("eventForwarders", () => {
expect(send).not.toHaveBeenCalled();
});
it("normalizes computer tool updates into digital employee runtime events", () => {
const { ctx } = createMockCtx();
registerEventForwarders(ctx);
agentService.emit("computer:progress", {
sessionId: "session-1",
projectId: "project-1",
subType: "tool_call_update",
data: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
title: "Write",
kind: "edit",
status: "completed",
rawInput: { filePath: "/tmp/report.md" },
rawOutput: { outputPath: "/tmp/report.md" },
},
});
expect(mockDigitalEmployee.recordRuntimeEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: "computer_tool_call_update",
message: "工具调用更新Write",
session_id: "session-1",
project_id: "project-1",
status: "completed",
payload: expect.objectContaining({
toolName: "Write",
output: { outputPath: "/tmp/report.md" },
input: { filePath: "/tmp/report.md" },
}),
}),
);
});
it("projects permission and file events into approval and artifact hints", () => {
const { ctx } = createMockCtx();
registerEventForwarders(ctx);
agentService.emit("permission.updated", {
sessionId: "session-2",
permissionId: "permission-1",
toolCall: { title: "Bash", kind: "shell" },
});
agentService.emit("file.edited", {
sessionId: "session-2",
filePath: "/tmp/final.txt",
});
expect(mockDigitalEmployee.recordRuntimeEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: "permission_updated",
status: "pending",
payload: expect.objectContaining({
approval_requests: [
expect.objectContaining({
id: "permission-1",
title: "权限确认Bash",
status: "pending",
}),
],
}),
}),
);
expect(mockDigitalEmployee.recordRuntimeEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: "file_edited",
message: "文件已编辑:/tmp/final.txt",
payload: expect.objectContaining({
artifacts: [expect.objectContaining({ kind: "file_edit", path: "/tmp/final.txt" })],
}),
}),
);
});
});

View File

@@ -13,31 +13,150 @@ const registeredHandlers: Array<{
}> = [];
function readEventField(data: unknown, keys: string[]): string | undefined {
if (!data || typeof data !== "object") return undefined;
const record = data as Record<string, unknown>;
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value;
for (const record of eventRecords(data)) {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value;
}
}
return undefined;
}
function recordForwardedEvent(eventType: string, data: unknown): void {
recordDigitalEmployeeRuntimeEvent({
kind: eventType,
message:
readEventField(data, ["message", "description", "title", "subType"]) ||
eventType,
const normalized = normalizeForwardedEvent(eventType, data);
recordDigitalEmployeeRuntimeEvent(normalized);
}
function normalizeForwardedEvent(
eventType: string,
data: unknown,
): Parameters<typeof recordDigitalEmployeeRuntimeEvent>[0] {
const subtype = readEventField(data, ["subType", "sessionUpdate", "type", "messageType"]);
const toolName = readEventField(data, ["toolTitle", "title", "name", "kind"]);
const message = eventMessage(eventType, data, subtype, toolName);
return {
kind: eventKind(eventType, subtype),
message,
request_id: readEventField(data, ["requestId", "request_id"]),
session_id: readEventField(data, ["sessionId", "session_id", "id"]),
project_id: readEventField(data, ["projectId", "project_id"]),
status: eventType.includes("error")
? "failed"
: eventType.includes("idle") || eventType.includes("promptEnd")
? "completed"
: "running",
payload: data,
});
status: eventStatus(eventType, data),
payload: runtimePayload(eventType, data, subtype, toolName),
};
}
function eventKind(eventType: string, subtype?: string): string {
if (eventType === "computer:progress" && subtype) return `computer_${subtype}`;
return eventType.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || eventType;
}
function eventMessage(
eventType: string,
data: unknown,
subtype?: string,
toolName?: string,
): string {
if (subtype === "tool_call") return `工具调用开始:${toolName || "unknown"}`;
if (subtype === "tool_call_update") return `工具调用更新:${toolName || "unknown"}`;
if (subtype === "agent_message_chunk") return "Agent 输出内容";
if (subtype === "agent_thought_chunk") return "Agent 思考内容";
const explicit = readEventField(data, ["message", "description", "title"]);
if (explicit) return explicit;
if (eventType === "permission.updated") return `权限请求:${toolName || "待确认"}`;
if (eventType === "permission.replied") return `权限请求已回复:${toolName || "已处理"}`;
if (eventType === "file.edited") return `文件已编辑:${readEventField(data, ["filePath", "file_path", "path"]) || "unknown"}`;
if (eventType === "session.diff") return "会话产生文件差异";
return subtype || eventType;
}
function eventStatus(
eventType: string,
data: unknown,
): "running" | "completed" | "failed" | "pending" {
if (eventType.includes("error")) return "failed";
const status = (readEventField(data, ["status", "outcome", "reason"]) || "").toLowerCase();
if (["failed", "failure", "error", "rejected", "denied", "cancelled", "canceled"].includes(status)) return "failed";
if (["completed", "complete", "success", "succeeded", "done", "accepted", "approved", "end_turn"].includes(status)) return "completed";
if (eventType.includes("idle") || eventType.includes("promptEnd")) return "completed";
if (eventType.includes("permission")) return eventType.endsWith("replied") ? "completed" : "pending";
return "running";
}
function runtimePayload(
eventType: string,
data: unknown,
subtype?: string,
toolName?: string,
): Record<string, unknown> {
const primary = objectRecord(data) ?? {};
const nested = objectRecord(primary.data) ?? {};
const payload: Record<string, unknown> = {
...primary,
eventType,
subtype,
toolName,
};
const output = nested.rawOutput ?? nested.output ?? primary.output;
if (output !== undefined) payload.output = output;
const rawInput = nested.rawInput ?? nested.input ?? primary.input;
if (rawInput !== undefined) payload.input = rawInput;
const locations = Array.isArray(nested.locations) ? nested.locations : Array.isArray(primary.locations) ? primary.locations : [];
const artifacts = artifactHints(eventType, primary, nested, locations);
if (artifacts.length > 0) payload.artifacts = artifacts;
const approval = approvalHint(eventType, primary, nested, toolName);
if (approval) payload.approval_requests = [approval];
return payload;
}
function artifactHints(
eventType: string,
primary: Record<string, unknown>,
nested: Record<string, unknown>,
locations: unknown[],
): Array<Record<string, unknown>> {
const directPath = readEventField(primary, ["filePath", "file_path", "path", "uri"])
|| readEventField(nested, ["filePath", "file_path", "path", "uri"]);
const hints: Array<Record<string, unknown>> = locations
.map((location) => objectRecord(location))
.filter((location): location is Record<string, unknown> => Boolean(location?.path))
.map((location) => ({ kind: "file", path: location.path, payload: location }));
if (directPath && (eventType === "file.edited" || eventType === "session.diff")) {
hints.unshift({ kind: eventType === "file.edited" ? "file_edit" : "diff", path: directPath });
}
return hints;
}
function approvalHint(
eventType: string,
primary: Record<string, unknown>,
nested: Record<string, unknown>,
toolName?: string,
): Record<string, unknown> | null {
if (!eventType.includes("permission")) return null;
const id = readEventField(primary, ["permissionId", "permission_id", "id"])
|| readEventField(nested, ["permissionId", "permission_id", "id"])
|| toolName
|| eventType;
return {
id,
title: `权限确认:${toolName || readEventField(primary, ["title", "message"]) || "工具调用"}`,
status: eventType.endsWith("replied") ? "completed" : "pending",
source: "acp-permission",
payload: primary,
};
}
function eventRecords(data: unknown): Array<Record<string, unknown>> {
const primary = objectRecord(data);
if (!primary) return [];
return [primary, objectRecord(primary.data), objectRecord(primary.toolCall)]
.filter((record): record is Record<string, unknown> => Boolean(record));
}
function objectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
/**

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,

View File

@@ -113,5 +113,8 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async flushSync() {
return ipcRenderer.invoke("digitalEmployee:flushSync");
},
async recordGovernanceCommand(command: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
},
},
});

View File

@@ -514,6 +514,25 @@ GET /api/digital-employee/sync/records
- 后续再拆分管理端 Plan / Task / Run / Event 查询模型,让同步记录从通用 payload 观察面升级为业务视图。
- 数字员工页面继续补充管理端业务视图入口和失败分组统计。
管理端业务视图建议:
```text
GET /api/digital-employee/plans
GET /api/digital-employee/plans/{plan_id}
GET /api/digital-employee/tasks
GET /api/digital-employee/runs
GET /api/digital-employee/events
GET /api/digital-employee/artifacts
GET /api/digital-employee/approvals
```
- 查询参数沿用同步记录维度:`client_key``device_id``entity_id``remote_id``status``sync_status``updated_from``updated_to``page_no``page_size`
- 业务视图先从通用 sync record 的 `entity_type/entity_id/payload/status/created_at/updated_at` 聚合,不要求立即拆物理表;后续再按容量和查询压力拆正式业务表。
- Plan 视图输出 `plan_id``title``objective``status``client_key_masked``device_id``task_count``run_count``latest_event_at``sync_status`
- Task / Run 视图保留 `plan_id``task_id``run_id` 关联Run 额外输出 `started_at``finished_at``duration_ms``last_event_message`
- Event / Artifact / Approval 视图保留原始 payload 抽屉,但列表层必须提炼 `kind/title/message/status/occurred_at`,避免管理端只能看 JSON 排障。
- qimingclaw 客户端的失败详情深链仍指向 sync record管理端命中后可跳转到对应业务视图详情形成“同步诊断 -> 业务事实”的双入口。
## 管理端联动原则
数字员工状态是本地优先,但不能成为本地孤岛。