feat(client): reply digital approvals to acp
This commit is contained in:
@@ -36,6 +36,7 @@ declare global {
|
||||
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
||||
flushSync?: () => Promise<QimingclawSyncStatus>;
|
||||
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(
|
||||
state,
|
||||
body.action,
|
||||
date,
|
||||
body.action === 'decide_approval'
|
||||
? { decision_id: body.decision_id, decision: body.decision }
|
||||
? {
|
||||
decision_id: body.decision_id,
|
||||
decision: body.decision,
|
||||
acp_permission: acpPermissionResult,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
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(
|
||||
body: GovernanceCommandRequest,
|
||||
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
@@ -15,7 +15,7 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -104,6 +104,12 @@ describe("digital employee state service", () => {
|
||||
metadata: {
|
||||
decision_id: "approval-1",
|
||||
decision: "approved",
|
||||
acp_permission: {
|
||||
permission_id: "permission-1",
|
||||
session_id: "session-1",
|
||||
response: "once",
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
selectedDutyIdsByDate: {},
|
||||
@@ -135,6 +141,12 @@ describe("digital employee state service", () => {
|
||||
decision: "approved",
|
||||
decidedAt: "2026-06-07T08:00:00.000Z",
|
||||
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({
|
||||
entity_type: "approval",
|
||||
|
||||
@@ -392,18 +392,21 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
|
||||
const previousStatus = stringField(existing, "status") || "pending";
|
||||
const storedPayload = parseStoredPayload(existing["payload"]);
|
||||
const storedPayloadRecord = objectRecord(storedPayload);
|
||||
const acpPermission = objectRecord(metadata?.acp_permission);
|
||||
const payload = storedPayloadRecord
|
||||
? {
|
||||
...storedPayloadRecord,
|
||||
decision,
|
||||
decidedAt: now,
|
||||
previousStatus,
|
||||
...(acpPermission ? { acpPermission } : {}),
|
||||
}
|
||||
: {
|
||||
value: storedPayload,
|
||||
decision,
|
||||
decidedAt: now,
|
||||
previousStatus,
|
||||
...(acpPermission ? { acpPermission } : {}),
|
||||
};
|
||||
upsertApproval({
|
||||
id: decisionId,
|
||||
|
||||
@@ -119,5 +119,8 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async recordGovernanceCommand(command: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
|
||||
},
|
||||
async respondPermission(sessionId: string, permissionId: string, response: "once" | "always" | "reject") {
|
||||
return ipcRenderer.invoke("agent:respondPermission", sessionId, permissionId, response);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -941,11 +949,13 @@ Approval
|
||||
|
||||
- 人工介入成为主路径能力。
|
||||
- 数字员工不是黑箱自动执行。
|
||||
- 数字员工审批动作能继续 ACP permission 链路。
|
||||
|
||||
### 验收
|
||||
|
||||
- ACP permission request 能进入数字员工审批区。
|
||||
- 用户在数字员工页面审批后,ACP 执行继续。
|
||||
- approval 同步 payload 能看到对应 `permission_id`、`session_id` 和回复结果。
|
||||
|
||||
## Phase 8:Artifact / 日报 / 汇报
|
||||
|
||||
|
||||
Reference in New Issue
Block a user