feat(client): project digital employee runtime into UI

This commit is contained in:
baiyanyun
2026-06-07 17:46:07 +08:00
parent 8ff1c57b83
commit b07f133ed6
15 changed files with 2639 additions and 252 deletions

View File

@@ -3,15 +3,20 @@ import type { HandlerContext } from "@shared/types/ipc";
import { FEATURES } from "@shared/featureFlags";
import { agentService } from "../services/engines/unifiedAgent";
import { mcpProxyManager } from "../services/packages/mcp";
import type { McpServerEntry } from "../services/packages/mcp";
import { getComputerServerStatus } from "../services/computerServer";
import { getGuiAgentServerStatus } from "../services/packages/guiAgentServer";
import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
import { isWindows } from "../services/system/shellEnv";
import {
recordDigitalEmployeeSnapshot,
readDigitalEmployeeRuntimeRecords,
readDigitalEmployeeState,
readDigitalEmployeeUiState,
saveDigitalEmployeeUiState,
type DigitalEmployeeServiceStatus,
type DigitalEmployeeSnapshot,
type DigitalEmployeeUiStateUpdate,
} from "../services/digitalEmployee/stateService";
import {
flushDigitalEmployeeSyncOutbox,
@@ -35,6 +40,25 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return readDigitalEmployeeState();
});
ipcMain.handle("digitalEmployee:getRuntimeRecords", async () => {
return readDigitalEmployeeRuntimeRecords();
});
ipcMain.handle("digitalEmployee:getUiState", async () => {
return readDigitalEmployeeUiState();
});
ipcMain.handle("digitalEmployee:getSkillCapabilities", async () => {
return buildSkillCapabilities();
});
ipcMain.handle(
"digitalEmployee:saveUiState",
async (_, update: DigitalEmployeeUiStateUpdate) => {
return saveDigitalEmployeeUiState(update);
},
);
ipcMain.handle("digitalEmployee:getSyncStatus", async () => {
return getDigitalEmployeeSyncStatus();
});
@@ -44,6 +68,135 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
});
}
interface DigitalEmployeeSkillCapability {
skill_id: string;
name: string;
version: string;
enabled: boolean;
description: string;
category: string;
capability_level: "core" | "standard" | "specialized" | "experimental";
source: "qimingclaw-mcp";
server_id: string;
transport: "stdio" | "remote";
running: boolean;
tools?: string[];
tool_count?: number;
allow_tools?: string[];
deny_tools?: string[];
}
type McpServerEntryWithRuntimeFields = McpServerEntry & {
enabled?: boolean;
discoveredTools?: string[];
};
function buildSkillCapabilities(): {
generatedAt: string;
skills: DigitalEmployeeSkillCapability[];
} {
const status = mcpProxyManager.getStatus();
const config = mcpProxyManager.getConfig();
const skills: DigitalEmployeeSkillCapability[] = [];
for (const [serverId, rawEntry] of Object.entries(config.mcpServers || {})) {
const entry = rawEntry as McpServerEntryWithRuntimeFields;
const enabled = entry.enabled !== false;
const transport = "url" in entry ? "remote" : "stdio";
const discoveredTools = normalizeToolList(entry.discoveredTools);
const allowedTools = normalizeToolList(entry.allowTools);
const deniedTools = normalizeToolList(entry.denyTools);
const effectiveTools = chooseEffectiveTools(
discoveredTools,
allowedTools,
deniedTools,
);
skills.push({
skill_id: `qimingclaw-mcp-server-${slugifySkillPart(serverId)}`,
name: `MCP 服务:${serverId}`,
version: "1.0.0",
enabled,
description: effectiveTools.length > 0
? `来自 qimingclaw MCP 配置,当前可用于数字员工编排的工具数:${effectiveTools.length}`
: "来自 qimingclaw MCP 配置,可作为数字员工的外部工具服务。",
category: "MCP",
capability_level: "specialized",
source: "qimingclaw-mcp",
server_id: serverId,
transport,
running: status.running,
tools: effectiveTools,
tool_count: effectiveTools.length,
...(allowedTools.length > 0 ? { allow_tools: allowedTools } : {}),
...(deniedTools.length > 0 ? { deny_tools: deniedTools } : {}),
});
for (const toolName of effectiveTools.slice(0, 120)) {
skills.push({
skill_id: `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`,
name: `MCP 工具:${toolName}`,
version: "1.0.0",
enabled,
description: `${serverId} 提供,可被 qimingclaw Agent 调用并纳入数字员工任务编排。`,
category: "MCP 工具",
capability_level: "standard",
source: "qimingclaw-mcp",
server_id: serverId,
transport,
running: status.running,
tools: [toolName],
tool_count: 1,
});
}
}
return {
generatedAt: new Date().toISOString(),
skills,
};
}
function normalizeToolList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => (
typeof item === "string" && item.trim().length > 0
)).map((item) => item.trim()))];
}
function chooseEffectiveTools(
discoveredTools: string[],
allowedTools: string[],
deniedTools: string[],
): string[] {
const baseTools = discoveredTools.length > 0 ? discoveredTools : allowedTools;
if (baseTools.length === 0) return [];
if (allowedTools.length > 0) {
const allowed = new Set(allowedTools);
return baseTools.filter((tool) => allowed.has(tool));
}
if (deniedTools.length > 0) {
const denied = new Set(deniedTools);
return baseTools.filter((tool) => !denied.has(tool));
}
return baseTools;
}
function slugifySkillPart(value: string): string {
const slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug) return slug;
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return `capability-${hash.toString(36)}`;
}
function buildSnapshot(ctx: HandlerContext): DigitalEmployeeSnapshot {
const sessions = agentService.listAllSessionsDetailed();
return {

View File

@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
db: null as TestDb | null,
settings: new Map<string, unknown>(),
}));
vi.mock("../../db", () => ({
getDb: () => mockState.db,
readSetting: (key: string) => mockState.settings.get(key) ?? null,
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
}));
describe("digital employee state service", () => {
beforeEach(() => {
vi.resetModules();
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
mockState.db = new TestDb();
mockState.settings = new Map<string, unknown>();
});
it("extracts artifacts and approvals from runtime event payload into syncable records", async () => {
const { recordDigitalEmployeeRuntimeEvent } = await import("./stateService");
recordDigitalEmployeeRuntimeEvent({
kind: "tool_result",
request_id: "req-1",
message: "生成交付产物并等待确认",
status: "completed",
payload: {
artifacts: [
{
id: "summary-csv",
name: "summary.csv",
uri: "file:///tmp/summary.csv",
},
],
outputPath: "/tmp/final-report.txt",
approval_requests: [
{
id: "approval-1",
title: "确认发送日报给管理端",
},
],
},
});
expect(Array.from(mockState.db?.artifacts.values() ?? [])).toEqual([
expect.objectContaining({
id: "computer-chat-run-req-1:artifact:summary-csv",
name: "summary.csv",
uri: "file:///tmp/summary.csv",
}),
expect.objectContaining({
id: "computer-chat-run-req-1:artifact:tmp-final-report-txt",
name: "final-report.txt",
uri: "/tmp/final-report.txt",
}),
]);
expect(Array.from(mockState.db?.approvals.values() ?? [])).toEqual([
expect.objectContaining({
id: "computer-chat-run-req-1:approval:approval-1",
title: "确认发送日报给管理端",
status: "pending",
}),
]);
expect(mockState.db?.outbox.get("artifact:upsert:computer-chat-run-req-1:artifact:summary-csv")).toMatchObject({
entity_type: "artifact",
entity_id: "computer-chat-run-req-1:artifact:summary-csv",
status: "pending",
});
expect(mockState.db?.outbox.get("approval:upsert:computer-chat-run-req-1:approval:approval-1")).toMatchObject({
entity_type: "approval",
entity_id: "computer-chat-run-req-1:approval:approval-1",
status: "pending",
});
});
});
interface TestArtifactRow {
id: string;
plan_id: string;
task_id: string;
run_id: string;
kind: string;
name: string | null;
uri: string | null;
payload: string;
sync_status: string;
created_at: string;
}
interface TestApprovalRow {
id: string;
plan_id: string;
task_id: string;
run_id: string;
title: string;
status: string;
payload: string;
sync_status: string;
created_at: string;
updated_at: string;
}
interface TestOutboxRow {
id: string;
entity_type: string;
entity_id: string;
operation: string;
payload: string;
status: string;
attempts: number;
created_at: string;
updated_at: string;
error: string | null;
}
class TestDb {
artifacts = new Map<string, TestArtifactRow>();
approvals = new Map<string, TestApprovalRow>();
outbox = new Map<string, TestOutboxRow>();
transaction<T>(callback: () => T): () => T {
return callback;
}
prepare(sql: string): {
all: (...args: unknown[]) => unknown[];
get: (...args: unknown[]) => unknown;
run: (...args: unknown[]) => unknown;
} {
const normalized = sql.replace(/\s+/g, " ").trim();
return {
all: () => [],
get: () => ({ count: 0 }),
run: (...args: unknown[]) => this.run(normalized, args),
};
}
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_artifacts")) {
const [id, planId, taskId, runId, kind, name, uri, payload, createdAt] = args as [
string,
string,
string,
string,
string,
string | null,
string | null,
string,
string,
];
this.artifacts.set(id, {
id,
plan_id: planId,
task_id: taskId,
run_id: runId,
kind,
name,
uri,
payload,
sync_status: "pending",
created_at: createdAt,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_approvals")) {
const [id, planId, taskId, runId, title, status, payload, createdAt, updatedAt] = args as [
string,
string,
string,
string,
string,
string,
string,
string,
string,
];
this.approvals.set(id, {
id,
plan_id: planId,
task_id: taskId,
run_id: runId,
title,
status,
payload,
sync_status: "pending",
created_at: createdAt,
updated_at: updatedAt,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
string,
string,
string,
string,
string,
string,
string,
];
this.outbox.set(id, {
id,
entity_type: entityType,
entity_id: entityId,
operation,
payload,
status: "pending",
attempts: 0,
created_at: createdAt,
updated_at: updatedAt,
error: null,
});
return { changes: 1 };
}
throw new Error(`Unhandled run query: ${sql}`);
}
}

View File

@@ -1,6 +1,7 @@
import { getDb, readSetting, writeSetting } from "../../db";
const STATE_KEY = "digital_employee_state_v1";
const UI_STATE_KEY = "digital_employee_ui_state_v1";
const MAX_EVENTS = 200;
export interface DigitalEmployeeServiceStatus {
@@ -31,6 +32,131 @@ export interface DigitalEmployeeSnapshot {
sessions: DigitalEmployeeSessionSummary;
}
export interface DigitalEmployeePlanRecord {
id: string;
remoteId?: string | null;
title: string;
objective?: string | null;
status: string;
source: string;
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
}
export interface DigitalEmployeeTaskRecord {
id: string;
remoteId?: string | null;
planId?: string | null;
title: string;
status: string;
assignedSkillId?: string | null;
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
}
export interface DigitalEmployeeRunRecord {
id: string;
remoteId?: string | null;
planId?: string | null;
taskId?: string | null;
status: string;
startedAt?: string | null;
finishedAt?: string | null;
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
}
export interface DigitalEmployeeEventRecord {
id: string;
remoteId?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
kind: string;
message: string;
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
occurredAt: string;
createdAt: string;
}
export interface DigitalEmployeeArtifactRecord {
id: string;
remoteId?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
kind: string;
name?: string | null;
uri?: string | null;
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
}
export interface DigitalEmployeeApprovalRecord {
id: string;
remoteId?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
title: string;
status: string;
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
}
export interface DigitalEmployeeRuntimeRecords {
generatedAt: string;
plans: DigitalEmployeePlanRecord[];
tasks: DigitalEmployeeTaskRecord[];
runs: DigitalEmployeeRunRecord[];
events: DigitalEmployeeEventRecord[];
artifacts: DigitalEmployeeArtifactRecord[];
approvals: DigitalEmployeeApprovalRecord[];
}
export interface DigitalEmployeeUiState {
selectedDutyIdsByDate: Record<string, string[]>;
confirmedDates: Record<string, string>;
reportScheduleTime: string;
reportGeneratedAtByDate: Record<string, string>;
decisionResultsByDate: Record<string, Record<string, string>>;
profile: {
name: string;
company: string;
position: string;
currentStatus: string;
};
}
export interface DigitalEmployeeUiStateUpdate {
state: DigitalEmployeeUiState;
action?: string;
date?: string;
metadata?: Record<string, unknown>;
}
export interface DigitalEmployeeStateEvent {
event_id: string;
kind: string;
@@ -86,6 +212,102 @@ function defaultState(): DigitalEmployeeState {
};
}
function defaultUiState(): DigitalEmployeeUiState {
return {
selectedDutyIdsByDate: {},
confirmedDates: {},
reportScheduleTime: "18:00",
reportGeneratedAtByDate: {},
decisionResultsByDate: {},
profile: {
name: "飞天数字员工",
company: "qimingclaw 客户端",
position: "客户端任务执行员",
currentStatus: "working",
},
};
}
function normalizeStringMap(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter((entry): entry is [string, string] =>
typeof entry[0] === "string" && typeof entry[1] === "string",
),
);
}
function normalizeStringArrayMap(value: unknown): Record<string, string[]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, list]) => [
key,
Array.isArray(list)
? list.filter((item): item is string => typeof item === "string")
: [],
]),
);
}
function normalizeNestedStringMap(value: unknown): Record<string, Record<string, string>> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, map]) => [
key,
normalizeStringMap(map),
]),
);
}
function normalizeUiState(
value: Partial<DigitalEmployeeUiState>,
): DigitalEmployeeUiState {
const fallback = defaultUiState();
const profile: Partial<DigitalEmployeeUiState["profile"]> = value.profile || {};
return {
selectedDutyIdsByDate: normalizeStringArrayMap(value.selectedDutyIdsByDate),
confirmedDates: normalizeStringMap(value.confirmedDates),
reportScheduleTime: typeof value.reportScheduleTime === "string" && value.reportScheduleTime.trim()
? value.reportScheduleTime
: fallback.reportScheduleTime,
reportGeneratedAtByDate: normalizeStringMap(value.reportGeneratedAtByDate),
decisionResultsByDate: normalizeNestedStringMap(value.decisionResultsByDate),
profile: {
name: typeof profile.name === "string" && profile.name.trim()
? profile.name
: fallback.profile.name,
company: typeof profile.company === "string" && profile.company.trim()
? profile.company
: fallback.profile.company,
position: typeof profile.position === "string" && profile.position.trim()
? profile.position
: fallback.profile.position,
currentStatus: typeof profile.currentStatus === "string" && profile.currentStatus.trim()
? profile.currentStatus
: fallback.profile.currentStatus,
},
};
}
function digitalEmployeeUiActionMessage(action: string, date?: string): string {
const suffix = date ? `${date}` : "";
switch (action) {
case "confirm_workday":
return `数字员工今日任务已确认${suffix}`;
case "update_profile":
return "数字员工资料已更新";
case "update_report_schedule":
return `数字员工日报时间已更新${suffix}`;
case "generate_daily_report":
return `数字员工日报已生成${suffix}`;
case "decide_approval":
return `数字员工待决策事项已处理${suffix}`;
default:
return `数字员工页面状态已更新${suffix}`;
}
}
export function readDigitalEmployeeState(): DigitalEmployeeState {
const state = readSetting(STATE_KEY);
const pendingSyncCount = countPendingSyncItems();
@@ -104,6 +326,103 @@ export function writeDigitalEmployeeState(state: DigitalEmployeeState): void {
writeSetting(STATE_KEY, state);
}
export function readDigitalEmployeeUiState(): DigitalEmployeeUiState {
const state = readSetting(UI_STATE_KEY);
if (!state || typeof state !== "object") return defaultUiState();
return normalizeUiState(state as Partial<DigitalEmployeeUiState>);
}
export function saveDigitalEmployeeUiState(
update: DigitalEmployeeUiStateUpdate,
): DigitalEmployeeUiState {
const state = normalizeUiState(update.state);
writeSetting(UI_STATE_KEY, state);
if (update.action) {
recordDigitalEmployeeRuntimeEvent({
kind: `digital_workday_${update.action}`,
message: digitalEmployeeUiActionMessage(update.action, update.date),
status: "completed",
payload: {
action: update.action,
date: update.date,
metadata: update.metadata,
state,
},
});
}
return state;
}
export function readDigitalEmployeeRuntimeRecords(
limit = 120,
): DigitalEmployeeRuntimeRecords {
const db = getDb();
if (!db) {
return {
generatedAt: new Date().toISOString(),
plans: [],
tasks: [],
runs: [],
events: [],
artifacts: [],
approvals: [],
};
}
const safeLimit = Math.max(1, Math.min(Math.floor(limit), 300));
const plans = db.prepare(`
SELECT id, remote_id, title, objective, status, source, payload, sync_status,
last_synced_at, sync_error, created_at, updated_at
FROM digital_plans
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const tasks = db.prepare(`
SELECT id, remote_id, plan_id, title, status, assigned_skill_id, payload,
sync_status, last_synced_at, sync_error, created_at, updated_at
FROM digital_tasks
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const runs = db.prepare(`
SELECT id, remote_id, plan_id, task_id, status, started_at, finished_at,
payload, sync_status, last_synced_at, sync_error, created_at, updated_at
FROM digital_runs
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const events = db.prepare(`
SELECT id, remote_id, plan_id, task_id, run_id, kind, message, payload,
sync_status, last_synced_at, sync_error, occurred_at, created_at
FROM digital_events
ORDER BY occurred_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const artifacts = db.prepare(`
SELECT id, remote_id, plan_id, task_id, run_id, kind, name, uri, payload,
sync_status, last_synced_at, sync_error, created_at
FROM digital_artifacts
ORDER BY created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const approvals = db.prepare(`
SELECT id, remote_id, plan_id, task_id, run_id, title, status, payload,
sync_status, last_synced_at, sync_error, created_at, updated_at
FROM digital_approvals
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
return {
generatedAt: new Date().toISOString(),
plans: plans.map(toPlanRecord),
tasks: tasks.map(toTaskRecord),
runs: runs.map(toRunRecord),
events: events.map(toEventRecord),
artifacts: artifacts.map(toArtifactRecord),
approvals: approvals.map(toApprovalRecord),
};
}
export function recordDigitalEmployeeSnapshot(
snapshot: DigitalEmployeeSnapshot,
): DigitalEmployeeState {
@@ -161,6 +480,7 @@ export function recordDigitalEmployeeChatReceived(
payload: { request },
now,
});
persistPayloadArtifactsAndApprovals(ids, request, now);
insertEvent(
{
event_id: `${ids.runId}:received`,
@@ -222,6 +542,7 @@ export function recordDigitalEmployeeChatResult(
now,
});
finishRun(ids.runId, status, now);
persistPayloadArtifactsAndApprovals(ids, { request, result }, now);
insertEvent(
{
event_id: `${ids.runId}:${status}`,
@@ -284,6 +605,7 @@ export function recordDigitalEmployeeRuntimeEvent(
if (status === "completed" || status === "failed") {
finishRun(ids.runId, status, now);
}
persistPayloadArtifactsAndApprovals(ids, event.payload ?? event, now);
insertEvent(
{
event_id: `${ids.runId}:${event.kind}:${Date.now()}`,
@@ -365,6 +687,138 @@ function stringify(value: unknown): string {
return JSON.stringify(value ?? {});
}
function stringField(row: Record<string, unknown>, key: string): string {
const value = row[key];
return typeof value === "string" ? value : "";
}
function optionalStringField(
row: Record<string, unknown>,
key: string,
): string | null {
const value = row[key];
return typeof value === "string" && value.trim() ? value : null;
}
function parseStoredPayload(value: unknown): unknown {
if (typeof value !== "string" || !value.trim()) return {};
try {
return JSON.parse(value);
} catch {
return value;
}
}
function toPlanRecord(row: Record<string, unknown>): DigitalEmployeePlanRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
title: stringField(row, "title"),
objective: optionalStringField(row, "objective"),
status: stringField(row, "status"),
source: stringField(row, "source"),
payload: parseStoredPayload(row.payload),
syncStatus: stringField(row, "sync_status"),
lastSyncedAt: optionalStringField(row, "last_synced_at"),
syncError: optionalStringField(row, "sync_error"),
createdAt: stringField(row, "created_at"),
updatedAt: stringField(row, "updated_at"),
};
}
function toTaskRecord(row: Record<string, unknown>): DigitalEmployeeTaskRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
planId: optionalStringField(row, "plan_id"),
title: stringField(row, "title"),
status: stringField(row, "status"),
assignedSkillId: optionalStringField(row, "assigned_skill_id"),
payload: parseStoredPayload(row.payload),
syncStatus: stringField(row, "sync_status"),
lastSyncedAt: optionalStringField(row, "last_synced_at"),
syncError: optionalStringField(row, "sync_error"),
createdAt: stringField(row, "created_at"),
updatedAt: stringField(row, "updated_at"),
};
}
function toRunRecord(row: Record<string, unknown>): DigitalEmployeeRunRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
planId: optionalStringField(row, "plan_id"),
taskId: optionalStringField(row, "task_id"),
status: stringField(row, "status"),
startedAt: optionalStringField(row, "started_at"),
finishedAt: optionalStringField(row, "finished_at"),
payload: parseStoredPayload(row.payload),
syncStatus: stringField(row, "sync_status"),
lastSyncedAt: optionalStringField(row, "last_synced_at"),
syncError: optionalStringField(row, "sync_error"),
createdAt: stringField(row, "created_at"),
updatedAt: stringField(row, "updated_at"),
};
}
function toEventRecord(row: Record<string, unknown>): DigitalEmployeeEventRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
planId: optionalStringField(row, "plan_id"),
taskId: optionalStringField(row, "task_id"),
runId: optionalStringField(row, "run_id"),
kind: stringField(row, "kind"),
message: stringField(row, "message"),
payload: parseStoredPayload(row.payload),
syncStatus: stringField(row, "sync_status"),
lastSyncedAt: optionalStringField(row, "last_synced_at"),
syncError: optionalStringField(row, "sync_error"),
occurredAt: stringField(row, "occurred_at"),
createdAt: stringField(row, "created_at"),
};
}
function toArtifactRecord(
row: Record<string, unknown>,
): DigitalEmployeeArtifactRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
planId: optionalStringField(row, "plan_id"),
taskId: optionalStringField(row, "task_id"),
runId: optionalStringField(row, "run_id"),
kind: stringField(row, "kind"),
name: optionalStringField(row, "name"),
uri: optionalStringField(row, "uri"),
payload: parseStoredPayload(row.payload),
syncStatus: stringField(row, "sync_status"),
lastSyncedAt: optionalStringField(row, "last_synced_at"),
syncError: optionalStringField(row, "sync_error"),
createdAt: stringField(row, "created_at"),
};
}
function toApprovalRecord(
row: Record<string, unknown>,
): DigitalEmployeeApprovalRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
planId: optionalStringField(row, "plan_id"),
taskId: optionalStringField(row, "task_id"),
runId: optionalStringField(row, "run_id"),
title: stringField(row, "title"),
status: stringField(row, "status"),
payload: parseStoredPayload(row.payload),
syncStatus: stringField(row, "sync_status"),
lastSyncedAt: optionalStringField(row, "last_synced_at"),
syncError: optionalStringField(row, "sync_error"),
createdAt: stringField(row, "created_at"),
updatedAt: stringField(row, "updated_at"),
};
}
function countPendingSyncItems(): number {
const db = getDb();
if (!db) return 0;
@@ -529,6 +983,91 @@ function finishRun(runId: string, status: string, now: string): void {
`).run(status, now, now, runId);
}
function upsertArtifact(input: {
id: string;
planId: string;
taskId: string;
runId: string;
kind: string;
name: string | null;
uri: string | null;
payload: unknown;
now: string;
}): void {
const db = getDb();
if (!db) return;
db.prepare(`
INSERT INTO digital_artifacts (
id, plan_id, task_id, run_id, kind, name, uri, payload, sync_status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)
ON CONFLICT(id) DO UPDATE SET
plan_id = excluded.plan_id,
task_id = excluded.task_id,
run_id = excluded.run_id,
kind = excluded.kind,
name = excluded.name,
uri = excluded.uri,
payload = excluded.payload,
sync_status = CASE
WHEN digital_artifacts.sync_status = 'synced' THEN 'pending'
ELSE digital_artifacts.sync_status
END
`).run(
input.id,
input.planId,
input.taskId,
input.runId,
input.kind,
input.name,
input.uri,
stringify(input.payload),
input.now,
);
markOutbox("artifact", input.id, "upsert", input, input.now);
}
function upsertApproval(input: {
id: string;
planId: string;
taskId: string;
runId: string;
title: string;
status: string;
payload: unknown;
now: string;
}): void {
const db = getDb();
if (!db) return;
db.prepare(`
INSERT INTO digital_approvals (
id, plan_id, task_id, run_id, title, status, payload, sync_status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
ON CONFLICT(id) DO UPDATE SET
plan_id = excluded.plan_id,
task_id = excluded.task_id,
run_id = excluded.run_id,
title = excluded.title,
status = excluded.status,
payload = excluded.payload,
sync_status = CASE
WHEN digital_approvals.sync_status = 'synced' THEN 'pending'
ELSE digital_approvals.sync_status
END,
updated_at = excluded.updated_at
`).run(
input.id,
input.planId,
input.taskId,
input.runId,
input.title,
input.status,
stringify(input.payload),
input.now,
input.now,
);
markOutbox("approval", input.id, "upsert", input, input.now);
}
function insertEvent(
event: DigitalEmployeeStateEvent,
runId: string,
@@ -554,6 +1093,189 @@ function insertEvent(
markOutbox("event", event.event_id, "insert", event, event.occurred_at);
}
function persistPayloadArtifactsAndApprovals(
ids: { planId: string; taskId: string; runId: string },
payload: unknown,
now: string,
): void {
const sources = payloadSources(payload);
const artifacts = sources.flatMap((source) => extractArtifacts(source));
const approvals = sources.flatMap((source) => extractApprovals(source));
dedupeByKey(artifacts, (artifact) => artifact.key).slice(0, 20).forEach((artifact, index) => {
upsertArtifact({
id: `${ids.runId}:artifact:${safeId(artifact.key || String(index + 1))}`,
planId: ids.planId,
taskId: ids.taskId,
runId: ids.runId,
kind: artifact.kind,
name: artifact.name,
uri: artifact.uri,
payload: artifact.payload,
now,
});
});
dedupeByKey(approvals, (approval) => approval.key).slice(0, 20).forEach((approval, index) => {
upsertApproval({
id: `${ids.runId}:approval:${safeId(approval.key || String(index + 1))}`,
planId: ids.planId,
taskId: ids.taskId,
runId: ids.runId,
title: approval.title,
status: approval.status,
payload: approval.payload,
now,
});
});
}
function payloadSources(payload: unknown): unknown[] {
const record = objectRecord(payload);
if (!record) return [payload];
return [
payload,
record.result,
record.response,
record.output,
record.payload,
].filter((item) => item !== undefined && item !== null);
}
interface ExtractedArtifact {
key: string;
kind: string;
name: string | null;
uri: string | null;
payload: unknown;
}
function extractArtifacts(payload: unknown): ExtractedArtifact[] {
const record = objectRecord(payload);
if (!record) return typeof payload === "string" && looksLikeArtifactUri(payload)
? [artifactFromValue(payload, 0)]
: [];
const values = [
...unknownArray(record.artifacts),
...unknownArray(record.outputs),
...unknownArray(record.files),
...unknownArray(record.attachments),
];
for (const key of ["artifact", "outputPath", "output_path", "filePath", "file_path", "path", "uri", "url"]) {
if (record[key]) values.push(record[key]);
}
return values
.map((value, index) => artifactFromValue(value, index))
.filter((artifact) => artifact.name || artifact.uri);
}
function artifactFromValue(value: unknown, index: number): ExtractedArtifact {
const record = objectRecord(value);
if (record) {
const uri = stringValue(record.uri ?? record.url ?? record.path ?? record.filePath ?? record.file_path ?? record.outputPath ?? record.output_path);
const name = stringValue(record.name ?? record.title ?? record.filename) || artifactName(uri) || `产物 ${index + 1}`;
const kind = stringValue(record.kind ?? record.type) || "artifact";
return {
key: stringValue(record.id ?? record.artifact_id) || uri || name,
kind,
name,
uri: uri || null,
payload: value,
};
}
const text = String(value ?? "").trim();
return {
key: text,
kind: "artifact",
name: artifactName(text) || `产物 ${index + 1}`,
uri: looksLikeArtifactUri(text) ? text : null,
payload: value,
};
}
interface ExtractedApproval {
key: string;
title: string;
status: string;
payload: unknown;
}
function extractApprovals(payload: unknown): ExtractedApproval[] {
const record = objectRecord(payload);
if (!record) return [];
const values = [
...unknownArray(record.approvals),
...unknownArray(record.approval_requests),
...unknownArray(record.approvalRequests),
...unknownArray(record.decisions),
];
if (record.needApproval === true || record.need_approval === true || record.requiresApproval === true) {
values.push(record);
}
return values.map((value, index) => approvalFromValue(value, index));
}
function approvalFromValue(value: unknown, index: number): ExtractedApproval {
const record = objectRecord(value);
if (!record) {
return {
key: String(index + 1),
title: `待确认事项 ${index + 1}`,
status: "pending",
payload: value,
};
}
const title = stringValue(record.title ?? record.message ?? record.reason ?? record.summary)
|| `待确认事项 ${index + 1}`;
return {
key: stringValue(record.id ?? record.approval_id ?? record.key) || title,
title,
status: stringValue(record.status) || "pending",
payload: value,
};
}
function objectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function unknownArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function looksLikeArtifactUri(value: string): boolean {
return /^(file|https?):\/\//i.test(value) || value.includes("/") || value.includes("\\");
}
function artifactName(value: string): string {
if (!value) return "";
const normalized = value.split(/[?#]/)[0] || value;
return normalized.split(/[\\/]/).filter(Boolean).pop() ?? "";
}
function dedupeByKey<T>(items: T[], keyOf: (item: T) => string): T[] {
const seen = new Set<string>();
return items.filter((item) => {
const key = keyOf(item);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function chatIds(request: DigitalEmployeeChatRequestRecord): {
planId: string;
taskId: string;

View File

@@ -158,10 +158,62 @@ describe("digital employee sync service", () => {
],
});
});
it("marks artifact and approval acknowledgements as synced", async () => {
insertEntity("artifact", "artifact-1");
insertEntity("approval", "approval-1");
insertOutbox("artifact:upsert:artifact-1", "artifact", "artifact-1");
insertOutbox("approval:upsert:approval-1", "approval", "approval-1");
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{
entity_type: "artifact",
entity_id: "artifact-1",
remote_id: "remote-artifact-1",
},
{
entity_type: "approval",
entity_id: "approval-1",
remote_id: "remote-approval-1",
},
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.pending).toBe(0);
expect(status.failed).toBe(0);
expect(readOutbox("artifact:upsert:artifact-1")).toMatchObject({
status: "synced",
attempts: 1,
});
expect(readOutbox("approval:upsert:approval-1")).toMatchObject({
status: "synced",
attempts: 1,
});
expect(readEntity("artifact", "artifact-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-artifact-1",
sync_error: null,
});
expect(readEntity("approval", "approval-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-approval-1",
sync_error: null,
});
});
});
function insertPlan(id: string): void {
mockState.db?.plans.set(id, {
insertEntity("plan", id);
}
function insertEntity(entityType: string, id: string): void {
entityMap(entityType)?.set(id, {
id,
remote_id: null,
sync_status: "pending",
@@ -191,7 +243,24 @@ function readOutbox(id: string): Record<string, unknown> | undefined {
}
function readPlan(id: string): Record<string, unknown> | undefined {
return mockState.db?.plans.get(id);
return readEntity("plan", id);
}
function readEntity(
entityType: string,
id: string,
): Record<string, unknown> | undefined {
return entityMap(entityType)?.get(id);
}
function entityMap(
entityType: string,
): Map<string, TestSyncEntityRow> | undefined {
if (!mockState.db) return undefined;
if (entityType === "plan") return mockState.db.plans;
if (entityType === "artifact") return mockState.db.artifacts;
if (entityType === "approval") return mockState.db.approvals;
return undefined;
}
function readAttempts(): Array<Record<string, unknown>> {
@@ -220,7 +289,7 @@ interface TestOutboxRow {
updated_at: string;
}
interface TestPlanRow {
interface TestSyncEntityRow {
id: string;
remote_id: string | null;
sync_status: string;
@@ -244,7 +313,9 @@ interface TestAttemptRow {
class TestDb {
outbox = new Map<string, TestOutboxRow>();
plans = new Map<string, TestPlanRow>();
plans = new Map<string, TestSyncEntityRow>();
artifacts = new Map<string, TestSyncEntityRow>();
approvals = new Map<string, TestSyncEntityRow>();
attempts: TestAttemptRow[] = [];
private nextAttemptId = 1;
@@ -427,9 +498,9 @@ class TestDb {
return { changes: before - this.attempts.length };
}
if (sql.startsWith("UPDATE digital_plans SET sync_status = 'synced'")) {
if (/^UPDATE digital_(plans|artifacts|approvals) SET sync_status = 'synced'/.test(sql)) {
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
const row = this.plans.get(id);
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
row.sync_status = "synced";
row.remote_id = remoteId ?? row.remote_id;
@@ -439,9 +510,9 @@ class TestDb {
return { changes: row ? 1 : 0 };
}
if (sql.startsWith("UPDATE digital_plans SET sync_status = 'failed'")) {
if (/^UPDATE digital_(plans|artifacts|approvals) SET sync_status = 'failed'/.test(sql)) {
const [syncError, id] = args as [string, string];
const row = this.plans.get(id);
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
row.sync_status = "failed";
row.sync_error = syncError;
@@ -451,6 +522,12 @@ class TestDb {
throw new Error(`Unhandled run query: ${sql}`);
}
private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> {
if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts;
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
return this.plans;
}
}
function copyRow<T extends object>(row: T): T {

View File

@@ -95,6 +95,18 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async getSnapshot() {
return ipcRenderer.invoke("digitalEmployee:getSnapshot");
},
async getRuntimeRecords() {
return ipcRenderer.invoke("digitalEmployee:getRuntimeRecords");
},
async getUiState() {
return ipcRenderer.invoke("digitalEmployee:getUiState");
},
async getSkillCapabilities() {
return ipcRenderer.invoke("digitalEmployee:getSkillCapabilities");
},
async saveUiState(update: unknown) {
return ipcRenderer.invoke("digitalEmployee:saveUiState", update);
},
async getSyncStatus() {
return ipcRenderer.invoke("digitalEmployee:getSyncStatus");
},