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>;
|
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
|
||||||
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
||||||
flushSync?: () => Promise<QimingclawSyncStatus>;
|
flushSync?: () => Promise<QimingclawSyncStatus>;
|
||||||
|
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -124,6 +125,19 @@ interface QimingclawSnapshot {
|
|||||||
uiState?: AdapterState | null;
|
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 {
|
interface QimingclawRuntimeRecords {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
plans: QimingclawPlanRecord[];
|
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 {
|
function parseJsonBody<T>(body: BodyInit | null | undefined): T {
|
||||||
if (typeof body !== 'string' || !body.trim()) return {} as T;
|
if (typeof body !== 'string' || !body.trim()) return {} as T;
|
||||||
return JSON.parse(body) as T;
|
return JSON.parse(body) as T;
|
||||||
@@ -1689,37 +1731,43 @@ async function handleGovernanceCommand(
|
|||||||
|
|
||||||
if (body.command_kind === 'create_plan') {
|
if (body.command_kind === 'create_plan') {
|
||||||
const createdPlanId = `qimingclaw-local-${slugifyIdPart(stringFromUnknown(body.payload?.title) || planId || 'plan')}-${Date.now()}`;
|
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,
|
plan_id: createdPlanId,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
source: 'qimingclaw-adapter',
|
source: 'qimingclaw-adapter',
|
||||||
requested_action: body.payload?.requested_action,
|
requested_action: body.payload?.requested_action,
|
||||||
original_plan_id: body.payload?.original_plan_id ?? planId,
|
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') {
|
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 targetPlanId = planId || runtimeDebugPlans(snapshot)[0]?.plan_id || PLANS[0]?.plan_id || 'qimingclaw-client-readiness';
|
||||||
const dispatch = await dispatchPlan(targetPlanId, snapshot);
|
const dispatch = await dispatchPlan(targetPlanId, snapshot);
|
||||||
return governanceAccepted(commandId, correlationId, '已映射为 qimingclaw 本地任务激活。', {
|
const response = governanceAccepted(commandId, correlationId, '已映射为 qimingclaw 本地任务激活。', {
|
||||||
plan_id: targetPlanId,
|
plan_id: targetPlanId,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
dispatched_tasks: dispatch.dispatched_tasks,
|
dispatched_tasks: dispatch.dispatched_tasks,
|
||||||
source: 'qimingclaw-adapter',
|
source: 'qimingclaw-adapter',
|
||||||
});
|
});
|
||||||
|
await recordGovernanceCommand(body, response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.command_kind === 'create_schedule') {
|
if (body.command_kind === 'create_schedule') {
|
||||||
return governanceAccepted(commandId, correlationId, '已记录 qimingclaw 兼容定时任务请求。', {
|
const response = governanceAccepted(commandId, correlationId, '已记录 qimingclaw 兼容定时任务请求。', {
|
||||||
schedule_id: scheduleId || `qimingclaw-schedule-${Date.now()}`,
|
schedule_id: scheduleId || `qimingclaw-schedule-${Date.now()}`,
|
||||||
plan_id: planId,
|
plan_id: planId,
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
source: 'qimingclaw-adapter',
|
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)) {
|
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,
|
plan_id: planId,
|
||||||
status: body.command_kind === 'approve_plan'
|
status: body.command_kind === 'approve_plan'
|
||||||
? 'approved'
|
? 'approved'
|
||||||
@@ -1728,15 +1776,19 @@ async function handleGovernanceCommand(
|
|||||||
: 'accepted',
|
: 'accepted',
|
||||||
source: 'qimingclaw-adapter',
|
source: 'qimingclaw-adapter',
|
||||||
});
|
});
|
||||||
|
await recordGovernanceCommand(body, response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const response = {
|
||||||
command_id: commandId,
|
command_id: commandId,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
correlation_id: correlationId,
|
correlation_id: correlationId,
|
||||||
error: 'missing_capability',
|
error: 'missing_capability',
|
||||||
message: `qimingclaw 数字员工兼容层尚未支持 ${body.command_kind}。`,
|
message: `qimingclaw 数字员工兼容层尚未支持 ${body.command_kind}。`,
|
||||||
};
|
};
|
||||||
|
await recordGovernanceCommand(body, response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
function governanceAccepted(
|
function governanceAccepted(
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ interface ManagementSyncFailure {
|
|||||||
id: string;
|
id: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
|
entitySummary?: ManagementSyncEntitySummary | null;
|
||||||
operation: string;
|
operation: string;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
lastAttemptAt: string | null;
|
lastAttemptAt: string | null;
|
||||||
@@ -98,6 +99,12 @@ interface ManagementSyncFailure {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ManagementSyncEntitySummary {
|
||||||
|
title?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// 技术侧 PlanTemplate 是任务模板来源;用户侧只展示为“任务”,运行时 Task/PlanStep 展示为“步骤”。
|
// 技术侧 PlanTemplate 是任务模板来源;用户侧只展示为“任务”,运行时 Task/PlanStep 展示为“步骤”。
|
||||||
|
|
||||||
interface EmployeeVisualStateOption {
|
interface EmployeeVisualStateOption {
|
||||||
@@ -523,7 +530,9 @@ function failureGroupSummary(summary: ManagementSyncFailureSummary): string {
|
|||||||
|
|
||||||
function syncFailureSummary(failure: ManagementSyncFailure): string {
|
function syncFailureSummary(failure: ManagementSyncFailure): string {
|
||||||
const error = summarizeSyncError(failure.error);
|
const error = summarizeSyncError(failure.error);
|
||||||
|
const title = compactText(failure.entitySummary?.title);
|
||||||
return [
|
return [
|
||||||
|
title,
|
||||||
`${entityTypeLabel(failure.entityType)} / ${failure.operation}`,
|
`${entityTypeLabel(failure.entityType)} / ${failure.operation}`,
|
||||||
`重试 ${failure.attempts} 次`,
|
`重试 ${failure.attempts} 次`,
|
||||||
failure.dueForRetry ? '等待补偿' : `下次 ${syncFailureRetryTime(failure)}`,
|
failure.dueForRetry ? '等待补偿' : `下次 ${syncFailureRetryTime(failure)}`,
|
||||||
@@ -2009,6 +2018,12 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
<div className="de-event-detail-grid">
|
<div className="de-event-detail-grid">
|
||||||
<div><span>Outbox</span><strong>{selectedSyncFailure.id}</strong></div>
|
<div><span>Outbox</span><strong>{selectedSyncFailure.id}</strong></div>
|
||||||
<div><span>实体</span><strong>{selectedSyncFailure.entityId}</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>{entityTypeLabel(selectedSyncFailure.entityType)}</strong></div>
|
||||||
<div><span>操作</span><strong>{selectedSyncFailure.operation}</strong></div>
|
<div><span>操作</span><strong>{selectedSyncFailure.operation}</strong></div>
|
||||||
<div><span>重试</span><strong>{selectedSyncFailure.attempts} 次</strong></div>
|
<div><span>重试</span><strong>{selectedSyncFailure.attempts} 次</strong></div>
|
||||||
@@ -2020,6 +2035,12 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
<span>失败原因</span>
|
<span>失败原因</span>
|
||||||
<p>{selectedSyncFailure.error || '暂无错误详情。'}</p>
|
<p>{selectedSyncFailure.error || '暂无错误详情。'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedSyncFailure.entitySummary?.summary && (
|
||||||
|
<div className="de-event-detail-result">
|
||||||
|
<span>业务摘要</span>
|
||||||
|
<p>{selectedSyncFailure.entitySummary.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(selectedSyncFailure.attemptHistory?.length ?? 0) > 0 && (
|
{(selectedSyncFailure.attemptHistory?.length ?? 0) > 0 && (
|
||||||
<div className="de-sync-attempt-history">
|
<div className="de-sync-attempt-history">
|
||||||
<span>重试历史</span>
|
<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);
|
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||||
}
|
}
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
|
|||||||
import { isWindows } from "../services/system/shellEnv";
|
import { isWindows } from "../services/system/shellEnv";
|
||||||
import {
|
import {
|
||||||
recordDigitalEmployeeSnapshot,
|
recordDigitalEmployeeSnapshot,
|
||||||
|
recordDigitalEmployeeGovernanceCommand,
|
||||||
readDigitalEmployeeRuntimeRecords,
|
readDigitalEmployeeRuntimeRecords,
|
||||||
readDigitalEmployeeState,
|
readDigitalEmployeeState,
|
||||||
readDigitalEmployeeUiState,
|
readDigitalEmployeeUiState,
|
||||||
saveDigitalEmployeeUiState,
|
saveDigitalEmployeeUiState,
|
||||||
|
type DigitalEmployeeGovernanceCommandRecord,
|
||||||
type DigitalEmployeeServiceStatus,
|
type DigitalEmployeeServiceStatus,
|
||||||
type DigitalEmployeeSnapshot,
|
type DigitalEmployeeSnapshot,
|
||||||
type DigitalEmployeeUiStateUpdate,
|
type DigitalEmployeeUiStateUpdate,
|
||||||
@@ -66,6 +68,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
ipcMain.handle("digitalEmployee:flushSync", async () => {
|
ipcMain.handle("digitalEmployee:flushSync", async () => {
|
||||||
return flushDigitalEmployeeSyncOutbox({ force: true });
|
return flushDigitalEmployeeSyncOutbox({ force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"digitalEmployee:recordGovernanceCommand",
|
||||||
|
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {
|
||||||
|
return recordDigitalEmployeeGovernanceCommand(command);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DigitalEmployeeSkillCapability {
|
interface DigitalEmployeeSkillCapability {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const mockDigitalEmployee = vi.hoisted(() => ({
|
||||||
|
recordRuntimeEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("electron-log", () => ({
|
vi.mock("electron-log", () => ({
|
||||||
default: {
|
default: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
@@ -23,6 +27,10 @@ vi.mock("../services/computerServer", () => ({
|
|||||||
pushSseEvent: vi.fn(),
|
pushSseEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/digitalEmployee/stateService", () => ({
|
||||||
|
recordDigitalEmployeeRuntimeEvent: mockDigitalEmployee.recordRuntimeEvent,
|
||||||
|
}));
|
||||||
|
|
||||||
// Use dynamic import to get the actual agentService (an EventEmitter) after mocking
|
// Use dynamic import to get the actual agentService (an EventEmitter) after mocking
|
||||||
// The unifiedAgent module exports agentService as a singleton EventEmitter
|
// The unifiedAgent module exports agentService as a singleton EventEmitter
|
||||||
vi.mock("../services/engines/unifiedAgent", async () => {
|
vi.mock("../services/engines/unifiedAgent", async () => {
|
||||||
@@ -60,6 +68,7 @@ describe("eventForwarders", () => {
|
|||||||
(agentService as any).removeAllListeners();
|
(agentService as any).removeAllListeners();
|
||||||
unregisterEventForwarders();
|
unregisterEventForwarders();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockDigitalEmployee.recordRuntimeEvent.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registerEventForwarders registers listeners on agentService", () => {
|
it("registerEventForwarders registers listeners on agentService", () => {
|
||||||
@@ -159,4 +168,79 @@ describe("eventForwarders", () => {
|
|||||||
|
|
||||||
expect(send).not.toHaveBeenCalled();
|
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 {
|
function readEventField(data: unknown, keys: string[]): string | undefined {
|
||||||
if (!data || typeof data !== "object") return undefined;
|
for (const record of eventRecords(data)) {
|
||||||
const record = data as Record<string, unknown>;
|
for (const key of keys) {
|
||||||
for (const key of keys) {
|
const value = record[key];
|
||||||
const value = record[key];
|
if (typeof value === "string" && value.trim()) return value;
|
||||||
if (typeof value === "string" && value.trim()) return value;
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordForwardedEvent(eventType: string, data: unknown): void {
|
function recordForwardedEvent(eventType: string, data: unknown): void {
|
||||||
recordDigitalEmployeeRuntimeEvent({
|
const normalized = normalizeForwardedEvent(eventType, data);
|
||||||
kind: eventType,
|
recordDigitalEmployeeRuntimeEvent(normalized);
|
||||||
message:
|
}
|
||||||
readEventField(data, ["message", "description", "title", "subType"]) ||
|
|
||||||
eventType,
|
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"]),
|
request_id: readEventField(data, ["requestId", "request_id"]),
|
||||||
session_id: readEventField(data, ["sessionId", "session_id", "id"]),
|
session_id: readEventField(data, ["sessionId", "session_id", "id"]),
|
||||||
project_id: readEventField(data, ["projectId", "project_id"]),
|
project_id: readEventField(data, ["projectId", "project_id"]),
|
||||||
status: eventType.includes("error")
|
status: eventStatus(eventType, data),
|
||||||
? "failed"
|
payload: runtimePayload(eventType, data, subtype, toolName),
|
||||||
: eventType.includes("idle") || eventType.includes("promptEnd")
|
};
|
||||||
? "completed"
|
}
|
||||||
: "running",
|
|
||||||
payload: data,
|
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(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
db: null as TestDb | null,
|
db: null as TestDb | null,
|
||||||
|
ensureSchemaCalls: 0,
|
||||||
settings: new Map<string, unknown>(),
|
settings: new Map<string, unknown>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../db", () => ({
|
vi.mock("../../db", () => ({
|
||||||
ensureDigitalEmployeeSchema: () => true,
|
ensureDigitalEmployeeSchema: () => {
|
||||||
|
mockState.ensureSchemaCalls += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
getDb: () => mockState.db,
|
getDb: () => mockState.db,
|
||||||
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
||||||
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
||||||
@@ -17,6 +21,7 @@ describe("digital employee state service", () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
|
||||||
mockState.db = new TestDb();
|
mockState.db = new TestDb();
|
||||||
|
mockState.ensureSchemaCalls = 0;
|
||||||
mockState.settings = new Map<string, unknown>();
|
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",
|
entity_id: "computer-chat-run-req-1:approval:approval-1",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
});
|
});
|
||||||
|
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates formal approval records when UI approval decisions are saved", async () => {
|
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",
|
entity_id: "approval-1",
|
||||||
status: "pending",
|
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 {
|
interface TestArtifactRow {
|
||||||
id: string;
|
id: string;
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
@@ -178,6 +287,10 @@ interface TestOutboxRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TestDb {
|
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>();
|
artifacts = new Map<string, TestArtifactRow>();
|
||||||
approvals = new Map<string, TestApprovalRow>();
|
approvals = new Map<string, TestApprovalRow>();
|
||||||
outbox = new Map<string, TestOutboxRow>();
|
outbox = new Map<string, TestOutboxRow>();
|
||||||
@@ -207,11 +320,126 @@ class TestDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private run(sql: string, args: unknown[]): unknown {
|
private run(sql: string, args: unknown[]): unknown {
|
||||||
if (sql.startsWith("INSERT INTO digital_plans")) return { changes: 1 };
|
if (sql.startsWith("INSERT INTO digital_plans")) {
|
||||||
if (sql.startsWith("INSERT INTO digital_tasks")) return { changes: 1 };
|
const [id, title, objective, status, payload, createdAt, updatedAt] = args as [
|
||||||
if (sql.startsWith("INSERT INTO digital_runs")) return { changes: 1 };
|
string,
|
||||||
if (sql.startsWith("UPDATE digital_runs")) return { changes: 1 };
|
string,
|
||||||
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) return { changes: 1 };
|
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")) {
|
if (sql.startsWith("INSERT INTO digital_artifacts")) {
|
||||||
const [id, planId, taskId, runId, kind, name, uri, payload, createdAt] = args as [
|
const [id, planId, taskId, runId, kind, name, uri, payload, createdAt] = args as [
|
||||||
|
|||||||
@@ -203,6 +203,19 @@ export interface DigitalEmployeeRuntimeEventRecord {
|
|||||||
payload?: unknown;
|
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 {
|
function defaultState(): DigitalEmployeeState {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -681,6 +694,140 @@ export function recordDigitalEmployeeRuntimeEvent(
|
|||||||
tx();
|
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(
|
function persistSnapshotTables(
|
||||||
snapshot: DigitalEmployeeSnapshot,
|
snapshot: DigitalEmployeeSnapshot,
|
||||||
event: DigitalEmployeeStateEvent | null,
|
event: DigitalEmployeeStateEvent | null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
db: null as TestDb | null,
|
db: null as TestDb | null,
|
||||||
|
ensureSchemaCalls: 0,
|
||||||
settings: new Map<string, unknown>(),
|
settings: new Map<string, unknown>(),
|
||||||
fetch: vi.fn(),
|
fetch: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -20,7 +21,10 @@ vi.mock("../system/deviceId", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../db", () => ({
|
vi.mock("../../db", () => ({
|
||||||
ensureDigitalEmployeeSchema: () => true,
|
ensureDigitalEmployeeSchema: () => {
|
||||||
|
mockState.ensureSchemaCalls += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
getDb: () => mockState.db,
|
getDb: () => mockState.db,
|
||||||
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
||||||
}));
|
}));
|
||||||
@@ -31,6 +35,7 @@ describe("digital employee sync service", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
|
||||||
mockState.fetch = vi.fn();
|
mockState.fetch = vi.fn();
|
||||||
|
mockState.ensureSchemaCalls = 0;
|
||||||
vi.stubGlobal("fetch", mockState.fetch);
|
vi.stubGlobal("fetch", mockState.fetch);
|
||||||
mockState.settings = new Map<string, unknown>([
|
mockState.settings = new Map<string, unknown>([
|
||||||
["step1_config", { serverHost: "https://manage.example.com" }],
|
["step1_config", { serverHost: "https://manage.example.com" }],
|
||||||
@@ -62,6 +67,7 @@ describe("digital employee sync service", () => {
|
|||||||
status: "pending",
|
status: "pending",
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
});
|
});
|
||||||
|
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks entity-only acknowledgements as synced", async () => {
|
it("marks entity-only acknowledgements as synced", async () => {
|
||||||
@@ -103,6 +109,7 @@ describe("digital employee sync service", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps unacknowledged rows retryable after a partial backend ack", async () => {
|
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({
|
expect(status.recentFailures[0]).toMatchObject({
|
||||||
id: "plan:upsert:plan-2",
|
id: "plan:upsert:plan-2",
|
||||||
|
entitySummary: {
|
||||||
|
title: "同步测试计划 plan-2",
|
||||||
|
status: "pending",
|
||||||
|
summary: "用于验证同步失败诊断的业务摘要",
|
||||||
|
},
|
||||||
dueForRetry: false,
|
dueForRetry: false,
|
||||||
managementRecordUrl:
|
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",
|
"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 {
|
function insertEntity(entityType: string, id: string): void {
|
||||||
entityMap(entityType)?.set(id, {
|
entityMap(entityType)?.set(id, {
|
||||||
id,
|
id,
|
||||||
|
title: `同步测试计划 ${id}`,
|
||||||
|
status: "pending",
|
||||||
|
objective: "用于验证同步失败诊断的业务摘要",
|
||||||
|
payload: "{}",
|
||||||
remote_id: null,
|
remote_id: null,
|
||||||
sync_status: "pending",
|
sync_status: "pending",
|
||||||
sync_error: null,
|
sync_error: null,
|
||||||
@@ -292,6 +308,10 @@ interface TestOutboxRow {
|
|||||||
|
|
||||||
interface TestSyncEntityRow {
|
interface TestSyncEntityRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
objective?: string | null;
|
||||||
|
payload?: string;
|
||||||
remote_id: string | null;
|
remote_id: string | null;
|
||||||
sync_status: string;
|
sync_status: string;
|
||||||
sync_error: string | null;
|
sync_error: string | null;
|
||||||
@@ -402,6 +422,11 @@ class TestDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get(sql: string, args: unknown[]): unknown {
|
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 (
|
if (
|
||||||
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
|
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export interface DigitalEmployeeSyncFailure {
|
|||||||
id: string;
|
id: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
|
entitySummary: DigitalEmployeeSyncEntitySummary | null;
|
||||||
operation: string;
|
operation: string;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
lastAttemptAt: string | null;
|
lastAttemptAt: string | null;
|
||||||
@@ -78,6 +79,12 @@ export interface DigitalEmployeeSyncFailure {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeSyncEntitySummary {
|
||||||
|
title: string | null;
|
||||||
|
status: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SyncAck {
|
interface SyncAck {
|
||||||
outbox_id?: string;
|
outbox_id?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -580,6 +587,7 @@ function readRecentFailures(
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
entityType: row.entity_type,
|
entityType: row.entity_type,
|
||||||
entityId: row.entity_id,
|
entityId: row.entity_id,
|
||||||
|
entitySummary: readEntitySummary(row.entity_type, row.entity_id),
|
||||||
operation: row.operation,
|
operation: row.operation,
|
||||||
attempts: row.attempts,
|
attempts: row.attempts,
|
||||||
lastAttemptAt: row.last_attempt_at,
|
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(
|
function readAttemptHistory(
|
||||||
outboxId: string,
|
outboxId: string,
|
||||||
limit = 5,
|
limit = 5,
|
||||||
|
|||||||
@@ -113,5 +113,8 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
|||||||
async flushSync() {
|
async flushSync() {
|
||||||
return ipcRenderer.invoke("digitalEmployee: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 观察面升级为业务视图。
|
- 后续再拆分管理端 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