吸收数字员工审批冲突与产物业务闭环
This commit is contained in:
@@ -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: [{
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user