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

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

@@ -4,6 +4,7 @@ import type { HandlerContext } from "@shared/types/ipc";
const mockStateService = vi.hoisted(() => ({
recordManagedServiceAction: vi.fn(),
recordArtifactLifecycle: vi.fn(),
recordApprovalAction: vi.fn(),
}));
const mockArtifactAccess = vi.hoisted(() => ({
@@ -68,6 +69,7 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
recordDigitalEmployeeGovernanceCommand: vi.fn(),
recordDigitalEmployeeDailyReport: vi.fn(),
recordDigitalEmployeeArtifactLifecycle: mockStateService.recordArtifactLifecycle,
recordDigitalEmployeeApprovalAction: mockStateService.recordApprovalAction,
recordDigitalEmployeeManagedServiceAction: mockStateService.recordManagedServiceAction,
readDigitalEmployeeRuntimeRecords: vi.fn(),
readDigitalEmployeeState: vi.fn(),
@@ -136,6 +138,7 @@ describe("digital employee managed service actions", () => {
vi.clearAllMocks();
mockStateService.recordManagedServiceAction.mockReturnValue({ eventId: "event-1" });
mockStateService.recordArtifactLifecycle.mockReturnValue({ eventId: "artifact-event-1", kind: "artifact_retention_marked", payload: {} });
mockStateService.recordApprovalAction.mockReturnValue({ eventId: "approval-event-1", kind: "approval_action_recorded", payload: {} });
mockArtifactAccess.accessDigitalEmployeeArtifact.mockResolvedValue({ ok: true, artifact_id: "artifact-1", action: "preview" });
mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" });
mockServiceManager.createServiceManager.mockReturnValue({
@@ -228,4 +231,24 @@ describe("digital employee managed service actions", () => {
expect(result).toMatchObject({ eventId: "artifact-event-1", kind: "artifact_retention_marked" });
expect(mockStateService.recordArtifactLifecycle).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "mark_keep" });
});
it("registers approval action history through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const approvalCall = calls.find(([channel]) => channel === "digitalEmployee:recordApprovalAction");
expect(approvalCall).toBeTruthy();
const handler = approvalCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>;
const result = await handler({}, { approvalId: "approval-1", action: "comment", comment: "需要复核" });
expect(result).toMatchObject({ eventId: "approval-event-1", kind: "approval_action_recorded" });
expect(mockStateService.recordApprovalAction).toHaveBeenCalledWith({
approvalId: "approval-1",
action: "comment",
comment: "需要复核",
});
});
});

View File

@@ -22,6 +22,7 @@ import {
recordDigitalEmployeeGovernanceCommand,
recordDigitalEmployeeDailyReport,
recordDigitalEmployeeArtifactLifecycle,
recordDigitalEmployeeApprovalAction,
recordDigitalEmployeeManagedServiceAction,
readDigitalEmployeeRuntimeRecords,
readDigitalEmployeeState,
@@ -41,6 +42,7 @@ import {
type DigitalEmployeeGovernanceCommandRecord,
type DigitalEmployeeDailyReportRecordInput,
type DigitalEmployeeArtifactLifecycleInput,
type DigitalEmployeeApprovalActionInput,
type DigitalEmployeeMemoryUpsertInput,
type DigitalEmployeeRouteDecisionInput,
type DigitalEmployeeServiceStatus,
@@ -241,6 +243,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
},
);
ipcMain.handle(
"digitalEmployee:recordApprovalAction",
async (_, input: DigitalEmployeeApprovalActionInput) => {
return recordDigitalEmployeeApprovalAction(input ?? { action: "unknown_action" });
},
);
ipcMain.handle(
"digitalEmployee:restartManagedService",
async (_, input: RestartManagedServiceRequest) => {

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);
}

View File

@@ -165,6 +165,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async recordArtifactLifecycle(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordArtifactLifecycle", input);
},
async recordApprovalAction(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordApprovalAction", input);
},
async restartManagedService(serviceId: string, options?: unknown) {
return ipcRenderer.invoke("digitalEmployee:restartManagedService", { serviceId, ...(typeof options === "object" && options ? options : {}) });
},