feat(client): reply digital approvals to acp

This commit is contained in:
baiyanyun
2026-06-08 10:23:06 +08:00
parent ee51fcdbeb
commit f4af77cb2b
8 changed files with 228 additions and 130 deletions

View File

@@ -36,6 +36,7 @@ declare global {
getSyncStatus?: () => Promise<QimingclawSyncStatus>; getSyncStatus?: () => Promise<QimingclawSyncStatus>;
flushSync?: () => Promise<QimingclawSyncStatus>; flushSync?: () => Promise<QimingclawSyncStatus>;
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>; recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
}; };
}; };
} }
@@ -1769,17 +1770,86 @@ async function handleWorkdayAction(body: DigitalEmployeeWorkdayActionRequest, sn
}; };
} }
const acpPermissionResult = body.action === 'decide_approval'
? await respondAcpPermissionForDecision(body, snapshot)
: null;
const savedState = await saveState( const savedState = await saveState(
state, state,
body.action, body.action,
date, date,
body.action === 'decide_approval' body.action === 'decide_approval'
? { decision_id: body.decision_id, decision: body.decision } ? {
decision_id: body.decision_id,
decision: body.decision,
acp_permission: acpPermissionResult,
}
: undefined, : undefined,
); );
return buildWorkday(date, { ...(snapshot ?? { generatedAt: new Date().toISOString(), services: {}, sessions: { total: 0, active: 0, items: [] } }), uiState: savedState }); return buildWorkday(date, { ...(snapshot ?? { generatedAt: new Date().toISOString(), services: {}, sessions: { total: 0, active: 0, items: [] } }), uiState: savedState });
} }
async function respondAcpPermissionForDecision(
body: DigitalEmployeeWorkdayActionRequest,
snapshot: QimingclawSnapshot | null,
): Promise<Record<string, unknown> | null> {
if (!body.decision_id || !body.decision) return null;
const approval = findApprovalForDecision(body.decision_id, snapshot);
if (!approval) return null;
const refs = acpPermissionRefs(approval);
if (!refs) return null;
const response = acpResponseForDecision(body.decision);
const respondPermission = window.QimingClawBridge?.digital?.respondPermission;
if (!respondPermission) return null;
try {
const result = await respondPermission(refs.sessionId, refs.permissionId, response);
return {
permission_id: refs.permissionId,
session_id: refs.sessionId,
response,
success: result?.success !== false,
error: result?.error,
};
} catch (error) {
return {
permission_id: refs.permissionId,
session_id: refs.sessionId,
response,
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
function findApprovalForDecision(decisionId: string, snapshot: QimingclawSnapshot | null): ApprovalEntry | null {
return buildApprovals(snapshot).find((approval) => stringValue(approval.approval_id) === decisionId) ?? null;
}
function acpPermissionRefs(approval: ApprovalEntry): { permissionId: string; sessionId: string } | null {
const payload = asRecord(approval.payload);
const nestedPayload = asRecord(payload?.payload);
const permissionId = stringValue(payload?.permissionId)
|| stringValue(payload?.permission_id)
|| stringValue(payload?.id)
|| stringValue(nestedPayload?.permissionId)
|| stringValue(nestedPayload?.permission_id)
|| stringValue(nestedPayload?.id);
const sessionId = stringValue(payload?.sessionId)
|| stringValue(payload?.session_id)
|| stringValue(nestedPayload?.sessionId)
|| stringValue(nestedPayload?.session_id);
const source = stringValue(payload?.source);
if (!permissionId || !sessionId || (source && source !== 'acp-permission')) return null;
return { permissionId, sessionId };
}
function acpResponseForDecision(decision: string): 'once' | 'always' | 'reject' {
const normalized = decision.toLowerCase();
if (normalized.includes('always')) return 'always';
if (normalized.includes('reject') || normalized.includes('deny')) return 'reject';
return 'once';
}
async function handleGovernanceCommand( async function handleGovernanceCommand(
body: GovernanceCommandRequest, body: GovernanceCommandRequest,
snapshot: QimingclawSnapshot | null, snapshot: QimingclawSnapshot | null,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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-DN59eLPD.js"></script> <script type="module" crossorigin src="./assets/index-qn2ia0Ac.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DeYFPN7i.css"> <link rel="stylesheet" crossorigin href="./assets/index-DeYFPN7i.css">
</head> </head>
<body> <body>

View File

@@ -104,6 +104,12 @@ describe("digital employee state service", () => {
metadata: { metadata: {
decision_id: "approval-1", decision_id: "approval-1",
decision: "approved", decision: "approved",
acp_permission: {
permission_id: "permission-1",
session_id: "session-1",
response: "once",
success: true,
},
}, },
state: { state: {
selectedDutyIdsByDate: {}, selectedDutyIdsByDate: {},
@@ -135,6 +141,12 @@ describe("digital employee state service", () => {
decision: "approved", decision: "approved",
decidedAt: "2026-06-07T08:00:00.000Z", decidedAt: "2026-06-07T08:00:00.000Z",
previousStatus: "pending", previousStatus: "pending",
acpPermission: {
permission_id: "permission-1",
session_id: "session-1",
response: "once",
success: true,
},
}); });
expect(mockState.db?.outbox.get("approval:upsert:approval-1")).toMatchObject({ expect(mockState.db?.outbox.get("approval:upsert:approval-1")).toMatchObject({
entity_type: "approval", entity_type: "approval",

View File

@@ -392,18 +392,21 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
const previousStatus = stringField(existing, "status") || "pending"; const previousStatus = stringField(existing, "status") || "pending";
const storedPayload = parseStoredPayload(existing["payload"]); const storedPayload = parseStoredPayload(existing["payload"]);
const storedPayloadRecord = objectRecord(storedPayload); const storedPayloadRecord = objectRecord(storedPayload);
const acpPermission = objectRecord(metadata?.acp_permission);
const payload = storedPayloadRecord const payload = storedPayloadRecord
? { ? {
...storedPayloadRecord, ...storedPayloadRecord,
decision, decision,
decidedAt: now, decidedAt: now,
previousStatus, previousStatus,
...(acpPermission ? { acpPermission } : {}),
} }
: { : {
value: storedPayload, value: storedPayload,
decision, decision,
decidedAt: now, decidedAt: now,
previousStatus, previousStatus,
...(acpPermission ? { acpPermission } : {}),
}; };
upsertApproval({ upsertApproval({
id: decisionId, id: decisionId,

View File

@@ -119,5 +119,8 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async recordGovernanceCommand(command: unknown) { async recordGovernanceCommand(command: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command); return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
}, },
async respondPermission(sessionId: string, permissionId: string, response: "once" | "always" | "reject") {
return ipcRenderer.invoke("agent:respondPermission", sessionId, permissionId, response);
},
}, },
}); });

View File

@@ -927,6 +927,14 @@ Approval
- 等待输入 - 等待输入
- 风险说明 - 风险说明
当前客户端实现:
- ACP `permission.updated` / `permission.replied` 事件会被 event forwarder 归一成 runtime event。
- `approval_requests` 会抽取到 `digital_approvals`,并进入本地 outbox。
- embedded digital 页面展示这些 runtime approval。
- 用户点击同意 / 驳回时adapter 从 approval payload 取出 `sessionId` / `permissionId`,通过 preload bridge 调用 `agent:respondPermission`
- 本地 approval payload 会记录 `acpPermission` 回复结果,供管理端同步和审计。
用户可操作: 用户可操作:
- approve - approve
@@ -941,11 +949,13 @@ Approval
- 人工介入成为主路径能力。 - 人工介入成为主路径能力。
- 数字员工不是黑箱自动执行。 - 数字员工不是黑箱自动执行。
- 数字员工审批动作能继续 ACP permission 链路。
### 验收 ### 验收
- ACP permission request 能进入数字员工审批区。 - ACP permission request 能进入数字员工审批区。
- 用户在数字员工页面审批后ACP 执行继续。 - 用户在数字员工页面审批后ACP 执行继续。
- approval 同步 payload 能看到对应 `permission_id``session_id` 和回复结果。
## Phase 8Artifact / 日报 / 汇报 ## Phase 8Artifact / 日报 / 汇报