吸收数字员工审批历史联动

This commit is contained in:
baiyanyun
2026-06-10 10:46:28 +08:00
parent ed2f19d257
commit 047651e404
15 changed files with 893 additions and 192 deletions

View File

@@ -497,6 +497,70 @@ describe("digital employee state service", () => {
});
});
it("records approval action history as syncable sanitized events", async () => {
const { recordDigitalEmployeeApprovalAction } = await import("./stateService");
const longComment = `${"需要业务复核。".repeat(20)} sensitive-tail-token`;
const recorded = recordDigitalEmployeeApprovalAction({
approvalId: "approval-1",
action: "delegate",
comment: longComment,
delegateTo: "ops-lead",
delegateLabel: "运营负责人",
actor: "operator",
source: "test",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
occurredAt: "2026-06-07T08:37:00.000Z",
});
const rejected = recordDigitalEmployeeApprovalAction({
approvalId: "approval-1",
action: "delete_approval",
occurredAt: "2026-06-07T08:38:00.000Z",
});
expect(recorded?.eventId).toBe("approval-action:approval-1:delegate:2026-06-07T08-37-00-000Z");
expect(mockState.db?.events.get(recorded!.eventId)).toMatchObject({
kind: "approval_action_recorded",
message: "审批动作已记录:转交 approval-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
const payload = JSON.parse(mockState.db?.events.get(recorded!.eventId)?.payload ?? "{}");
expect(payload).toMatchObject({
approval_id: "approval-1",
action: "delegate",
status: "recorded",
comment_summary: {
length: longComment.length,
},
delegate_summary: {
target: "ops-lead",
label: "运营负责人",
},
actor: "operator",
source: "test",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
expect(payload.comment_summary.preview.length).toBeLessThanOrEqual(160);
expect(JSON.stringify(payload)).not.toContain("sensitive-tail-token");
expect(mockState.db?.outbox.get(`event:insert:${recorded!.eventId}`)).toMatchObject({
entity_type: "event",
entity_id: recorded!.eventId,
operation: "insert",
status: "pending",
});
expect(rejected?.kind).toBe("approval_action_rejected");
expect(JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}")).toMatchObject({
action: "delete_approval",
status: "rejected",
});
});
it("records and lists route decisions as syncable events", async () => {
const {
listDigitalEmployeeRouteDecisions,

View File

@@ -263,6 +263,32 @@ export interface DigitalEmployeeArtifactLifecycleRecord {
payload: Record<string, unknown>;
}
export type DigitalEmployeeApprovalActionKind = "comment" | "delegate" | "mark_timeout" | "mark_risk" | "clear_risk";
export interface DigitalEmployeeApprovalActionInput {
approvalId?: string | null;
action: DigitalEmployeeApprovalActionKind | string;
comment?: string | null;
delegateTo?: string | null;
delegateLabel?: string | null;
dueAt?: string | null;
riskLevel?: string | null;
reason?: string | null;
actor?: string | null;
source?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
occurredAt?: string | null;
message?: string | null;
}
export interface DigitalEmployeeApprovalActionRecord {
eventId: string;
kind: "approval_action_recorded" | "approval_action_rejected";
payload: Record<string, unknown>;
}
export interface DigitalEmployeeRouteDecisionRecord {
id: string;
route_kind: string;
@@ -1155,6 +1181,104 @@ export function recordDigitalEmployeeArtifactLifecycle(
return { eventId, kind, payload };
}
export function recordDigitalEmployeeApprovalAction(
input: DigitalEmployeeApprovalActionInput,
): DigitalEmployeeApprovalActionRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = input.occurredAt || new Date().toISOString();
const approvalId = stringValue(input.approvalId) || "unknown-approval";
const action = stringValue(input.action) || "unknown_action";
const accepted = isDigitalEmployeeApprovalAction(action);
const kind: DigitalEmployeeApprovalActionRecord["kind"] = accepted
? "approval_action_recorded"
: "approval_action_rejected";
const eventId = `approval-action:${safeId(approvalId)}:${safeId(action)}:${safeId(now)}`;
const payload: Record<string, unknown> = {
approval_id: approvalId,
action,
status: accepted ? "recorded" : "rejected",
comment_summary: sanitizeApprovalComment(input.comment),
delegate_summary: sanitizeApprovalDelegate(input.delegateTo, input.delegateLabel),
sla_summary: sanitizeApprovalSla(input.dueAt, action),
risk_summary: sanitizeApprovalRisk(input.riskLevel, action),
reason: stringValue(input.reason) || null,
actor: stringValue(input.actor) || null,
source: stringValue(input.source) || "qimingclaw-approval-action",
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
run_id: stringValue(input.runId) || null,
};
insertEvent(
{
event_id: eventId,
kind,
occurred_at: now,
message: input.message || approvalActionMessage(kind, action, approvalId),
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
},
stringValue(input.runId) || null,
payload,
);
return { eventId, kind, payload };
}
function isDigitalEmployeeApprovalAction(action: string): action is DigitalEmployeeApprovalActionKind {
return ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk"].includes(action);
}
function sanitizeApprovalComment(comment: string | null | undefined): Record<string, unknown> | null {
const text = stringValue(comment);
if (!text) return null;
return {
preview: text.slice(0, 160),
length: text.length,
};
}
function sanitizeApprovalDelegate(delegateTo: string | null | undefined, delegateLabel: string | null | undefined): Record<string, unknown> | null {
const target = stringValue(delegateTo);
const label = stringValue(delegateLabel);
if (!target && !label) return null;
return {
target: target || null,
label: label || target || null,
};
}
function sanitizeApprovalSla(dueAt: string | null | undefined, action: string): Record<string, unknown> {
const due = stringValue(dueAt);
return {
due_at: due || null,
sla_status: action === "mark_timeout" ? "timeout" : due ? "tracked" : "untracked",
};
}
function sanitizeApprovalRisk(riskLevel: string | null | undefined, action: string): Record<string, unknown> {
const explicit = stringValue(riskLevel);
const level = action === "mark_risk" ? explicit || "high" : action === "clear_risk" ? "normal" : explicit || null;
return { risk_level: level };
}
function approvalActionMessage(
kind: DigitalEmployeeApprovalActionRecord["kind"],
action: string,
approvalId: string,
): string {
if (kind === "approval_action_rejected") return `审批动作已拒绝:${approvalId}`;
const actionLabel = action === "comment"
? "评论"
: action === "delegate"
? "转交"
: action === "mark_timeout"
? "标记超时"
: action === "mark_risk"
? "标记风险"
: "清除风险";
return `审批动作已记录:${actionLabel} ${approvalId}`;
}
function isDigitalEmployeeArtifactRetentionAction(action: string): action is DigitalEmployeeArtifactRetentionAction {
return ["mark_keep", "mark_expire", "mark_review", "clear_retention"].includes(action);
}