吸收数字员工审批时间线
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 event;embedded adapter 新增 `/api/approval/:approvalId/timeline`,可从 approval 记录和相关 runtime event 投影只读审批动作时间线。
|
||||||
|
|
||||||
用户可操作:
|
用户可操作:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user