吸收数字员工审批时间线
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ApprovalEntry,
|
||||
ApprovalTimelineResponse,
|
||||
ArtifactEntry,
|
||||
CanonicalEvent,
|
||||
CronJob,
|
||||
@@ -639,6 +640,10 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && pathname === '/api/artifact') {
|
||||
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') {
|
||||
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];
|
||||
}
|
||||
|
||||
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(
|
||||
state: AdapterState,
|
||||
date: string,
|
||||
|
||||
@@ -443,6 +443,22 @@ export interface ApprovalEntry {
|
||||
[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 {
|
||||
artifact_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
@@ -16,7 +16,7 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -342,6 +342,39 @@ describe("digital employee state service", () => {
|
||||
entity_id: "approval-1",
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -662,6 +662,11 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
|
||||
|
||||
const now = new Date().toISOString();
|
||||
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 storedPayloadRecord = objectRecord(storedPayload);
|
||||
const acpPermission = objectRecord(metadata?.acp_permission);
|
||||
@@ -682,14 +687,39 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
|
||||
};
|
||||
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),
|
||||
planId,
|
||||
taskId,
|
||||
runId,
|
||||
title,
|
||||
status: nextStatus,
|
||||
payload,
|
||||
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 {
|
||||
|
||||
@@ -578,9 +578,9 @@ GET /api/digital-employee/approvals
|
||||
- 建议:下一轮围绕 Artifact 预览和下载权限,把 File Server 交付状态从事实归集推进到可操作交付视图。
|
||||
|
||||
8. **审批 / 人工介入增强**
|
||||
- 已吸收:approval 记录、workday decision、ACP permission 响应桥已有雏形。
|
||||
- 缺口:缺少评论、转交、超时、SLA、通知、审批历史、风险等级和多步审批流。
|
||||
- 建议:先补 approval timeline 和 notification 投影,再做多动作审批。
|
||||
- 已吸收:approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event,并可通过 embedded `/api/approval/:approvalId/timeline` 读取只读审批动作时间线,审批动作时间线已开始闭环。
|
||||
- 缺口:缺少评论、转交、超时、SLA、通知、审批历史 UI、风险等级和多步审批流。
|
||||
- 建议:下一轮先补 notification 投影和审批历史 UI,再做多动作审批。
|
||||
|
||||
9. **通知 / Inbox / 用户消息**
|
||||
- 已吸收:运行事件和同步失败能在数字员工页面展示。
|
||||
@@ -1057,6 +1057,7 @@ Approval
|
||||
- embedded digital 页面展示这些 runtime approval。
|
||||
- 用户点击同意 / 驳回时,adapter 从 approval payload 取出 `sessionId` / `permissionId`,通过 preload bridge 调用 `agent:respondPermission`。
|
||||
- 本地 approval payload 会记录 `acpPermission` 回复结果,供管理端同步和审计。
|
||||
- 用户处理审批后,主进程会同步写入 `approval_decision` runtime event;embedded adapter 新增 `/api/approval/:approvalId/timeline`,可从 approval 记录和相关 runtime event 投影只读审批动作时间线。
|
||||
|
||||
用户可操作:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user