fix(client): sync digital approval decisions
This commit is contained in:
@@ -75,6 +75,66 @@ describe("digital employee state service", () => {
|
|||||||
status: "pending",
|
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 {
|
interface TestArtifactRow {
|
||||||
@@ -133,11 +193,18 @@ class TestDb {
|
|||||||
const normalized = sql.replace(/\s+/g, " ").trim();
|
const normalized = sql.replace(/\s+/g, " ").trim();
|
||||||
return {
|
return {
|
||||||
all: () => [],
|
all: () => [],
|
||||||
get: () => ({ count: 0 }),
|
get: (...args: unknown[]) => this.get(normalized, args),
|
||||||
run: (...args: unknown[]) => this.run(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 {
|
private run(sql: string, args: unknown[]): unknown {
|
||||||
if (sql.startsWith("INSERT INTO digital_plans")) return { changes: 1 };
|
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_tasks")) return { changes: 1 };
|
||||||
@@ -184,6 +251,7 @@ class TestDb {
|
|||||||
string,
|
string,
|
||||||
string,
|
string,
|
||||||
];
|
];
|
||||||
|
const previous = this.approvals.get(id);
|
||||||
this.approvals.set(id, {
|
this.approvals.set(id, {
|
||||||
id,
|
id,
|
||||||
plan_id: planId,
|
plan_id: planId,
|
||||||
@@ -192,8 +260,8 @@ class TestDb {
|
|||||||
title,
|
title,
|
||||||
status,
|
status,
|
||||||
payload,
|
payload,
|
||||||
sync_status: "pending",
|
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||||
created_at: createdAt,
|
created_at: previous?.created_at ?? createdAt,
|
||||||
updated_at: updatedAt,
|
updated_at: updatedAt,
|
||||||
});
|
});
|
||||||
return { changes: 1 };
|
return { changes: 1 };
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export function saveDigitalEmployeeUiState(
|
|||||||
): DigitalEmployeeUiState {
|
): DigitalEmployeeUiState {
|
||||||
const state = normalizeUiState(update.state);
|
const state = normalizeUiState(update.state);
|
||||||
writeSetting(UI_STATE_KEY, state);
|
writeSetting(UI_STATE_KEY, state);
|
||||||
|
recordUiApprovalDecision(update);
|
||||||
if (update.action) {
|
if (update.action) {
|
||||||
recordDigitalEmployeeRuntimeEvent({
|
recordDigitalEmployeeRuntimeEvent({
|
||||||
kind: `digital_workday_${update.action}`,
|
kind: `digital_workday_${update.action}`,
|
||||||
@@ -353,6 +354,59 @@ export function saveDigitalEmployeeUiState(
|
|||||||
return state;
|
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<string, unknown> | 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(
|
export function readDigitalEmployeeRuntimeRecords(
|
||||||
limit = 120,
|
limit = 120,
|
||||||
): DigitalEmployeeRuntimeRecords {
|
): DigitalEmployeeRuntimeRecords {
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
|||||||
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。
|
- 数字员工技能库通过 `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 记录为空时的兼容产物入口展示。
|
- 数字员工调试存储、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 同步。
|
- 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 状态。
|
- 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan`、`submit_plan`、`approve_plan`、`activate_plan`、`create_schedule`、`run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。
|
||||||
- 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。
|
- 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。
|
||||||
- 任务中心在加载到 qimingclaw 真实计划时不再混入“白领一天”等 demo supplement;只有没有任何真实计划或请求失败时才使用本地示例兜底。
|
- 任务中心在加载到 qimingclaw 真实计划时不再混入“白领一天”等 demo supplement;只有没有任何真实计划或请求失败时才使用本地示例兜底。
|
||||||
|
|||||||
Reference in New Issue
Block a user