吸收数字员工审批时间线

This commit is contained in:
baiyanyun
2026-06-09 17:33:00 +08:00
parent 5160a73254
commit dc8cd09535
8 changed files with 333 additions and 147 deletions

View File

@@ -1,5 +1,6 @@
import type { import type {
ApprovalEntry, ApprovalEntry,
ApprovalTimelineResponse,
ArtifactEntry, ArtifactEntry,
CanonicalEvent, CanonicalEvent,
CronJob, CronJob,
@@ -639,6 +640,10 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname === '/api/artifact') { if (method === 'GET' && pathname === '/api/artifact') {
return { artifacts: buildArtifacts(await readQimingclawSnapshot()) } as T; return { artifacts: buildArtifacts(await readQimingclawSnapshot()) } as T;
} }
const approvalTimelineMatch = pathname.match(/^\/api\/approval\/([^/]+)\/timeline$/);
if (method === 'GET' && approvalTimelineMatch) {
return buildApprovalTimeline(decodeURIComponent(approvalTimelineMatch[1] || ''), await readQimingclawSnapshot()) as T;
}
if (method === 'GET' && pathname === '/api/memory') { if (method === 'GET' && pathname === '/api/memory') {
return { entries: await readMemoryEntries(url.searchParams.get('query'), url.searchParams.get('category')) } as T; return { entries: await readMemoryEntries(url.searchParams.get('query'), url.searchParams.get('category')) } as T;
} }
@@ -1963,6 +1968,107 @@ function buildApprovals(snapshot: QimingclawSnapshot | null): ApprovalEntry[] {
return [...runtimeApprovalEntries, ...syncReviewEntries]; return [...runtimeApprovalEntries, ...syncReviewEntries];
} }
function buildApprovalTimeline(approvalId: string, snapshot: QimingclawSnapshot | null): ApprovalTimelineResponse {
const approval = buildApprovals(snapshot).find((candidate) => stringValue(candidate.approval_id) === approvalId) ?? null;
const runtimeApproval = runtimeApprovals(snapshot).find((candidate) => candidate.id === approvalId) ?? null;
const approvalPayload = asRecord(approval?.payload ?? runtimeApproval?.payload);
const approvalAcpPermission = acpPermissionFromPayload(approvalPayload);
const refs = {
approvalId,
planId: stringValue(approval?.plan_id) || runtimeApproval?.planId || '',
taskId: stringValue(approval?.task_id) || runtimeApproval?.taskId || '',
runId: stringValue(approval?.run_id) || runtimeApproval?.runId || '',
permissionId: approvalAcpPermission.permissionId,
sessionId: approvalAcpPermission.sessionId,
};
const approvalCreatedAt = stringValue(approval?.created_at) || runtimeApproval?.createdAt || runtimeApproval?.updatedAt || '';
const entries: ApprovalTimelineResponse['events'] = [];
if (approval || runtimeApproval) {
const title = stringValue(approval?.title) || runtimeApproval?.title || '待确认事项';
const status = stringValue(approval?.status) || runtimeApproval?.status || 'pending';
entries.push({
event_id: `${approvalId}:approval_record`,
kind: 'approval_record',
status,
message: `审批记录:${title} -> ${status}`,
occurred_at: approvalCreatedAt || new Date().toISOString(),
payload: approvalPayload,
source_type: 'approval',
});
}
for (const event of runtimeEvents(snapshot)) {
if (!isApprovalTimelineEvent(event, refs)) continue;
const payload = asRecord(event.payload);
entries.push({
event_id: event.id,
kind: event.kind,
status: approvalTimelineEventStatus(event, payload),
message: event.message || event.kind,
occurred_at: event.occurredAt,
payload,
source_type: stringValue(payload?.source) || 'runtime_event',
});
}
return {
approval_id: approvalId,
approval,
events: dedupeApprovalTimelineEvents(entries)
.sort((left, right) => left.occurred_at.localeCompare(right.occurred_at) || left.event_id.localeCompare(right.event_id)),
};
}
function acpPermissionFromPayload(payload: Record<string, unknown> | null): { permissionId: string; sessionId: string } {
const nested = asRecord(payload?.acpPermission) ?? asRecord(payload?.acp_permission) ?? asRecord(payload?.permission);
return {
permissionId: stringValue(nested?.permission_id) || stringValue(nested?.permissionId) || stringValue(payload?.permission_id) || stringValue(payload?.permissionId),
sessionId: stringValue(nested?.session_id) || stringValue(nested?.sessionId) || stringValue(payload?.session_id) || stringValue(payload?.sessionId),
};
}
function isApprovalTimelineEvent(
event: QimingclawEventRecord,
refs: { approvalId: string; planId: string; taskId: string; runId: string; permissionId: string; sessionId: string },
): boolean {
const payload = asRecord(event.payload);
const payloadApprovalId = stringValue(payload?.approvalId)
|| stringValue(payload?.approval_id)
|| stringValue(payload?.decision_id)
|| stringValue(payload?.decisionId);
if (payloadApprovalId === refs.approvalId) return true;
if (event.id.startsWith(`${refs.approvalId}:`)) return true;
const acpPermission = acpPermissionFromPayload(payload);
if (refs.permissionId && acpPermission.permissionId === refs.permissionId) return true;
if (refs.sessionId && acpPermission.sessionId === refs.sessionId) return true;
const approvalLikeKind = event.kind.includes('approval') || event.kind.includes('permission');
if (!approvalLikeKind) return false;
if (refs.runId && event.runId === refs.runId) return true;
if (refs.taskId && event.taskId === refs.taskId) return true;
if (refs.planId && event.planId === refs.planId) return true;
return false;
}
function approvalTimelineEventStatus(event: QimingclawEventRecord, payload: Record<string, unknown> | null): string | null {
return stringValue(payload?.nextStatus)
|| stringValue(payload?.next_status)
|| stringValue(payload?.status)
|| stringValue(payload?.decision)
|| (event.kind === 'approval_decision' ? 'completed' : null);
}
function dedupeApprovalTimelineEvents(events: ApprovalTimelineResponse['events']): ApprovalTimelineResponse['events'] {
const seen = new Set<string>();
return events.filter((event) => {
if (seen.has(event.event_id)) return false;
seen.add(event.event_id);
return true;
});
}
function buildWorkdayDecisions( function buildWorkdayDecisions(
state: AdapterState, state: AdapterState,
date: string, date: string,

View File

@@ -443,6 +443,22 @@ export interface ApprovalEntry {
[key: string]: any; [key: string]: any;
} }
export interface ApprovalTimelineEvent {
event_id: string;
kind: string;
status?: string | null;
message: string;
occurred_at: string;
payload?: Record<string, unknown> | null;
source_type: string;
}
export interface ApprovalTimelineResponse {
approval_id: string;
approval: ApprovalEntry | null;
events: ApprovalTimelineEvent[];
}
export interface ArtifactEntry { export interface ArtifactEntry {
artifact_id?: string; artifact_id?: string;
subject_id: string; subject_id: string;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,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-Dm9GtUrc.js"></script> <script type="module" crossorigin src="./assets/index-g_4byzgP.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css"> <link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
</head> </head>
<body> <body>

View File

@@ -342,6 +342,39 @@ describe("digital employee state service", () => {
entity_id: "approval-1", entity_id: "approval-1",
status: "pending", status: "pending",
}); });
const decisionEvents = Array.from(mockState.db?.events.values() ?? []).filter((event) => event.kind === "approval_decision");
expect(decisionEvents).toHaveLength(1);
expect(decisionEvents[0]).toMatchObject({
id: "approval-1:decision:approved:2026-06-07T08-00-00-000Z",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
message: "审批处理:确认发送日报给管理端 -> approved",
sync_status: "pending",
occurred_at: "2026-06-07T08:00:00.000Z",
});
expect(JSON.parse(decisionEvents[0]?.payload ?? "{}")).toMatchObject({
source: "qimingclaw-approval-decision",
approvalId: "approval-1",
decision: "approved",
previousStatus: "pending",
nextStatus: "approved",
title: "确认发送日报给管理端",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
decidedAt: "2026-06-07T08:00:00.000Z",
acpPermission: {
permission_id: "permission-1",
session_id: "session-1",
},
});
expect(mockState.db?.outbox.get("event:insert:approval-1:decision:approved:2026-06-07T08-00-00-000Z")).toMatchObject({
entity_type: "event",
entity_id: "approval-1:decision:approved:2026-06-07T08-00-00-000Z",
operation: "insert",
status: "pending",
});
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0); expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
}); });

View File

@@ -662,6 +662,11 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
const now = new Date().toISOString(); const now = new Date().toISOString();
const previousStatus = stringField(existing, "status") || "pending"; const previousStatus = stringField(existing, "status") || "pending";
const nextStatus = approvalDecisionStatus(decision);
const planId = stringField(existing, "plan_id");
const taskId = stringField(existing, "task_id");
const runId = stringField(existing, "run_id");
const title = stringField(existing, "title") || "待确认事项";
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 acpPermission = objectRecord(metadata?.acp_permission);
@@ -682,14 +687,39 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
}; };
upsertApproval({ upsertApproval({
id: decisionId, id: decisionId,
planId: stringField(existing, "plan_id"), planId,
taskId: stringField(existing, "task_id"), taskId,
runId: stringField(existing, "run_id"), runId,
title: stringField(existing, "title") || "待确认事项", title,
status: approvalDecisionStatus(decision), status: nextStatus,
payload, payload,
now, now,
}); });
const eventPayload = {
source: "qimingclaw-approval-decision",
approvalId: decisionId,
decision,
previousStatus,
nextStatus,
title,
planId,
taskId,
runId,
decidedAt: now,
...(acpPermission ? { acpPermission } : {}),
};
insertEvent(
{
event_id: `${decisionId}:decision:${safeId(decision)}:${safeId(now)}`,
kind: "approval_decision",
occurred_at: now,
message: `审批处理:${title} -> ${decision}`,
plan_id: planId,
task_id: taskId,
},
runId || null,
eventPayload,
);
} }
function approvalDecisionStatus(decision: string): string { function approvalDecisionStatus(decision: string): string {

View File

@@ -578,9 +578,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕 Artifact 预览和下载权限,把 File Server 交付状态从事实归集推进到可操作交付视图。 - 建议:下一轮围绕 Artifact 预览和下载权限,把 File Server 交付状态从事实归集推进到可操作交付视图。
8. **审批 / 人工介入增强** 8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形。 - 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event并可通过 embedded `/api/approval/:approvalId/timeline` 读取只读审批动作时间线,审批动作时间线已开始闭环
- 缺口缺少评论、转交、超时、SLA、通知、审批历史、风险等级和多步审批流。 - 缺口缺少评论、转交、超时、SLA、通知、审批历史 UI、风险等级和多步审批流。
- 建议:先补 approval timeline 和 notification 投影,再做多动作审批。 - 建议:下一轮先补 notification 投影和审批历史 UI,再做多动作审批。
9. **通知 / Inbox / 用户消息** 9. **通知 / Inbox / 用户消息**
- 已吸收:运行事件和同步失败能在数字员工页面展示。 - 已吸收:运行事件和同步失败能在数字员工页面展示。
@@ -1057,6 +1057,7 @@ Approval
- embedded digital 页面展示这些 runtime approval。 - embedded digital 页面展示这些 runtime approval。
- 用户点击同意 / 驳回时adapter 从 approval payload 取出 `sessionId` / `permissionId`,通过 preload bridge 调用 `agent:respondPermission` - 用户点击同意 / 驳回时adapter 从 approval payload 取出 `sessionId` / `permissionId`,通过 preload bridge 调用 `agent:respondPermission`
- 本地 approval payload 会记录 `acpPermission` 回复结果,供管理端同步和审计。 - 本地 approval payload 会记录 `acpPermission` 回复结果,供管理端同步和审计。
- 用户处理审批后,主进程会同步写入 `approval_decision` runtime eventembedded adapter 新增 `/api/approval/:approvalId/timeline`,可从 approval 记录和相关 runtime event 投影只读审批动作时间线。
用户可操作: 用户可操作: