feat(client): reply digital approvals to acp
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 8:Artifact / 日报 / 汇报
|
## Phase 8:Artifact / 日报 / 汇报
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user