diff --git a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.test.ts b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.test.ts index 6f64474e..9dddece4 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.test.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.test.ts @@ -75,6 +75,66 @@ describe("digital employee state service", () => { status: "pending", }); }); + + it("updates formal approval records when UI approval decisions are saved", async () => { + const { saveDigitalEmployeeUiState } = await import("./stateService"); + mockState.db?.approvals.set("approval-1", { + id: "approval-1", + plan_id: "plan-1", + task_id: "task-1", + run_id: "run-1", + title: "确认发送日报给管理端", + status: "pending", + payload: JSON.stringify({ source: "test" }), + sync_status: "synced", + created_at: "2026-06-07T07:00:00.000Z", + updated_at: "2026-06-07T07:00:00.000Z", + }); + + saveDigitalEmployeeUiState({ + action: "decide_approval", + date: "2026-06-07", + metadata: { + decision_id: "approval-1", + decision: "approved", + }, + state: { + selectedDutyIdsByDate: {}, + confirmedDates: {}, + reportScheduleTime: "18:00", + reportGeneratedAtByDate: {}, + decisionResultsByDate: { + "2026-06-07": { + "approval-1": "approved", + }, + }, + profile: { + name: "飞天数字员工", + company: "qimingclaw 客户端", + position: "客户端任务执行员", + currentStatus: "working", + }, + }, + }); + + expect(mockState.db?.approvals.get("approval-1")).toMatchObject({ + id: "approval-1", + status: "approved", + sync_status: "pending", + updated_at: "2026-06-07T08:00:00.000Z", + }); + expect(JSON.parse(mockState.db?.approvals.get("approval-1")?.payload ?? "{}")).toMatchObject({ + source: "test", + decision: "approved", + decidedAt: "2026-06-07T08:00:00.000Z", + previousStatus: "pending", + }); + expect(mockState.db?.outbox.get("approval:upsert:approval-1")).toMatchObject({ + entity_type: "approval", + entity_id: "approval-1", + status: "pending", + }); + }); }); interface TestArtifactRow { @@ -133,11 +193,18 @@ class TestDb { const normalized = sql.replace(/\s+/g, " ").trim(); return { all: () => [], - get: () => ({ count: 0 }), + get: (...args: unknown[]) => this.get(normalized, args), run: (...args: unknown[]) => this.run(normalized, args), }; } + private get(sql: string, args: unknown[]): unknown { + if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload, created_at FROM digital_approvals")) { + return this.approvals.get(args[0] as string); + } + return { count: 0 }; + } + 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 }; @@ -184,6 +251,7 @@ class TestDb { string, string, ]; + const previous = this.approvals.get(id); this.approvals.set(id, { id, plan_id: planId, @@ -192,8 +260,8 @@ class TestDb { title, status, payload, - sync_status: "pending", - created_at: createdAt, + sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending", + created_at: previous?.created_at ?? createdAt, updated_at: updatedAt, }); return { changes: 1 }; diff --git a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts index 29a52125..7b3e9952 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.ts @@ -337,6 +337,7 @@ export function saveDigitalEmployeeUiState( ): DigitalEmployeeUiState { const state = normalizeUiState(update.state); writeSetting(UI_STATE_KEY, state); + recordUiApprovalDecision(update); if (update.action) { recordDigitalEmployeeRuntimeEvent({ kind: `digital_workday_${update.action}`, @@ -353,6 +354,59 @@ export function saveDigitalEmployeeUiState( return state; } +function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void { + if (update.action !== "decide_approval") return; + const metadata = objectRecord(update.metadata); + const decisionId = stringValue(metadata?.decision_id); + const decision = stringValue(metadata?.decision); + if (!decisionId || !decision) return; + + const db = getDb(); + if (!db) return; + const existing = db.prepare(` + SELECT id, plan_id, task_id, run_id, title, status, payload, created_at + FROM digital_approvals + WHERE id = ? + `).get(decisionId) as Record | undefined; + if (!existing) return; + + const now = new Date().toISOString(); + const previousStatus = stringField(existing, "status") || "pending"; + const storedPayload = parseStoredPayload(existing["payload"]); + const storedPayloadRecord = objectRecord(storedPayload); + const payload = storedPayloadRecord + ? { + ...storedPayloadRecord, + decision, + decidedAt: now, + previousStatus, + } + : { + value: storedPayload, + decision, + decidedAt: now, + previousStatus, + }; + upsertApproval({ + id: decisionId, + planId: stringField(existing, "plan_id"), + taskId: stringField(existing, "task_id"), + runId: stringField(existing, "run_id"), + title: stringField(existing, "title") || "待确认事项", + status: approvalDecisionStatus(decision), + payload, + now, + }); +} + +function approvalDecisionStatus(decision: string): string { + const normalized = decision.toLowerCase(); + if (["approve", "approved", "accept", "accepted"].includes(normalized)) return "approved"; + if (["reject", "rejected", "deny", "denied"].includes(normalized)) return "rejected"; + if (["dismiss", "dismissed", "skip", "skipped"].includes(normalized)) return "dismissed"; + return "completed"; +} + export function readDigitalEmployeeRuntimeRecords( limit = 120, ): DigitalEmployeeRuntimeRecords { diff --git a/qimingclaw/docs/digital-employee-frontend-integration-plan.md b/qimingclaw/docs/digital-employee-frontend-integration-plan.md index 409f8db1..4b9ce59f 100644 --- a/qimingclaw/docs/digital-employee-frontend-integration-plan.md +++ b/qimingclaw/docs/digital-employee-frontend-integration-plan.md @@ -450,7 +450,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts - 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。 - 数字员工调试存储、dashboard、flow 和产物列表通过 qimingclaw adapter 的 `/api/debug/store`、`/api/dashboard`、`/api/debug/plan/:id/flow`、`/api/debug/task/:id/flow`、`/api/artifact` 投射本地 Plan / Task / Run / Event / Artifact / Approval;Run/Event payload 中的 `artifacts`、`outputs`、`files`、`outputPath`、`uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。 - qimingclaw 主进程记录 `/computer/chat` 结果和 runtime event 时,会从 payload 的 `artifacts`、`outputs`、`files`、`attachments`、`outputPath`、`uri` 以及 `approvals`、`approval_requests`、`needApproval` 等字段轻量提取正式 `digital_artifacts` / `digital_approvals` 记录,并进入 outbox 同步。 -- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action,把处理结果写入 SQLite UI state,并生成 `digital_workday_decide_approval` runtime event。 +- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action,把处理结果写入 SQLite UI state,回写正式 approval 状态并进入 approval outbox,同时生成 `digital_workday_decide_approval` runtime event。 - 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan`、`submit_plan`、`approve_plan`、`activate_plan`、`create_schedule`、`run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。 - 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。 - 任务中心在加载到 qimingclaw 真实计划时不再混入“白领一天”等 demo supplement;只有没有任何真实计划或请求失败时才使用本地示例兜底。