吸收数字员工审批冲突与产物业务闭环

This commit is contained in:
baiyanyun
2026-06-12 16:29:01 +08:00
parent fffa0ebe50
commit 81d2797035
26 changed files with 2963 additions and 241 deletions

View File

@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from "fs/promises";
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "fs/promises";
import os from "os";
import path from "path";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -6,6 +6,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockStateService = vi.hoisted(() => ({
records: { artifacts: [] as any[] },
recordDigitalEmployeeArtifactAccess: vi.fn((input: { status: string }) => ({ eventId: `event:${input.status}` })),
recordDigitalEmployeeArtifactCleanup: vi.fn((input: { status: string }) => ({
eventId: `cleanup:${input.status}`,
kind: `artifact_cleanup_${input.status === "deleted" ? "executed" : input.status}`,
payload: input,
})),
}));
const mockShell = vi.hoisted(() => ({
@@ -20,6 +25,7 @@ vi.mock("electron", () => ({
vi.mock("./stateService", () => ({
readDigitalEmployeeRuntimeRecords: () => mockStateService.records,
recordDigitalEmployeeArtifactAccess: mockStateService.recordDigitalEmployeeArtifactAccess,
recordDigitalEmployeeArtifactCleanup: mockStateService.recordDigitalEmployeeArtifactCleanup,
}));
describe("digital employee artifact access service", () => {
@@ -29,6 +35,168 @@ describe("digital employee artifact access service", () => {
mockStateService.records = { artifacts: [] };
});
it("deletes an expired local artifact file and audits cleanup execution", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qiming-artifact-cleanup-"));
const filePath = path.join(tempDir, "expired-report.txt");
await writeFile(filePath, "cleanup me", "utf8");
mockStateService.records = {
artifacts: [{
id: "artifact-expired",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
kind: "report",
name: "expired-report.txt",
uri: filePath,
payload: { retention_status: "expired", delivery: { status: "completed" } },
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
}],
};
const { cleanupDigitalEmployeeArtifact } = await import("./artifactAccessService");
const result = await cleanupDigitalEmployeeArtifact({ artifactId: "artifact-expired", actor: "operator", reason: "retention_expired" });
await expect(stat(filePath)).rejects.toThrow();
expect(result).toMatchObject({
ok: true,
artifact_id: "artifact-expired",
status: "deleted",
reason_codes: ["retention_expired"],
event_id: "cleanup:deleted",
});
expect(mockStateService.recordDigitalEmployeeArtifactCleanup).toHaveBeenCalledWith(expect.objectContaining({
artifactId: "artifact-expired",
status: "deleted",
reasonCodes: ["retention_expired"],
actor: "operator",
reason: "retention_expired",
uriSummary: expect.objectContaining({ basename: "expired-report.txt", extension: ".txt" }),
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
}));
await rm(tempDir, { recursive: true, force: true });
});
it("protects keep-retained artifacts from cleanup", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qiming-artifact-cleanup-"));
try {
const filePath = path.join(tempDir, "keep-report.txt");
await writeFile(filePath, "keep me", "utf8");
mockStateService.records = {
artifacts: [{
id: "artifact-keep",
kind: "report",
name: "keep-report.txt",
uri: filePath,
payload: { retention_status: "keep" },
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
}],
};
const { cleanupDigitalEmployeeArtifact } = await import("./artifactAccessService");
const result = await cleanupDigitalEmployeeArtifact({ artifactId: "artifact-keep" });
expect(await readFile(filePath, "utf8")).toBe("keep me");
expect(result).toMatchObject({ ok: false, status: "rejected", error: "retention_keep_protected", reason_codes: ["retention_keep_protected"] });
expect(mockStateService.recordDigitalEmployeeArtifactCleanup).toHaveBeenCalledWith(expect.objectContaining({
artifactId: "artifact-keep",
status: "rejected",
reasonCodes: ["retention_keep_protected"],
}));
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("rejects local cleanup before retention expires", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qiming-artifact-cleanup-"));
try {
const filePath = path.join(tempDir, "review-report.txt");
await writeFile(filePath, "review me", "utf8");
mockStateService.records = {
artifacts: [{
id: "artifact-review",
kind: "report",
name: "review-report.txt",
uri: filePath,
payload: { retention_status: "review_required" },
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
}],
};
const { cleanupDigitalEmployeeArtifact } = await import("./artifactAccessService");
const result = await cleanupDigitalEmployeeArtifact({ artifactId: "artifact-review" });
expect(await readFile(filePath, "utf8")).toBe("review me");
expect(result).toMatchObject({ ok: false, status: "rejected", error: "retention_not_expired", reason_codes: ["retention_not_expired"] });
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("rejects remote, embedded, directory and missing artifact cleanup targets", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qiming-artifact-cleanup-"));
try {
const missingPath = path.join(tempDir, "missing.txt");
mockStateService.records = {
artifacts: [
{ id: "remote", kind: "report", name: "remote", uri: "https://example.com/report.txt", payload: { retention_status: "expired" }, syncStatus: "pending", createdAt: "2026-06-07T08:00:00.000Z" },
{ id: "embedded", kind: "daily_report_pdf", name: "report.pdf", uri: null, payload: { format: "pdf", pdf_content_base64: "JVBERg==", retention_status: "expired" }, syncStatus: "pending", createdAt: "2026-06-07T08:00:00.000Z" },
{ id: "dir", kind: "report", name: "dir", uri: tempDir, payload: { retention_status: "expired" }, syncStatus: "pending", createdAt: "2026-06-07T08:00:00.000Z" },
{ id: "missing", kind: "report", name: "missing", uri: missingPath, payload: { retention_status: "expired" }, syncStatus: "pending", createdAt: "2026-06-07T08:00:00.000Z" },
],
};
const { cleanupDigitalEmployeeArtifact } = await import("./artifactAccessService");
await expect(cleanupDigitalEmployeeArtifact({ artifactId: "remote" })).resolves.toMatchObject({ ok: false, error: "local_uri_required" });
await expect(cleanupDigitalEmployeeArtifact({ artifactId: "embedded" })).resolves.toMatchObject({ ok: false, error: "local_uri_required" });
await expect(cleanupDigitalEmployeeArtifact({ artifactId: "dir" })).resolves.toMatchObject({ ok: false, error: "directory_not_allowed" });
await expect(cleanupDigitalEmployeeArtifact({ artifactId: "missing" })).resolves.toMatchObject({ ok: false, error: "path_not_found" });
expect(mockStateService.recordDigitalEmployeeArtifactCleanup).toHaveBeenCalledWith(expect.objectContaining({ artifactId: "remote", status: "rejected" }));
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("audits cleanup unlink failures without reporting deletion", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qiming-artifact-cleanup-"));
try {
const filePath = path.join(tempDir, "unlink-fails.txt");
await writeFile(filePath, "still here", "utf8");
mockStateService.records = {
artifacts: [{
id: "artifact-unlink-fails",
kind: "report",
name: "unlink-fails.txt",
uri: filePath,
payload: { retention_status: "expired" },
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
}],
};
const { cleanupDigitalEmployeeArtifact } = await import("./artifactAccessService");
await chmod(tempDir, 0o500);
const result = await cleanupDigitalEmployeeArtifact({ artifactId: "artifact-unlink-fails" });
await chmod(tempDir, 0o700);
expect(await readFile(filePath, "utf8")).toBe("still here");
expect(result).toMatchObject({ ok: false, status: "failed", error: "unlink_failed", reason_codes: ["unlink_failed"] });
expect(mockStateService.recordDigitalEmployeeArtifactCleanup).toHaveBeenCalledWith(expect.objectContaining({
artifactId: "artifact-unlink-fails",
status: "failed",
reasonCodes: ["unlink_failed"],
}));
} finally {
await chmod(tempDir, 0o700).catch(() => undefined);
await rm(tempDir, { recursive: true, force: true });
}
});
it("returns bounded text preview for a local artifact and audits allowed access", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qiming-artifact-access-"));
try {
@@ -91,6 +259,66 @@ describe("digital employee artifact access service", () => {
expect(mockShell.showItemInFolder).not.toHaveBeenCalled();
});
it("allows embedded daily report html and pdf artifacts without a local path", async () => {
mockStateService.records = {
artifacts: [
{
id: "report-1:artifact:html",
planId: "daily-report:2026-06-07",
taskId: "daily-report:2026-06-07:generate",
runId: "report-1",
kind: "daily_report_html",
name: "report.html",
uri: null,
payload: {
format: "html",
html_content: "<article><h1>日报</h1></article>",
delivery: { status: "generated", stage: "export", format: "html" },
},
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
},
{
id: "report-1:artifact:pdf",
planId: "daily-report:2026-06-07",
taskId: "daily-report:2026-06-07:generate",
runId: "report-1",
kind: "daily_report_pdf",
name: "report.pdf",
uri: null,
payload: {
format: "pdf",
pdf_content_base64: "JVBERi0xLjQKJcTl8uXrCg==",
delivery: { status: "generated", stage: "export", format: "pdf" },
},
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
},
],
};
const { accessDigitalEmployeeArtifact } = await import("./artifactAccessService");
const html = await accessDigitalEmployeeArtifact({ artifactId: "report-1:artifact:html", action: "preview" });
const pdf = await accessDigitalEmployeeArtifact({ artifactId: "report-1:artifact:pdf", action: "download" });
expect(html).toMatchObject({
ok: true,
action: "preview",
preview: { name: "report.html", mime: "text/html", text: "<article><h1>日报</h1></article>" },
});
expect(pdf).toMatchObject({
ok: true,
action: "download",
preview: { name: "report.pdf", mime: "application/pdf", base64: "JVBERi0xLjQKJcTl8uXrCg==" },
});
expect(mockShell.openPath).not.toHaveBeenCalled();
expect(mockStateService.recordDigitalEmployeeArtifactAccess).toHaveBeenCalledWith(expect.objectContaining({
artifactId: "report-1:artifact:pdf",
status: "allowed",
deliverySnapshot: expect.objectContaining({ delivery_status: "generated", delivery_stage: "export" }),
}));
});
it("rejects unknown access actions before opening anything", async () => {
mockStateService.records = {
artifacts: [{

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
import {
readDigitalEmployeeRuntimeRecords,
recordDigitalEmployeeArtifactAccess,
recordDigitalEmployeeArtifactCleanup,
type DigitalEmployeeArtifactAccessAction,
type DigitalEmployeeArtifactRecord,
} from "./stateService";
@@ -44,12 +45,35 @@ export interface DigitalEmployeeArtifactAccessResponse {
size: number | null;
truncated: boolean;
text?: string;
base64?: string;
delivery_status?: string | null;
delivery_stage?: string | null;
};
event_id?: string | null;
}
export interface DigitalEmployeeArtifactCleanupRequest {
artifactId?: string | null;
artifact_id?: string | null;
actor?: string | null;
reason?: string | null;
source?: string | null;
retentionStatus?: string | null;
retention_status?: string | null;
lastRetentionAction?: string | null;
last_retention_action?: string | null;
}
export interface DigitalEmployeeArtifactCleanupResponse {
ok: boolean;
artifact_id: string;
status: "deleted" | "rejected" | "failed";
error?: string;
reason_codes: string[];
event_id?: string | null;
deleted_at?: string | null;
}
interface ResolvedArtifactAccess {
artifact: DigitalEmployeeArtifactRecord;
path: string | null;
@@ -76,19 +100,11 @@ export async function accessDigitalEmployeeArtifact(
}
const resolved = await resolveArtifactAccess(artifact);
const embeddedDailyReport = embeddedDailyReportPreview(artifact, resolved.delivery);
if (embeddedDailyReport && (action === "preview" || action === "download")) {
return allowArtifactAccess(artifactId, action, resolved, input, embeddedDailyReport);
}
if (action === "preview") {
if (isDailyReportTextArtifact(artifact)) {
return allowArtifactAccess(artifactId, action, resolved, input, {
name: artifact.name || "daily-report.txt",
kind: artifact.kind,
mime: "text/plain",
size: textContentForArtifact(artifact).length,
truncated: false,
text: textContentForArtifact(artifact),
delivery_status: deliveryString(resolved.delivery, "delivery_status"),
delivery_stage: deliveryString(resolved.delivery, "delivery_stage"),
});
}
const pathValidation = await validateLocalArtifactPath(resolved.path);
if (!pathValidation.ok) {
return rejectArtifactAccess(artifactId, action, pathValidation.reasonCodes, input, resolved);
@@ -114,6 +130,55 @@ export async function accessDigitalEmployeeArtifact(
return allowArtifactAccess(artifactId, action, resolved, input);
}
export async function cleanupDigitalEmployeeArtifact(
input: DigitalEmployeeArtifactCleanupRequest,
): Promise<DigitalEmployeeArtifactCleanupResponse> {
const artifactId = String(input.artifactId || input.artifact_id || "").trim();
if (!artifactId) {
return rejectArtifactCleanup("unknown-artifact", ["artifact_id_required"], input);
}
const artifact = findArtifact(artifactId);
if (!artifact) {
return rejectArtifactCleanup(artifactId, ["artifact_not_found"], input);
}
const resolved = await resolveArtifactAccess(artifact);
const retention = artifactRetentionSnapshot(artifact, input);
if (retention.retentionStatus === "keep") {
return rejectArtifactCleanup(artifactId, ["retention_keep_protected"], input, resolved);
}
if (retention.retentionStatus !== "expired" && retention.lastRetentionAction !== "mark_expire") {
return rejectArtifactCleanup(artifactId, ["retention_not_expired"], input, resolved);
}
const pathValidation = await validateLocalArtifactPath(resolved.path);
if (!pathValidation.ok) {
return rejectArtifactCleanup(artifactId, pathValidation.reasonCodes, input, resolved);
}
try {
await fs.unlink(pathValidation.filePath);
} catch {
return failArtifactCleanup(artifactId, ["unlink_failed"], input, resolved);
}
const deletedAt = new Date().toISOString();
const event = recordDigitalEmployeeArtifactCleanup({
artifactId,
status: "deleted",
reasonCodes: ["retention_expired"],
reason: stringValue(input.reason) || "retention_expired",
uriSummary: { ...resolved.summary, size: Number(pathValidation.stats.size) },
actor: input.actor,
source: input.source,
planId: artifact.planId,
taskId: artifact.taskId,
runId: artifact.runId,
occurredAt: deletedAt,
});
return { ok: true, artifact_id: artifactId, status: "deleted", reason_codes: ["retention_expired"], event_id: event?.eventId ?? null, deleted_at: deletedAt };
}
function findArtifact(artifactId: string): DigitalEmployeeArtifactRecord | null {
const records = readDigitalEmployeeRuntimeRecords();
return records.artifacts.find((artifact) => artifact.id === artifactId || artifact.remoteId === artifactId) ?? null;
@@ -253,13 +318,110 @@ function rejectArtifactAccess(
return { ok: false, artifact_id: artifactId, action, status: "rejected", error: reasonCodes[0] || "artifact_access_rejected", reason_codes: reasonCodes, event_id: event?.eventId ?? null };
}
function isDailyReportTextArtifact(artifact: DigitalEmployeeArtifactRecord): boolean {
return artifact.kind === "daily_report" && Boolean(textContentForArtifact(artifact));
function rejectArtifactCleanup(
artifactId: string,
reasonCodes: string[],
input: DigitalEmployeeArtifactCleanupRequest,
resolved?: ResolvedArtifactAccess,
): DigitalEmployeeArtifactCleanupResponse {
const event = recordDigitalEmployeeArtifactCleanup({
artifactId,
status: "rejected",
reasonCodes,
reason: stringValue(input.reason) || reasonCodes[0] || null,
uriSummary: resolved?.summary ?? {},
actor: input.actor,
source: input.source,
planId: resolved?.artifact.planId,
taskId: resolved?.artifact.taskId,
runId: resolved?.artifact.runId,
});
return { ok: false, artifact_id: artifactId, status: "rejected", error: reasonCodes[0] || "artifact_cleanup_rejected", reason_codes: reasonCodes, event_id: event?.eventId ?? null };
}
function textContentForArtifact(artifact: DigitalEmployeeArtifactRecord): string {
function failArtifactCleanup(
artifactId: string,
reasonCodes: string[],
input: DigitalEmployeeArtifactCleanupRequest,
resolved: ResolvedArtifactAccess,
): DigitalEmployeeArtifactCleanupResponse {
const event = recordDigitalEmployeeArtifactCleanup({
artifactId,
status: "failed",
reasonCodes,
reason: stringValue(input.reason) || reasonCodes[0] || null,
uriSummary: resolved.summary,
actor: input.actor,
source: input.source,
planId: resolved.artifact.planId,
taskId: resolved.artifact.taskId,
runId: resolved.artifact.runId,
});
return { ok: false, artifact_id: artifactId, status: "failed", error: reasonCodes[0] || "artifact_cleanup_failed", reason_codes: reasonCodes, event_id: event?.eventId ?? null };
}
function artifactRetentionSnapshot(artifact: DigitalEmployeeArtifactRecord, input: DigitalEmployeeArtifactCleanupRequest): { retentionStatus: string; lastRetentionAction: string } {
const payload = objectRecord(artifact.payload) ?? {};
return stringValue(payload.text_content).slice(0, MAX_TEXT_PREVIEW_BYTES);
const retention = objectRecord(payload.retention) ?? objectRecord(payload.retention_summary) ?? {};
const cleanup = objectRecord(payload.cleanup) ?? {};
return {
retentionStatus: stringValue(input.retentionStatus ?? input.retention_status)
|| stringValue(payload.retention_status)
|| stringValue(retention.retention_status ?? retention.status)
|| "unmanaged",
lastRetentionAction: stringValue(input.lastRetentionAction ?? input.last_retention_action)
|| stringValue(payload.last_retention_action)
|| stringValue(retention.last_retention_action ?? retention.action)
|| stringValue(cleanup.last_retention_action),
};
}
function embeddedDailyReportPreview(
artifact: DigitalEmployeeArtifactRecord,
delivery: Record<string, unknown>,
): DigitalEmployeeArtifactAccessResponse["preview"] | null {
const payload = objectRecord(artifact.payload) ?? {};
const format = stringValue(payload.format) || artifact.kind.replace(/^daily_report_?/, "") || "text";
if (artifact.kind !== "daily_report" && !artifact.kind.startsWith("daily_report_")) return null;
if (format === "pdf") {
const base64 = stringValue(payload.pdf_content_base64);
if (!base64) return null;
return {
name: artifact.name || stringValue(payload.filename) || "daily-report.pdf",
kind: artifact.kind,
mime: "application/pdf",
size: Math.ceil((base64.length * 3) / 4),
truncated: false,
base64,
delivery_status: deliveryString(delivery, "delivery_status"),
delivery_stage: deliveryString(delivery, "delivery_stage"),
};
}
const htmlContent = stringValue(payload.html_content).slice(0, MAX_TEXT_PREVIEW_BYTES);
if (format === "html" && htmlContent) {
return {
name: artifact.name || stringValue(payload.filename) || "daily-report.html",
kind: artifact.kind,
mime: "text/html",
size: htmlContent.length,
truncated: stringValue(payload.html_content).length > MAX_TEXT_PREVIEW_BYTES,
text: htmlContent,
delivery_status: deliveryString(delivery, "delivery_status"),
delivery_stage: deliveryString(delivery, "delivery_stage"),
};
}
const textContent = stringValue(payload.text_content).slice(0, MAX_TEXT_PREVIEW_BYTES);
if (!textContent) return null;
return {
name: artifact.name || stringValue(payload.filename) || "daily-report.txt",
kind: artifact.kind,
mime: "text/plain",
size: textContent.length,
truncated: stringValue(payload.text_content).length > MAX_TEXT_PREVIEW_BYTES,
text: textContent,
delivery_status: deliveryString(delivery, "delivery_status"),
delivery_stage: deliveryString(delivery, "delivery_stage"),
};
}
function normalizeDelivery(payload: Record<string, unknown>): Record<string, unknown> {

View File

@@ -268,6 +268,87 @@ describe("digital employee state service", () => {
});
});
it("records daily report export formats and delivery audit events", async () => {
const {
recordDigitalEmployeeDailyReport,
recordDigitalEmployeeDailyReportDelivery,
} = await import("./stateService");
const report = recordDigitalEmployeeDailyReport({
date: "2026-06-07",
title: "2026-06-07 数字员工日报",
summary: "今日完成任务汇总。",
totals: { task_count: 2, execution_count: 3, success_count: 2, failure_count: 1 },
detailLines: ["09:00 任务 A完成"],
filename: "qimingclaw-digital-report-2026-06-07.txt",
textContent: "日报正文",
htmlFilename: "qimingclaw-digital-report-2026-06-07.html",
htmlContent: "<article><h1>日报</h1><p>日报正文</p></article>",
pdfFilename: "qimingclaw-digital-report-2026-06-07.pdf",
pdfContentBase64: "JVBERi0xLjQKJcTl8uXrCg==",
generatedAt: "2026-06-07T08:00:00.000Z",
});
expect(report).toMatchObject({
htmlArtifactId: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z:artifact:html",
pdfArtifactId: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z:artifact:pdf",
});
expect(Array.from(mockState.db?.artifacts.values() ?? []).filter((row) => row.run_id === report!.runId)).toHaveLength(3);
expect(JSON.parse(mockState.db?.artifacts.get(report!.htmlArtifactId!)?.payload ?? "{}")).toMatchObject({
report_id: report!.reportId,
format: "html",
filename: "qimingclaw-digital-report-2026-06-07.html",
html_content: "<article><h1>日报</h1><p>日报正文</p></article>",
delivery: { stage: "export", status: "generated", format: "html" },
});
expect(JSON.parse(mockState.db?.artifacts.get(report!.pdfArtifactId!)?.payload ?? "{}")).toMatchObject({
report_id: report!.reportId,
format: "pdf",
filename: "qimingclaw-digital-report-2026-06-07.pdf",
pdf_content_base64: "JVBERi0xLjQKJcTl8uXrCg==",
delivery: { stage: "export", status: "generated", format: "pdf" },
});
const sent = recordDigitalEmployeeDailyReportDelivery({
reportId: report!.reportId,
action: "send",
status: "sent",
actor: "operator",
endpoint: "management-console",
recordedAt: "2026-06-07T08:05:00.000Z",
});
const confirmed = recordDigitalEmployeeDailyReportDelivery({
reportId: report!.reportId,
action: "confirm",
status: "confirmed",
actor: "management",
remoteId: "remote-report-1",
recordedAt: "2026-06-07T08:06:00.000Z",
});
const approval = recordDigitalEmployeeDailyReportDelivery({
reportId: report!.reportId,
action: "request_approval",
status: "pending",
actor: "operator",
recordedAt: "2026-06-07T08:07:00.000Z",
});
expect(mockState.db?.events.get(sent!.eventId)).toMatchObject({ kind: "daily_report_send_recorded" });
expect(JSON.parse(mockState.db?.events.get(confirmed!.eventId)?.payload ?? "{}")).toMatchObject({
report_id: report!.reportId,
action: "confirm",
status: "confirmed",
remote_id: "remote-report-1",
});
expect(approval?.approvalId).toBe(`daily-report:2026-06-07:approval:2026-06-07T08-07-00-000Z`);
expect(mockState.db?.approvals.get(approval!.approvalId!)).toMatchObject({
title: "确认发送日报给管理端",
status: "pending",
run_id: report!.runId,
});
expect(mockState.db?.events.get(approval!.eventId)).toMatchObject({ kind: "daily_report_approval_requested" });
});
it("records managed service restart actions as syncable events", async () => {
const { recordDigitalEmployeeManagedServiceAction } = await import("./stateService");
@@ -501,6 +582,79 @@ describe("digital employee state service", () => {
});
});
it("records artifact cleanup events and marks deleted payload after execution", async () => {
mockState.db?.artifacts.set("artifact-1", {
id: "artifact-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
kind: "report",
name: "report.txt",
uri: "/tmp/report.txt",
payload: JSON.stringify({ retention_status: "expired", delivery: { status: "completed" } }),
sync_status: "pending",
created_at: "2026-06-07T08:00:00.000Z",
});
const { recordDigitalEmployeeArtifactCleanup } = await import("./stateService");
const executed = recordDigitalEmployeeArtifactCleanup({
artifactId: "artifact-1",
status: "deleted",
reasonCodes: ["retention_expired"],
reason: "retention_expired",
uriSummary: { scheme: "path", basename: "report.txt", extension: ".txt", raw_path: "/tmp/report.txt" },
actor: "operator",
source: "test",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
occurredAt: "2026-06-07T08:39:00.000Z",
});
const rejected = recordDigitalEmployeeArtifactCleanup({
artifactId: "artifact-2",
status: "rejected",
reasonCodes: ["retention_keep_protected"],
occurredAt: "2026-06-07T08:40:00.000Z",
});
expect(executed?.eventId).toBe("artifact-cleanup:deleted:artifact-1:2026-06-07T08-39-00-000Z");
expect(executed?.kind).toBe("artifact_cleanup_executed");
expect(rejected?.kind).toBe("artifact_cleanup_rejected");
expect(mockState.db?.events.get(executed!.eventId)).toMatchObject({
kind: "artifact_cleanup_executed",
message: "产物文件已清理artifact-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
const payload = JSON.parse(mockState.db?.events.get(executed!.eventId)?.payload ?? "{}");
expect(payload).toMatchObject({
artifact_id: "artifact-1",
status: "deleted",
reason_codes: ["retention_expired"],
uri_summary: { scheme: "path", basename: "report.txt", extension: ".txt" },
actor: "operator",
source: "test",
});
expect(JSON.stringify(payload)).not.toContain("/tmp/report.txt");
expect(JSON.parse(String(mockState.db?.artifacts.get("artifact-1")?.payload ?? "{}"))).toMatchObject({
retention_status: "expired",
cleanup: {
status: "deleted",
deleted_at: "2026-06-07T08:39:00.000Z",
cleanup_actor: "operator",
cleanup_reason: "retention_expired",
event_id: executed?.eventId,
},
});
expect(mockState.db?.outbox.get(`event:insert:${executed!.eventId}`)).toMatchObject({ entity_type: "event", status: "pending" });
expect(mockState.db?.outbox.get("artifact:upsert:artifact-1")).toMatchObject({ entity_type: "artifact", status: "pending" });
expect(JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}")).toMatchObject({
status: "rejected",
reason_codes: ["retention_keep_protected"],
});
});
it("records approval action history as syncable sanitized events", async () => {
const { recordDigitalEmployeeApprovalAction } = await import("./stateService");
const longComment = `${"需要业务复核。".repeat(20)} sensitive-tail-token`;
@@ -713,6 +867,160 @@ describe("digital employee state service", () => {
});
});
it("resolves active approval conflicts by keeping local approval facts", async () => {
const { resolveDigitalEmployeeApprovalConflict } = await import("./stateService");
mockState.db?.approvals.set("approval-conflict-keep-local", {
id: "approval-conflict-keep-local",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "保留本地审批",
status: "approved",
payload: JSON.stringify({
workflow: { current_step_id: "step-local", decision_history: [{ step_id: "step-local", decision: "approved" }] },
conflict: {
active: true,
reason: "terminal_status_mismatch",
local_status: "approved",
remote_status: "rejected",
remote_snapshot: {
approval_id: "approval-conflict-keep-local",
status: "rejected",
updated_at: "2026-06-07T08:06:00.000Z",
payload: { workflow: { current_step_id: "step-remote" }, remote_actor: "management" },
},
detected_at: "2026-06-07T08:00:00.000Z",
},
}),
sync_status: "pending",
created_at: "2026-06-07T07:50:00.000Z",
updated_at: "2026-06-07T08:05:00.000Z",
});
const result = resolveDigitalEmployeeApprovalConflict({
approvalId: "approval-conflict-keep-local",
resolution: "keep_local",
actor: "lead",
comment: "本地审批已经人工确认",
});
expect(result).toMatchObject({
ok: true,
approval_id: "approval-conflict-keep-local",
resolution: "keep_local",
status: "approved",
conflict_active: false,
reason_codes: ["conflict_resolved"],
});
const approval = mockState.db?.approvals.get("approval-conflict-keep-local");
expect(approval).toMatchObject({ status: "approved", updated_at: "2026-06-07T08:00:00.000Z" });
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({
workflow: { current_step_id: "step-local" },
conflict: {
active: false,
reason: "terminal_status_mismatch",
resolution: {
resolution: "keep_local",
actor: "lead",
comment_summary: { preview: "本地审批已经人工确认", length: 10 },
previous_local_status: "approved",
previous_remote_status: "rejected",
event_id: "approval-conflict-resolution:approval-conflict-keep-local:keep_local:2026-06-07T08-00-00-000Z",
},
},
});
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({
id: "approval-conflict-resolution:approval-conflict-keep-local:keep_local:2026-06-07T08-00-00-000Z",
kind: "approval_conflict_resolved",
}),
]));
});
it("resolves active approval conflicts by accepting the remote snapshot", async () => {
const { resolveDigitalEmployeeApprovalConflict } = await import("./stateService");
mockState.db?.approvals.set("approval-conflict-accept-remote", {
id: "approval-conflict-accept-remote",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "采纳管理端审批",
status: "approved",
payload: JSON.stringify({
workflow: { current_step_id: "step-local" },
local_note: "keep for history",
conflict: {
active: true,
reason: "workflow_step_mismatch",
local_status: "approved",
remote_status: "pending",
remote_snapshot: {
approval_id: "approval-conflict-accept-remote",
status: "pending",
updated_at: "2026-06-07T08:06:00.000Z",
payload: {
workflow: { current_step_id: "step-remote", decision_history: [{ step_id: "step-remote", decision: "delegated" }] },
remote_actor: "management",
},
},
},
}),
sync_status: "pending",
created_at: "2026-06-07T07:50:00.000Z",
updated_at: "2026-06-07T08:05:00.000Z",
});
const result = resolveDigitalEmployeeApprovalConflict({
approvalId: "approval-conflict-accept-remote",
resolution: "accept_remote",
actor: "lead",
});
expect(result).toMatchObject({ ok: true, resolution: "accept_remote", status: "pending", conflict_active: false });
const approval = mockState.db?.approvals.get("approval-conflict-accept-remote");
expect(approval).toMatchObject({ status: "pending", updated_at: "2026-06-07T08:00:00.000Z" });
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({
workflow: { current_step_id: "step-remote" },
remote_actor: "management",
conflict: {
active: false,
resolution: { resolution: "accept_remote", previous_local_status: "approved", previous_remote_status: "pending" },
},
});
});
it("rejects approval conflict resolution when no active conflict exists", async () => {
const { resolveDigitalEmployeeApprovalConflict } = await import("./stateService");
mockState.db?.approvals.set("approval-no-active-conflict", {
id: "approval-no-active-conflict",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "无冲突审批",
status: "pending",
payload: JSON.stringify({ conflict: { active: false } }),
sync_status: "pending",
created_at: "2026-06-07T07:50:00.000Z",
updated_at: "2026-06-07T08:05:00.000Z",
});
const result = resolveDigitalEmployeeApprovalConflict({
approvalId: "approval-no-active-conflict",
resolution: "keep_local",
actor: "lead",
});
expect(result).toMatchObject({
ok: false,
approval_id: "approval-no-active-conflict",
status: "rejected",
reason_codes: ["conflict_not_active"],
});
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "approval_conflict_resolution_rejected" }),
]));
});
it("records and lists route decisions as syncable events", async () => {
const {
listDigitalEmployeeRouteDecisions,
@@ -2861,6 +3169,9 @@ class TestDb {
if (sql.includes("FROM digital_runs") && sql.includes("WHERE id = ?")) {
return this.runs.get(args[0] as string);
}
if (sql.includes("FROM digital_artifacts") && sql.includes("WHERE id = ?")) {
return this.artifacts.get(args[0] as string);
}
if (sql.includes("FROM digital_runs") && sql.includes("WHERE task_id = ?")) {
return Array.from(this.runs.values())
.filter((run) => run.task_id === args[0])
@@ -3201,6 +3512,19 @@ class TestDb {
return { changes: previous ? 1 : 0 };
}
if (sql.startsWith("UPDATE digital_artifacts")) {
const [payload, id] = args as [string, string];
const previous = this.artifacts.get(id);
if (previous) {
this.artifacts.set(id, {
...previous,
payload,
sync_status: previous.sync_status === "synced" ? "pending" : previous.sync_status,
});
}
return { changes: previous ? 1 : 0 };
}
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) {
const [id, planId, taskId, runId, kind, message, payload, occurredAt, createdAt] = args as [
string,

View File

@@ -265,6 +265,30 @@ export interface DigitalEmployeeArtifactLifecycleRecord {
payload: Record<string, unknown>;
}
export type DigitalEmployeeArtifactCleanupStatus = "deleted" | "rejected" | "failed";
export interface DigitalEmployeeArtifactCleanupInput {
artifactId?: string | null;
status: DigitalEmployeeArtifactCleanupStatus | string;
reasonCodes?: string[] | null;
reason?: string | null;
uriSummary?: Record<string, unknown> | null;
actor?: string | null;
source?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
occurredAt?: string | null;
message?: string | null;
}
export interface DigitalEmployeeArtifactCleanupRecord {
eventId: string;
kind: "artifact_cleanup_executed" | "artifact_cleanup_rejected" | "artifact_cleanup_failed";
status: DigitalEmployeeArtifactCleanupStatus;
payload: Record<string, unknown>;
}
export type DigitalEmployeeApprovalActionKind = "comment" | "delegate" | "mark_timeout" | "mark_risk" | "clear_risk" | "approve" | "reject";
export interface DigitalEmployeeApprovalWorkflowStep {
@@ -332,6 +356,26 @@ export interface DigitalEmployeeApprovalUpdateInput {
source?: string | null;
}
export type DigitalEmployeeApprovalConflictResolution = "keep_local" | "accept_remote" | "mark_reviewed";
export interface DigitalEmployeeApprovalConflictResolutionInput {
approvalId?: string | null;
resolution?: DigitalEmployeeApprovalConflictResolution | string | null;
actor?: string | null;
comment?: string | null;
occurredAt?: string | null;
}
export interface DigitalEmployeeApprovalConflictResolutionResult {
ok: boolean;
approval_id: string;
resolution: string | null;
status: string;
conflict_active: boolean;
reason_codes: string[];
event_id?: string | null;
}
export interface DigitalEmployeeReadyPlanStepDispatchCandidate {
stepId: string;
planId: string;
@@ -808,6 +852,10 @@ export interface DigitalEmployeeDailyReportRecordInput {
artifactSources?: unknown[];
filename: string;
textContent: string;
htmlFilename?: string | null;
htmlContent?: string | null;
pdfFilename?: string | null;
pdfContentBase64?: string | null;
generatedAt?: string | null;
reportScheduleTime?: string | null;
source?: string | null;
@@ -820,9 +868,27 @@ export interface DigitalEmployeeDailyReportRecordResult {
taskId: string;
runId: string;
artifactId: string;
htmlArtifactId: string;
pdfArtifactId: string;
eventId: string;
}
export interface DigitalEmployeeDailyReportDeliveryInput {
reportId: string;
action: "send" | "confirm" | "request_approval";
status: string;
actor?: string | null;
endpoint?: string | null;
remoteId?: string | null;
message?: string | null;
recordedAt?: string | null;
}
export interface DigitalEmployeeDailyReportDeliveryResult {
eventId: string;
approvalId?: string | null;
}
export interface DigitalEmployeeManagedServiceActionInput {
serviceId: string;
action: "restart";
@@ -2598,6 +2664,54 @@ export function recordDigitalEmployeeArtifactLifecycle(
return { eventId, kind, payload };
}
export function recordDigitalEmployeeArtifactCleanup(
input: DigitalEmployeeArtifactCleanupInput,
): DigitalEmployeeArtifactCleanupRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = input.occurredAt || new Date().toISOString();
const artifactId = stringValue(input.artifactId) || "unknown-artifact";
const status = normalizeArtifactCleanupStatus(input.status);
const kind = artifactCleanupKind(status);
const reasonCodes = normalizeSkillIds(input.reasonCodes ?? []);
const eventId = `artifact-cleanup:${safeId(status)}:${safeId(artifactId)}:${safeId(now)}`;
const payload: Record<string, unknown> = {
artifact_id: artifactId,
status,
reason_codes: reasonCodes,
reason: stringValue(input.reason) || reasonCodes[0] || null,
uri_summary: sanitizeArtifactUriSummary(objectRecord(input.uriSummary) ?? {}),
actor: stringValue(input.actor) || null,
source: stringValue(input.source) || "qimingclaw-artifact-cleanup",
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
run_id: stringValue(input.runId) || null,
deleted_at: status === "deleted" ? now : null,
};
insertEvent(
{
event_id: eventId,
kind,
occurred_at: now,
message: input.message || artifactCleanupMessage(status, artifactId),
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
},
stringValue(input.runId) || null,
payload,
);
if (status === "deleted") {
markArtifactCleanupDeleted(db, artifactId, now, {
status: "deleted",
deleted_at: now,
cleanup_actor: stringValue(input.actor) || null,
cleanup_reason: stringValue(input.reason) || reasonCodes[0] || null,
event_id: eventId,
});
}
return { eventId, kind, status, payload };
}
export function recordDigitalEmployeeApprovalAction(
input: DigitalEmployeeApprovalActionInput,
): DigitalEmployeeApprovalActionRecord | null {
@@ -2688,6 +2802,129 @@ export function recordDigitalEmployeeApprovalAction(
return { eventId, kind, payload };
}
export function resolveDigitalEmployeeApprovalConflict(
input: DigitalEmployeeApprovalConflictResolutionInput,
): DigitalEmployeeApprovalConflictResolutionResult {
const db = getDigitalEmployeeDb();
const approvalId = stringValue(input.approvalId) || "";
const resolution = normalizeApprovalConflictResolution(input.resolution);
const now = input.occurredAt || new Date().toISOString();
if (!db || !approvalId) {
return approvalConflictResolutionRejected({
approvalId: approvalId || "unknown-approval",
resolution,
reason: approvalId ? "database_unavailable" : "approval_id_required",
now,
actor: input.actor,
comment: input.comment,
});
}
if (!resolution) {
return approvalConflictResolutionRejected({
approvalId,
resolution: null,
reason: "invalid_resolution",
now,
actor: input.actor,
comment: input.comment,
});
}
const existing = db.prepare(`
SELECT id, plan_id, task_id, run_id, title, status, payload, created_at, updated_at
FROM digital_approvals
WHERE id = ?
`).get(approvalId) as Record<string, unknown> | undefined;
if (!existing) {
return approvalConflictResolutionRejected({ approvalId, resolution, reason: "approval_not_found", now, actor: input.actor, comment: input.comment });
}
const existingPayload = objectRecord(parseStoredPayload(existing.payload)) ?? {};
const conflict = objectRecord(existingPayload.conflict);
if (conflict?.active !== true) {
return approvalConflictResolutionRejected({ approvalId, resolution, reason: "conflict_not_active", now, actor: input.actor, comment: input.comment, existing });
}
const remoteSnapshot = objectRecord(conflict.remote_snapshot ?? conflict.remoteSnapshot);
if (resolution === "accept_remote" && !remoteSnapshot) {
return approvalConflictResolutionRejected({ approvalId, resolution, reason: "remote_snapshot_missing", now, actor: input.actor, comment: input.comment, existing, conflict });
}
const eventId = `approval-conflict-resolution:${safeId(approvalId)}:${safeId(resolution)}:${safeId(now)}`;
const remotePayload = objectRecord(remoteSnapshot?.payload) ?? {};
const resolutionRecord = approvalConflictResolutionRecord({
eventId,
resolution,
actor: input.actor,
comment: input.comment,
now,
conflict,
});
const nextPayload = resolution === "accept_remote"
? {
...existingPayload,
...remotePayload,
source: stringValue(remoteSnapshot?.source) || stringValue(remotePayload.source) || stringValue(existingPayload.source) || null,
remote_updated_at: stringValue(remoteSnapshot?.updated_at ?? remoteSnapshot?.updatedAt) || stringValue(existingPayload.remote_updated_at) || null,
conflict: {
...conflict,
active: false,
resolution: resolutionRecord,
},
}
: {
...existingPayload,
conflict: {
...conflict,
active: false,
resolution: resolutionRecord,
},
};
const nextStatus = resolution === "accept_remote"
? normalizeApprovalWorkflowStatus(stringValue(remoteSnapshot?.status) || stringField(existing, "status") || "pending")
: stringField(existing, "status") || "pending";
const title = stringField(existing, "title") || "待确认事项";
upsertApproval({
id: approvalId,
planId: stringField(existing, "plan_id"),
taskId: stringField(existing, "task_id"),
runId: stringField(existing, "run_id"),
title,
status: nextStatus,
payload: materializeApprovalWorkflowCurrentStep(nextPayload, approvalId, title, nextStatus),
now,
});
insertEvent(
{
event_id: eventId,
kind: "approval_conflict_resolved",
occurred_at: now,
message: `审批冲突裁决已记录:${title}`,
plan_id: stringField(existing, "plan_id") || null,
task_id: stringField(existing, "task_id") || null,
},
stringField(existing, "run_id") || null,
{
source: "qimingclaw-approval-conflict-resolution",
approval_id: approvalId,
resolution,
status: nextStatus,
actor: stringValue(input.actor) || null,
comment_summary: sanitizeApprovalComment(input.comment),
reason_codes: ["conflict_resolved"],
resolved_at: now,
previous_reason: stringValue(conflict.reason) || null,
previous_local_status: stringValue(conflict.local_status ?? conflict.localStatus) || null,
previous_remote_status: stringValue(conflict.remote_status ?? conflict.remoteStatus) || null,
},
);
return {
ok: true,
approval_id: approvalId,
resolution,
status: nextStatus,
conflict_active: false,
reason_codes: ["conflict_resolved"],
event_id: eventId,
};
}
export function upsertDigitalEmployeeApprovalFromRemote(
input: DigitalEmployeeApprovalUpdateInput,
): { ok: boolean; skipped?: boolean; conflict?: boolean; approvalId?: string; reason?: string } {
@@ -2833,6 +3070,97 @@ function insertApprovalConflictEvent(
);
}
function normalizeApprovalConflictResolution(value: unknown): DigitalEmployeeApprovalConflictResolution | null {
const normalized = stringValue(value);
if (normalized === "keep_local" || normalized === "accept_remote" || normalized === "mark_reviewed") return normalized;
return null;
}
function approvalConflictResolutionRecord(input: {
eventId: string;
resolution: DigitalEmployeeApprovalConflictResolution;
actor?: string | null;
comment?: string | null;
now: string;
conflict: Record<string, unknown>;
}): Record<string, unknown> {
return {
resolution: input.resolution,
actor: stringValue(input.actor) || null,
comment_summary: sanitizeApprovalComment(input.comment),
resolved_at: input.now,
previous_reason: stringValue(input.conflict.reason) || null,
previous_local_status: stringValue(input.conflict.local_status ?? input.conflict.localStatus) || null,
previous_remote_status: stringValue(input.conflict.remote_status ?? input.conflict.remoteStatus) || null,
event_id: input.eventId,
};
}
function materializeApprovalWorkflowCurrentStep(
payload: Record<string, unknown>,
approvalId: string,
title: string,
status: string,
): Record<string, unknown> {
const workflow = objectRecord(payload.workflow ?? payload.approval_workflow);
const currentStepId = stringValue(workflow?.current_step_id ?? workflow?.currentStepId ?? payload.current_step_id ?? payload.currentStepId);
if (!workflow || !currentStepId || Array.isArray(workflow.steps) && workflow.steps.length > 0) return payload;
return {
...payload,
workflow: {
...workflow,
current_step_id: currentStepId,
steps: [{ step_id: currentStepId, label: title, status }],
},
};
}
function approvalConflictResolutionRejected(input: {
approvalId: string;
resolution: DigitalEmployeeApprovalConflictResolution | null;
reason: string;
now: string;
actor?: string | null;
comment?: string | null;
existing?: Record<string, unknown> | null;
conflict?: Record<string, unknown> | null;
}): DigitalEmployeeApprovalConflictResolutionResult {
const eventId = `approval-conflict-resolution-rejected:${safeId(input.approvalId)}:${safeId(input.reason)}:${safeId(input.now)}`;
const existing = input.existing ?? {};
insertEvent(
{
event_id: eventId,
kind: "approval_conflict_resolution_rejected",
occurred_at: input.now,
message: `审批冲突裁决被拒绝:${input.approvalId}`,
plan_id: stringField(existing, "plan_id") || null,
task_id: stringField(existing, "task_id") || null,
},
stringField(existing, "run_id") || null,
{
source: "qimingclaw-approval-conflict-resolution",
approval_id: input.approvalId,
resolution: input.resolution,
status: "rejected",
actor: stringValue(input.actor) || null,
comment_summary: sanitizeApprovalComment(input.comment),
reason_codes: [input.reason],
previous_reason: stringValue(input.conflict?.reason) || null,
previous_local_status: stringValue(input.conflict?.local_status ?? input.conflict?.localStatus) || null,
previous_remote_status: stringValue(input.conflict?.remote_status ?? input.conflict?.remoteStatus) || null,
},
);
return {
ok: false,
approval_id: input.approvalId,
resolution: input.resolution,
status: "rejected",
conflict_active: input.conflict?.active === true,
reason_codes: [input.reason],
event_id: eventId,
};
}
function approvalCurrentStepId(payload: Record<string, unknown>): string {
const workflow = objectRecord(payload.workflow ?? payload.approval_workflow) ?? {};
return stringValue(workflow.current_step_id ?? workflow.currentStepId ?? payload.current_step_id ?? payload.currentStepId);
@@ -3404,6 +3732,23 @@ function artifactLifecycleMessage(
return `产物保留策略已标记:${actionLabel} ${artifactId}`;
}
function normalizeArtifactCleanupStatus(status: string): DigitalEmployeeArtifactCleanupStatus {
if (status === "deleted" || status === "failed") return status;
return "rejected";
}
function artifactCleanupKind(status: DigitalEmployeeArtifactCleanupStatus): DigitalEmployeeArtifactCleanupRecord["kind"] {
if (status === "deleted") return "artifact_cleanup_executed";
if (status === "failed") return "artifact_cleanup_failed";
return "artifact_cleanup_rejected";
}
function artifactCleanupMessage(status: DigitalEmployeeArtifactCleanupStatus, artifactId: string): string {
if (status === "deleted") return `产物文件已清理:${artifactId}`;
if (status === "failed") return `产物文件清理失败:${artifactId}`;
return `产物文件清理已拒绝:${artifactId}`;
}
function normalizeArtifactAccessAction(action: string): DigitalEmployeeArtifactAccessAction {
return ["preview", "download", "open_location"].includes(action)
? action as DigitalEmployeeArtifactAccessAction
@@ -4040,7 +4385,13 @@ export function recordDigitalEmployeeDailyReport(
const reportId = input.reportId || `${planId}:${safeId(now)}`;
const runId = reportId;
const artifactId = `${reportId}:artifact:text`;
const htmlArtifactId = `${reportId}:artifact:html`;
const pdfArtifactId = `${reportId}:artifact:pdf`;
const eventId = `${reportId}:event:generated`;
const htmlFilename = input.htmlFilename || input.filename.replace(/\.txt$/i, ".html");
const pdfFilename = input.pdfFilename || input.filename.replace(/\.txt$/i, ".pdf");
const htmlContent = input.htmlContent || dailyReportHtmlContent(input.title, input.textContent);
const pdfContentBase64 = input.pdfContentBase64 || dailyReportPdfContentBase64(input.title, input.textContent);
const payload = {
report_id: reportId,
date,
@@ -4057,6 +4408,22 @@ export function recordDigitalEmployeeDailyReport(
source: input.source ?? "qimingclaw-adapter",
model: input.model ?? "qimingclaw-adapter",
};
const htmlPayload = {
...payload,
filename: htmlFilename,
format: "html",
text_content: undefined,
html_content: htmlContent,
delivery: { stage: "export", status: "generated", format: "html" },
};
const pdfPayload = {
...payload,
filename: pdfFilename,
format: "pdf",
text_content: undefined,
pdf_content_base64: pdfContentBase64,
delivery: { stage: "export", status: "generated", format: "pdf" },
};
const tx = db.transaction(() => {
upsertPlan({
@@ -4096,6 +4463,28 @@ export function recordDigitalEmployeeDailyReport(
payload,
now,
});
upsertArtifact({
id: htmlArtifactId,
planId,
taskId,
runId,
kind: "daily_report_html",
name: htmlFilename,
uri: null,
payload: htmlPayload,
now,
});
upsertArtifact({
id: pdfArtifactId,
planId,
taskId,
runId,
kind: "daily_report_pdf",
name: pdfFilename,
uri: null,
payload: pdfPayload,
now,
});
insertEvent({
event_id: eventId,
kind: "daily_report_generated",
@@ -4107,7 +4496,115 @@ export function recordDigitalEmployeeDailyReport(
});
tx();
return { reportId, planId, taskId, runId, artifactId, eventId };
return { reportId, planId, taskId, runId, artifactId, htmlArtifactId, pdfArtifactId, eventId };
}
export function recordDigitalEmployeeDailyReportDelivery(
input: DigitalEmployeeDailyReportDeliveryInput,
): DigitalEmployeeDailyReportDeliveryResult | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const reportId = stringValue(input.reportId);
if (!reportId) return null;
const now = input.recordedAt || new Date().toISOString();
const planId = reportId.split(":").slice(0, 2).join(":") || reportId;
const taskId = `${planId}:generate`;
const runId = reportId;
const eventKind = input.action === "request_approval"
? "daily_report_approval_requested"
: input.action === "confirm"
? "daily_report_confirmed"
: "daily_report_send_recorded";
const eventId = `${reportId}:delivery:${input.action}:${safeId(now)}`;
const approvalId = input.action === "request_approval"
? `${planId}:approval:${safeId(now)}`
: null;
const payload = {
report_id: reportId,
action: input.action,
status: input.status,
actor: input.actor ?? null,
endpoint: input.endpoint ?? null,
remote_id: input.remoteId ?? null,
message: input.message ?? null,
approval_id: approvalId,
recorded_at: now,
};
const tx = db.transaction(() => {
if (approvalId) {
upsertApproval({
id: approvalId,
planId,
taskId,
runId,
title: "确认发送日报给管理端",
status: input.status || "pending",
payload: {
...payload,
title: "确认发送日报给管理端",
reason: "daily_report_delivery_approval",
},
now,
});
}
insertEvent({
event_id: eventId,
kind: eventKind,
occurred_at: now,
message: dailyReportDeliveryMessage(input.action, input.status),
plan_id: planId,
task_id: taskId,
}, runId, payload);
});
tx();
return { eventId, approvalId };
}
function dailyReportDeliveryMessage(action: DigitalEmployeeDailyReportDeliveryInput["action"], status: string): string {
if (action === "request_approval") return "数字员工日报发送审批已创建";
if (action === "confirm") return `数字员工日报管理端确认:${status}`;
return `数字员工日报发送记录:${status}`;
}
function dailyReportHtmlContent(title: string, textContent: string): string {
const paragraphs = textContent.split(/\n{2,}/).map((block) => `<p>${escapeHtml(block).replace(/\n/g, "<br>")}</p>`).join("\n");
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body><article><h1>${escapeHtml(title)}</h1>${paragraphs}</article></body></html>`;
}
function dailyReportPdfContentBase64(title: string, textContent: string): string {
const safeText = `${title}\n\n${textContent}`.replace(/[^\x20-\x7E\n]/g, "?").slice(0, 3600);
const lines = safeText.split("\n").slice(0, 48);
const stream = (lines.length > 0 ? lines : ["Daily Report"])
.map((line, index) => `BT /F1 11 Tf 48 ${760 - index * 14} Td (${pdfEscape(line)}) Tj ET`)
.join("\n");
const objects = [
"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj",
"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj",
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj",
"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj",
`5 0 obj << /Length ${stream.length} >> stream\n${stream}\nendstream endobj`,
];
let offset = "%PDF-1.4\n".length;
const xref = ["0000000000 65535 f "];
for (const object of objects) {
xref.push(`${String(offset).padStart(10, "0")} 00000 n `);
offset += object.length + 1;
}
const pdf = `%PDF-1.4\n${objects.join("\n")}\nxref\n0 ${objects.length + 1}\n${xref.join("\n")}\ntrailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${offset}\n%%EOF`;
return Buffer.from(pdf, "binary").toString("base64");
}
function pdfEscape(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function recordDigitalEmployeeManagedServiceAction(
@@ -6253,6 +6750,43 @@ function upsertArtifact(input: {
markOutbox("artifact", input.id, "upsert", input, input.now);
}
function markArtifactCleanupDeleted(
db: NonNullable<ReturnType<typeof getDigitalEmployeeDb>>,
artifactId: string,
now: string,
cleanup: Record<string, unknown>,
): void {
const row = db.prepare(`
SELECT id, plan_id, task_id, run_id, kind, name, uri, payload, sync_status, created_at
FROM digital_artifacts
WHERE id = ?
LIMIT 1
`).get(artifactId) as Record<string, unknown> | undefined;
if (!row) return;
const payload = {
...(objectRecord(parseStoredPayload(row.payload)) ?? {}),
cleanup,
};
db.prepare(`
UPDATE digital_artifacts
SET payload = ?, sync_status = CASE
WHEN sync_status = 'synced' THEN 'pending'
ELSE sync_status
END
WHERE id = ?
`).run(stringify(payload), artifactId);
markOutbox("artifact", artifactId, "upsert", {
id: artifactId,
planId: stringField(row, "plan_id"),
taskId: stringField(row, "task_id"),
runId: stringField(row, "run_id"),
kind: stringField(row, "kind"),
name: optionalStringField(row, "name"),
uri: optionalStringField(row, "uri"),
payload,
}, now);
}
function upsertApproval(input: {
id: string;
planId: string;

View File

@@ -1262,6 +1262,24 @@ describe("digital employee sync service", () => {
taskId: "task-1",
runId: "run-1",
});
insertEventEntity("event-approval-conflict-resolved", "approval_conflict_resolved", {
approval_id: "approval-conflict-1",
resolution: "keep_local",
status: "approved",
actor: "lead",
reason_codes: ["conflict_resolved"],
previous_reason: "terminal_status_mismatch",
previous_local_status: "approved",
previous_remote_status: "rejected",
resolved_at: "2026-06-07T08:00:00.000Z",
});
insertEventEntity("event-approval-conflict-rejected", "approval_conflict_resolution_rejected", {
approval_id: "approval-conflict-2",
resolution: "accept_remote",
status: "rejected",
actor: "lead",
reason_codes: ["remote_snapshot_missing"],
});
insertEventEntity("event-artifact", "artifact_retention_marked", {
artifact_id: "artifact-1",
action: "mark_keep",
@@ -1270,6 +1288,30 @@ describe("digital employee sync service", () => {
retention_status: "kept",
retention_until: "2026-07-01T00:00:00.000Z",
});
insertEventEntity("event-artifact-cleanup", "artifact_cleanup_executed", {
artifact_id: "artifact-1",
status: "deleted",
actor: "operator",
reason: "retention_expired",
reason_codes: ["retention_expired"],
deleted_at: "2026-06-07T08:06:00.000Z",
});
insertEventEntity("event-daily-report-send", "daily_report_send_recorded", {
report_id: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
action: "send",
status: "sent",
actor: "operator",
endpoint: "management-console",
recorded_at: "2026-06-07T08:05:00.000Z",
});
insertEventEntity("event-daily-report-confirm", "daily_report_confirmed", {
report_id: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
action: "confirm",
status: "confirmed",
actor: "management",
remote_id: "remote-report-1",
recorded_at: "2026-06-07T08:06:00.000Z",
});
insertEventEntity("event-skill", "skill_call_rejected", {
call_id: "call-1",
source: "qimingclaw-acp",
@@ -1569,7 +1611,12 @@ describe("digital employee sync service", () => {
{ entity_type: "event", entity_id: "event-route-intervention" },
{ entity_type: "event", entity_id: "event-approval" },
{ entity_type: "event", entity_id: "event-approval-decision" },
{ entity_type: "event", entity_id: "event-approval-conflict-resolved" },
{ entity_type: "event", entity_id: "event-approval-conflict-rejected" },
{ entity_type: "event", entity_id: "event-artifact" },
{ entity_type: "event", entity_id: "event-artifact-cleanup" },
{ entity_type: "event", entity_id: "event-daily-report-send" },
{ entity_type: "event", entity_id: "event-daily-report-confirm" },
{ entity_type: "event", entity_id: "event-skill" },
{ entity_type: "event", entity_id: "event-sandbox-audit" },
{ entity_type: "event", entity_id: "event-token-cost" },
@@ -1652,6 +1699,28 @@ describe("digital employee sync service", () => {
task_id: "task-1",
run_id: "run-1",
});
expect(businessView("event-approval-conflict-resolved")).toMatchObject({
category: "approval",
approval_id: "approval-conflict-1",
action: "conflict_resolution",
approval_conflict_resolution: "keep_local",
approval_conflict_resolution_actor: "lead",
approval_conflict_resolved_at: "2026-06-07T08:00:00.000Z",
status: "approved",
reason_codes: ["conflict_resolved"],
previous_reason: "terminal_status_mismatch",
previous_local_status: "approved",
previous_remote_status: "rejected",
});
expect(businessView("event-approval-conflict-rejected")).toMatchObject({
category: "approval",
approval_id: "approval-conflict-2",
action: "conflict_resolution",
approval_conflict_resolution: "accept_remote",
approval_conflict_resolution_actor: "lead",
status: "rejected",
reason_codes: ["remote_snapshot_missing"],
});
expect(businessView("event-artifact")).toMatchObject({
category: "artifact",
artifact_id: "artifact-1",
@@ -1661,6 +1730,31 @@ describe("digital employee sync service", () => {
retention_until: "2026-07-01T00:00:00.000Z",
actor: "operator",
});
expect(businessView("event-artifact-cleanup")).toMatchObject({
category: "artifact",
artifact_id: "artifact-1",
status: "deleted",
cleanup_status: "deleted",
cleanup_reason: "retention_expired",
deleted_at: "2026-06-07T08:06:00.000Z",
reason_codes: ["retention_expired"],
actor: "operator",
});
expect(businessView("event-daily-report-send")).toMatchObject({
category: "daily_report",
report_id: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
action: "send",
status: "sent",
actor: "operator",
endpoint: "management-console",
});
expect(businessView("event-daily-report-confirm")).toMatchObject({
category: "daily_report",
report_id: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
action: "confirm",
status: "confirmed",
remote_id: "remote-report-1",
});
expect(businessView("event-skill")).toMatchObject({
category: "skill",
call_id: "call-1",

View File

@@ -1725,11 +1725,13 @@ function eventSpecificBusinessView(
if (kind === "route_decision_intervention_resolved") return routeDecisionInterventionBusinessView(payload);
if (kind === "approval_decision") return approvalDecisionBusinessView(payload);
if (kind.startsWith("approval_action_")) return approvalActionBusinessView(payload);
if (kind === "approval_conflict_resolved" || kind === "approval_conflict_resolution_rejected") return approvalConflictResolutionBusinessView(payload);
if (isApprovalSyncEvent(kind)) return approvalSyncBusinessView(payload);
if (isApprovalSubscriptionEvent(kind)) return approvalSubscriptionBusinessView(payload);
if (kind.startsWith("artifact_access_") || kind.startsWith("artifact_retention_") || kind === "artifact_version_observed") {
if (kind.startsWith("artifact_access_") || kind.startsWith("artifact_retention_") || kind.startsWith("artifact_cleanup_") || kind === "artifact_version_observed") {
return artifactEventBusinessView(payload);
}
if (isDailyReportEvent(kind)) return dailyReportEventBusinessView(payload);
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
if (kind === "risk_approval_required" || kind === "risk_policy_rejected") return riskPolicyBusinessView(payload);
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
@@ -1848,6 +1850,21 @@ function approvalDecisionBusinessView(payload: Record<string, unknown>): Record<
};
}
function approvalConflictResolutionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
action: "conflict_resolution",
approval_conflict_resolution: stringField(payload.resolution) || null,
approval_conflict_resolution_actor: stringField(payload.actor) || null,
approval_conflict_resolved_at: stringField(payload.resolved_at ?? payload.resolvedAt) || null,
status: stringField(payload.status) || null,
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
previous_reason: stringField(payload.previous_reason ?? payload.previousReason) || null,
previous_local_status: stringField(payload.previous_local_status ?? payload.previousLocalStatus) || null,
previous_remote_status: stringField(payload.previous_remote_status ?? payload.previousRemoteStatus) || null,
};
}
function approvalSyncBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? payload;
return {
@@ -1905,10 +1922,27 @@ function artifactEventBusinessView(payload: Record<string, unknown>): Record<str
status: stringField(payload.status) || null,
retention_status: stringField(payload.retention_status ?? retention?.retention_status) || null,
retention_until: stringField(payload.retention_until ?? retention?.retention_until) || null,
cleanup_status: stringField(payload.cleanup_status ?? payload.status) || null,
deleted_at: stringField(payload.deleted_at ?? payload.deletedAt) || null,
cleanup_reason: stringField(payload.reason ?? payload.cleanup_reason ?? payload.cleanupReason) || null,
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
actor: stringField(payload.actor) || null,
};
}
function dailyReportEventBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
report_id: stringField(payload.report_id ?? payload.reportId) || null,
action: stringField(payload.action) || null,
status: stringField(payload.status) || null,
actor: stringField(payload.actor) || null,
endpoint: stringField(payload.endpoint) || null,
remote_id: stringField(payload.remote_id ?? payload.remoteId) || null,
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
recorded_at: stringField(payload.recorded_at ?? payload.recordedAt) || null,
};
}
function skillCallBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
call_id: stringField(payload.call_id ?? payload.callId) || null,
@@ -2102,6 +2136,7 @@ function eventBusinessCategory(kind: string): string {
if (isApprovalSyncEvent(kind)) return "approval";
if (isApprovalSubscriptionEvent(kind)) return "approval";
if (kind.startsWith("artifact_")) return "artifact";
if (isDailyReportEvent(kind)) return "daily_report";
if (kind.startsWith("skill_call_")) return "skill";
if (isPlanStepDependencyEvent(kind)) return "dispatch";
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
@@ -2115,6 +2150,13 @@ function eventBusinessCategory(kind: string): string {
return "runtime";
}
function isDailyReportEvent(kind: string): boolean {
return kind === "daily_report_generated"
|| kind === "daily_report_send_recorded"
|| kind === "daily_report_confirmed"
|| kind === "daily_report_approval_requested";
}
function isApprovalSubscriptionEvent(kind: string): boolean {
return kind === "digital_workday_update_approval_subscriptions";
}