吸收数字员工产物生命周期治理

This commit is contained in:
baiyanyun
2026-06-10 10:23:47 +08:00
parent 362f397e37
commit ed2f19d257
15 changed files with 739 additions and 164 deletions

View File

@@ -3,6 +3,7 @@ import type { HandlerContext } from "@shared/types/ipc";
const mockStateService = vi.hoisted(() => ({
recordManagedServiceAction: vi.fn(),
recordArtifactLifecycle: vi.fn(),
}));
const mockArtifactAccess = vi.hoisted(() => ({
@@ -66,6 +67,7 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
recordDigitalEmployeeSnapshot: vi.fn(() => ({ updatedAt: null, events: [] })),
recordDigitalEmployeeGovernanceCommand: vi.fn(),
recordDigitalEmployeeDailyReport: vi.fn(),
recordDigitalEmployeeArtifactLifecycle: mockStateService.recordArtifactLifecycle,
recordDigitalEmployeeManagedServiceAction: mockStateService.recordManagedServiceAction,
readDigitalEmployeeRuntimeRecords: vi.fn(),
readDigitalEmployeeState: vi.fn(),
@@ -133,6 +135,7 @@ describe("digital employee managed service actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockStateService.recordManagedServiceAction.mockReturnValue({ eventId: "event-1" });
mockStateService.recordArtifactLifecycle.mockReturnValue({ eventId: "artifact-event-1", kind: "artifact_retention_marked", payload: {} });
mockArtifactAccess.accessDigitalEmployeeArtifact.mockResolvedValue({ ok: true, artifact_id: "artifact-1", action: "preview" });
mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" });
mockServiceManager.createServiceManager.mockReturnValue({
@@ -209,4 +212,20 @@ describe("digital employee managed service actions", () => {
expect(result).toMatchObject({ ok: true, artifact_id: "artifact-1", action: "preview" });
expect(mockArtifactAccess.accessDigitalEmployeeArtifact).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "preview" });
});
it("registers artifact lifecycle commands through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const lifecycleCall = calls.find(([channel]) => channel === "digitalEmployee:recordArtifactLifecycle");
expect(lifecycleCall).toBeTruthy();
const handler = lifecycleCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>;
const result = await handler({}, { artifactId: "artifact-1", action: "mark_keep" });
expect(result).toMatchObject({ eventId: "artifact-event-1", kind: "artifact_retention_marked" });
expect(mockStateService.recordArtifactLifecycle).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "mark_keep" });
});
});

View File

@@ -21,6 +21,7 @@ import {
recordDigitalEmployeeSnapshot,
recordDigitalEmployeeGovernanceCommand,
recordDigitalEmployeeDailyReport,
recordDigitalEmployeeArtifactLifecycle,
recordDigitalEmployeeManagedServiceAction,
readDigitalEmployeeRuntimeRecords,
readDigitalEmployeeState,
@@ -39,6 +40,7 @@ import {
type DigitalEmployeeManagedServiceActionInput,
type DigitalEmployeeGovernanceCommandRecord,
type DigitalEmployeeDailyReportRecordInput,
type DigitalEmployeeArtifactLifecycleInput,
type DigitalEmployeeMemoryUpsertInput,
type DigitalEmployeeRouteDecisionInput,
type DigitalEmployeeServiceStatus,
@@ -232,6 +234,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
},
);
ipcMain.handle(
"digitalEmployee:recordArtifactLifecycle",
async (_, input: DigitalEmployeeArtifactLifecycleInput) => {
return recordDigitalEmployeeArtifactLifecycle(input ?? { action: "unknown_action" });
},
);
ipcMain.handle(
"digitalEmployee:restartManagedService",
async (_, input: RestartManagedServiceRequest) => {

View File

@@ -430,6 +430,73 @@ describe("digital employee state service", () => {
});
});
it("records artifact lifecycle events and rejects unknown retention actions", async () => {
const { recordDigitalEmployeeArtifactLifecycle } = await import("./stateService");
const marked = recordDigitalEmployeeArtifactLifecycle({
artifactId: "artifact-1",
action: "mark_keep",
reason: "operator_keep",
versionSummary: {
version: "v2",
version_key: "file:workspace:file:report:v2",
file_id: "file-1",
raw_path: "/Users/qiming/private/report.txt",
},
retentionSummary: {
previous_status: "unmanaged",
retention_status: "keep",
reason: "operator_keep",
},
deliverySummary: {
delivery_status: "completed",
file_id: "file-1",
secret_token: "should-not-be-persisted",
},
actor: "operator",
source: "test",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
occurredAt: "2026-06-07T08:35:00.000Z",
});
const rejected = recordDigitalEmployeeArtifactLifecycle({
artifactId: "artifact-1",
action: "delete_file",
occurredAt: "2026-06-07T08:36:00.000Z",
});
expect(marked?.eventId).toBe("artifact-lifecycle:artifact-1:mark_keep:2026-06-07T08-35-00-000Z");
expect(rejected?.kind).toBe("artifact_retention_rejected");
expect(mockState.db?.events.get(marked!.eventId)).toMatchObject({
kind: "artifact_retention_marked",
message: "产物保留策略已标记:保留 artifact-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
const payload = JSON.parse(mockState.db?.events.get(marked!.eventId)?.payload ?? "{}");
expect(payload).toMatchObject({
artifact_id: "artifact-1",
action: "mark_keep",
status: "recorded",
retention_status: "keep",
version_summary: { version: "v2", file_id: "file-1" },
retention_summary: { previous_status: "unmanaged", retention_status: "keep" },
delivery_summary: { delivery_status: "completed", file_id: "file-1" },
});
expect(JSON.stringify(payload)).not.toContain("/Users/qiming/private/report.txt");
expect(JSON.stringify(payload)).not.toContain("should-not-be-persisted");
expect(mockState.db?.outbox.get(`event:insert:${marked!.eventId}`)).toMatchObject({
entity_type: "event",
status: "pending",
});
expect(JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}")).toMatchObject({
action: "delete_file",
status: "rejected",
});
});
it("records and lists route decisions as syncable events", async () => {
const {
listDigitalEmployeeRouteDecisions,

View File

@@ -238,6 +238,31 @@ export interface DigitalEmployeeArtifactAccessAuditRecord {
payload: Record<string, unknown>;
}
export type DigitalEmployeeArtifactRetentionAction = "mark_keep" | "mark_expire" | "mark_review" | "clear_retention";
export interface DigitalEmployeeArtifactLifecycleInput {
artifactId?: string | null;
action: DigitalEmployeeArtifactRetentionAction | "observe_version" | string;
reason?: string | null;
retentionUntil?: string | null;
versionSummary?: Record<string, unknown> | null;
retentionSummary?: Record<string, unknown> | null;
deliverySummary?: 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 DigitalEmployeeArtifactLifecycleRecord {
eventId: string;
kind: "artifact_retention_marked" | "artifact_retention_rejected" | "artifact_version_observed";
payload: Record<string, unknown>;
}
export interface DigitalEmployeeRouteDecisionRecord {
id: string;
route_kind: string;
@@ -1082,6 +1107,97 @@ export function recordDigitalEmployeeArtifactAccess(
return { eventId, kind, status, payload };
}
export function recordDigitalEmployeeArtifactLifecycle(
input: DigitalEmployeeArtifactLifecycleInput,
): DigitalEmployeeArtifactLifecycleRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = input.occurredAt || new Date().toISOString();
const artifactId = stringValue(input.artifactId) || "unknown-artifact";
const action = stringValue(input.action) || "unknown_action";
const validRetentionAction = isDigitalEmployeeArtifactRetentionAction(action);
const isVersionObserve = action === "observe_version";
const kind: DigitalEmployeeArtifactLifecycleRecord["kind"] = isVersionObserve
? "artifact_version_observed"
: validRetentionAction
? "artifact_retention_marked"
: "artifact_retention_rejected";
const retentionStatus = retentionStatusForAction(action);
const eventId = `artifact-lifecycle:${safeId(artifactId)}:${safeId(action)}:${safeId(now)}`;
const payload: Record<string, unknown> = {
artifact_id: artifactId,
action,
status: kind === "artifact_retention_rejected" ? "rejected" : "recorded",
reason: stringValue(input.reason) || null,
retention_status: retentionStatus,
retention_until: stringValue(input.retentionUntil) || null,
version_summary: sanitizeArtifactVersionSummary(objectRecord(input.versionSummary) ?? {}),
retention_summary: sanitizeArtifactRetentionSummary(objectRecord(input.retentionSummary) ?? {}),
delivery_summary: sanitizeArtifactDeliverySnapshot(objectRecord(input.deliverySummary) ?? {}),
actor: stringValue(input.actor) || null,
source: stringValue(input.source) || "qimingclaw-artifact-lifecycle",
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
run_id: stringValue(input.runId) || null,
};
insertEvent(
{
event_id: eventId,
kind,
occurred_at: now,
message: input.message || artifactLifecycleMessage(kind, action, artifactId),
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
},
stringValue(input.runId) || null,
payload,
);
return { eventId, kind, payload };
}
function isDigitalEmployeeArtifactRetentionAction(action: string): action is DigitalEmployeeArtifactRetentionAction {
return ["mark_keep", "mark_expire", "mark_review", "clear_retention"].includes(action);
}
function retentionStatusForAction(action: string): string | null {
if (action === "mark_keep") return "keep";
if (action === "mark_expire") return "expire_candidate";
if (action === "mark_review") return "review_required";
if (action === "clear_retention") return "unmanaged";
return null;
}
function sanitizeArtifactVersionSummary(summary: Record<string, unknown>): Record<string, unknown> {
const allowedKeys = new Set(["version", "version_key", "version_rank", "file_id", "workspace_id", "project_id", "created_at", "name"]);
return Object.fromEntries(
Object.entries(summary).filter(([key]) => allowedKeys.has(key)),
);
}
function sanitizeArtifactRetentionSummary(summary: Record<string, unknown>): Record<string, unknown> {
const allowedKeys = new Set(["retention_status", "retention_until", "previous_status", "reason"]);
return Object.fromEntries(
Object.entries(summary).filter(([key]) => allowedKeys.has(key)),
);
}
function artifactLifecycleMessage(
kind: DigitalEmployeeArtifactLifecycleRecord["kind"],
action: string,
artifactId: string,
): string {
if (kind === "artifact_version_observed") return `产物版本已观测:${artifactId}`;
if (kind === "artifact_retention_rejected") return `产物保留策略已拒绝:${artifactId}`;
const actionLabel = action === "mark_keep"
? "保留"
: action === "mark_expire"
? "到期候选"
: action === "mark_review"
? "待复核"
: "清除保留标记";
return `产物保留策略已标记:${actionLabel} ${artifactId}`;
}
function normalizeArtifactAccessAction(action: string): DigitalEmployeeArtifactAccessAction {
return ["preview", "download", "open_location"].includes(action)
? action as DigitalEmployeeArtifactAccessAction