吸收数字员工产物生命周期治理
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,6 +162,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
...(typeof options === "object" && options ? options : {}),
|
||||
});
|
||||
},
|
||||
async recordArtifactLifecycle(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:recordArtifactLifecycle", input);
|
||||
},
|
||||
async restartManagedService(serviceId: string, options?: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:restartManagedService", { serviceId, ...(typeof options === "object" && options ? options : {}) });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user