feat(client): persist digital governance events
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ?")
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -113,5 +113,8 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async flushSync() {
|
||||
return ipcRenderer.invoke("digitalEmployee:flushSync");
|
||||
},
|
||||
async recordGovernanceCommand(command: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;管理端命中后可跳转到对应业务视图详情,形成“同步诊断 -> 业务事实”的双入口。
|
||||
|
||||
## 管理端联动原则
|
||||
|
||||
数字员工状态是本地优先,但不能成为本地孤岛。
|
||||
|
||||
Reference in New Issue
Block a user