吸收数字员工产物交付状态

This commit is contained in:
baiyanyun
2026-06-09 16:47:14 +08:00
parent 4d8b8e4ad5
commit 5160a73254
7 changed files with 365 additions and 42 deletions

View File

@@ -2052,23 +2052,28 @@ function buildArtifacts(snapshot: QimingclawSnapshot | null): ArtifactEntry[] {
function artifactFromRuntimeRecord(artifact: QimingclawArtifactRecord): ArtifactEntry {
const payload = asRecord(artifact.payload);
const delivery = asRecord(payload?.delivery);
const sourceTool = stringValue(delivery?.source_tool);
const deliveryUri = stringValue(delivery?.uri);
const deliveryFilename = stringValue(delivery?.filename);
return {
artifact_id: artifact.id,
subject_id: artifact.runId ?? artifact.taskId ?? artifact.planId ?? artifact.id,
kind: artifact.kind,
name: artifact.name || stringValue(payload?.name ?? payload?.title) || artifactName(artifact.uri ?? '') || artifact.kind,
uri: artifact.uri || stringValue(payload?.uri ?? payload?.url ?? payload?.path) || undefined,
value: stringValue(payload?.value ?? payload?.summary ?? payload?.text) || artifact.uri || artifact.name || artifact.kind,
name: artifact.name || deliveryFilename || stringValue(payload?.name ?? payload?.title) || artifactName(artifact.uri ?? deliveryUri) || artifact.kind,
uri: artifact.uri || deliveryUri || stringValue(payload?.uri ?? payload?.url ?? payload?.path) || undefined,
value: stringValue(payload?.value ?? payload?.summary ?? payload?.text) || artifact.uri || deliveryUri || artifact.name || deliveryFilename || artifact.kind,
plan_id: artifact.planId ?? null,
task_id: artifact.taskId ?? null,
run_id: artifact.runId ?? null,
created_at: artifact.createdAt,
source: 'qimingclaw-runtime',
source_type: 'artifact',
source_label: artifactSourceLabel('artifact', artifact.id),
source_label: sourceTool ? `File Server: ${sourceTool}` : artifactSourceLabel('artifact', artifact.id),
sync_status: artifact.syncStatus,
sync_error: artifact.syncError,
payload: artifact.payload,
...deliveryProjection(delivery),
};
}
@@ -2136,23 +2141,27 @@ function artifactFromValue(
): ArtifactEntry {
const record = asRecord(value);
if (record) {
const delivery = asRecord(record.delivery);
const sourceTool = stringValue(delivery?.source_tool);
const deliveryUri = stringValue(delivery?.uri);
const uri = stringValue(record.uri ?? record.url ?? record.path ?? record.filePath ?? record.file_path ?? record.outputPath ?? record.output_path);
const name = stringValue(record.name ?? record.title ?? record.filename) || artifactName(uri) || `产物 ${index + 1}`;
const name = stringValue(record.name ?? record.title ?? record.filename) || stringValue(delivery?.filename) || artifactName(uri || deliveryUri) || `产物 ${index + 1}`;
return {
artifact_id: stringValue(record.artifact_id ?? record.id) || `artifact-${slugifyIdPart(subjectId)}-${index + 1}`,
subject_id: stringValue(record.subject_id) || subjectId,
kind: stringValue(record.kind ?? record.type) || 'artifact',
name,
uri,
value: stringValue(record.value ?? record.summary ?? record.text) || uri,
uri: uri || deliveryUri,
value: stringValue(record.value ?? record.summary ?? record.text) || uri || deliveryUri,
created_at: (stringValue(record.created_at ?? record.createdAt) || createdAt) ?? undefined,
source: 'qimingclaw-runtime',
source_type: stringValue(record.source_type) || sourceContext?.sourceType || 'payload',
source_event_id: stringValue(record.source_event_id) || sourceContext?.sourceEventId || null,
source_label: stringValue(record.source_label) || sourceContext?.sourceLabel || artifactSourceLabel('payload', subjectId),
source_label: sourceTool ? `File Server: ${sourceTool}` : stringValue(record.source_label) || sourceContext?.sourceLabel || artifactSourceLabel('payload', subjectId),
plan_id: stringValue(record.plan_id) || sourceContext?.planId || null,
task_id: stringValue(record.task_id) || sourceContext?.taskId || null,
run_id: stringValue(record.run_id) || sourceContext?.runId || null,
...deliveryProjection(delivery),
};
}
@@ -2175,6 +2184,20 @@ function artifactFromValue(
};
}
function deliveryProjection(delivery: Record<string, unknown> | null): Record<string, unknown> {
if (!delivery) return {};
return {
delivery_status: stringValue(delivery.status) || null,
delivery_stage: stringValue(delivery.stage) || null,
workspace_id: stringValue(delivery.workspace_id) || null,
project_id: stringValue(delivery.project_id) || null,
file_id: stringValue(delivery.file_id) || null,
version: stringValue(delivery.version) || null,
file_size: numberValue(delivery.size),
source_tool: stringValue(delivery.source_tool) || null,
};
}
function artifactSourceLabel(sourceType: string, sourceId?: string | null): string {
const suffix = sourceId ? ` ${sourceId}` : '';
switch (sourceType) {
@@ -2211,6 +2234,15 @@ function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function numberValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function looksLikeUri(value: string): boolean {
return /^(file|https?):\/\//i.test(value) || value.includes('/') || value.includes('\\');
}
@@ -2555,6 +2587,14 @@ function reportArtifactSources(artifacts: ArtifactEntry[]) {
name: stringValue(artifact.name) || stringValue(artifact.uri) || stringValue(artifact.value) || '未命名产物',
kind: stringValue(artifact.kind) || null,
uri: stringValue(artifact.uri) || null,
delivery_status: stringValue(artifact.delivery_status) || null,
delivery_stage: stringValue(artifact.delivery_stage) || null,
workspace_id: stringValue(artifact.workspace_id) || null,
project_id: stringValue(artifact.project_id) || null,
file_id: stringValue(artifact.file_id) || null,
version: stringValue(artifact.version) || null,
file_size: typeof artifact.file_size === 'number' ? artifact.file_size : null,
source_tool: stringValue(artifact.source_tool) || null,
source_label: stringValue(artifact.source_label) || null,
source_type: stringValue(artifact.source_type) || null,
source_event_id: stringValue(artifact.source_event_id) || null,

View File

@@ -718,6 +718,14 @@ export interface DigitalEmployeeReportArtifactSource {
name: string;
kind?: string | null;
uri?: string | null;
delivery_status?: string | null;
delivery_stage?: string | null;
workspace_id?: string | null;
project_id?: string | null;
file_id?: string | null;
version?: string | null;
file_size?: number | null;
source_tool?: string | null;
source_label?: string | null;
source_type?: string | null;
source_event_id?: string | null;

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-Dc0KTjwq.js"></script>
<script type="module" crossorigin src="./assets/index-Dm9GtUrc.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
</head>
<body>

View File

@@ -83,6 +83,110 @@ describe("digital employee state service", () => {
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("normalizes file server workspace events into delivery artifact payload", async () => {
const { recordDigitalEmployeeRuntimeEvent } = await import("./stateService");
recordDigitalEmployeeRuntimeEvent({
kind: "file_server.workspace.created",
request_id: "file-workspace-1",
message: "create-workspace 完成",
status: "completed",
payload: {
source: "qiming-file-server",
toolName: "create_workspace",
workspaceId: "workspace-123",
projectId: "project-file",
path: "/tmp/qiming/workspace-123",
status: "completed",
},
});
const artifacts = Array.from(mockState.db?.artifacts.values() ?? []);
expect(artifacts).toHaveLength(1);
expect(artifacts[0]).toMatchObject({
kind: "workspace",
name: "workspace-123",
uri: "/tmp/qiming/workspace-123",
});
expect(JSON.parse(artifacts[0]?.payload ?? "{}")).toMatchObject({
delivery: {
stage: "workspace",
status: "completed",
workspace_id: "workspace-123",
project_id: "project-file",
uri: "/tmp/qiming/workspace-123",
source_tool: "create_workspace",
source_event_kind: "file_server.workspace.created",
},
});
expect(mockState.db?.outbox.get(`artifact:upsert:${artifacts[0]?.id}`)).toMatchObject({
entity_type: "artifact",
status: "pending",
});
});
it("upserts file server file delivery status without duplicating artifacts", async () => {
const { recordDigitalEmployeeRuntimeEvent } = await import("./stateService");
const basePayload = {
source: "qiming-file-server",
workspaceId: "workspace-123",
projectId: "project-file",
fileId: "file-1",
filename: "report.pdf",
version: "v2",
size: 2048,
path: "/tmp/qiming/workspace-123/report.pdf",
};
recordDigitalEmployeeRuntimeEvent({
kind: "file_server.upload.completed",
request_id: "file-delivery-1",
message: "upload-file 完成",
status: "completed",
payload: {
...basePayload,
toolName: "upload_file",
status: "uploaded",
},
});
recordDigitalEmployeeRuntimeEvent({
kind: "file_server.download.completed",
request_id: "file-delivery-1",
message: "download-all-files 完成",
status: "completed",
payload: {
...basePayload,
toolName: "download_all_files",
status: "downloaded",
downloadUrl: "file:///tmp/qiming/workspace-123/report.pdf",
},
});
const artifacts = Array.from(mockState.db?.artifacts.values() ?? []);
expect(artifacts).toHaveLength(1);
expect(artifacts[0]).toMatchObject({
kind: "download",
name: "report.pdf",
uri: "file:///tmp/qiming/workspace-123/report.pdf",
});
expect(JSON.parse(artifacts[0]?.payload ?? "{}")).toMatchObject({
delivery: {
stage: "download",
status: "downloaded",
workspace_id: "workspace-123",
project_id: "project-file",
file_id: "file-1",
version: "v2",
filename: "report.pdf",
size: 2048,
uri: "file:///tmp/qiming/workspace-123/report.pdf",
source_tool: "download_all_files",
source_event_kind: "file_server.download.completed",
},
});
expect(Array.from(mockState.db?.outbox.values() ?? []).filter((row) => row.entity_type === "artifact")).toHaveLength(1);
});
it("records and lists route decisions as syncable events", async () => {
const {
listDigitalEmployeeRouteDecisions,

View File

@@ -1402,7 +1402,9 @@ export function recordDigitalEmployeeRuntimeEvent(
if (status === "completed" || status === "failed") {
finishRun(ids.runId, status, now);
}
persistPayloadArtifactsAndApprovals(ids, event.payload ?? event, now);
persistPayloadArtifactsAndApprovals(ids, event.payload ?? event, now, {
sourceEventKind: event.kind,
});
insertEvent(
{
event_id: `${ids.runId}:${event.kind}:${Date.now()}`,
@@ -3592,9 +3594,10 @@ function persistPayloadArtifactsAndApprovals(
ids: { planId: string; taskId: string; runId: string },
payload: unknown,
now: string,
context: ArtifactExtractionContext = {},
): void {
const sources = payloadSources(payload);
const artifacts = sources.flatMap((source) => extractArtifacts(source));
const artifacts = sources.flatMap((source) => extractArtifacts(source, context));
const approvals = sources.flatMap((source) => extractApprovals(source));
dedupeByKey(artifacts, (artifact) => artifact.key).slice(0, 20).forEach((artifact, index) => {
@@ -3645,12 +3648,34 @@ interface ExtractedArtifact {
payload: unknown;
}
function extractArtifacts(payload: unknown): ExtractedArtifact[] {
interface ArtifactExtractionContext {
sourceEventKind?: string;
}
interface DeliveryMetadata {
stage: string;
status: string | null;
workspace_id: string | null;
project_id: string | null;
file_id: string | null;
version: string | null;
filename: string | null;
size: number | null;
uri: string | null;
error: string | null;
source_tool: string | null;
source_event_kind: string | null;
}
function extractArtifacts(payload: unknown, context: ArtifactExtractionContext = {}): ExtractedArtifact[] {
const record = objectRecord(payload);
if (!record) return typeof payload === "string" && looksLikeArtifactUri(payload)
? [artifactFromValue(payload, 0)]
: [];
const fileServerArtifacts = extractFileServerArtifacts(record, context);
if (fileServerArtifacts.length > 0) return fileServerArtifacts;
const values = [
...unknownArray(record.artifacts),
...unknownArray(record.outputs),
@@ -3691,6 +3716,152 @@ function artifactFromValue(value: unknown, index: number): ExtractedArtifact {
};
}
const FILE_SERVER_TOOLS = new Set([
"create_workspace",
"create_workspace_v2",
"push_skills_to_workspace",
"push_skills_to_workspace_v2",
"get_file_list",
"files_update",
"upload_file",
"upload_files",
"download_all_files",
"create_project",
"upload_project",
"get_project_content",
"get_project_content_by_version",
"backup_current_version",
"export_project",
"delete_project",
"upload_attachment_file",
"copy_project",
]);
function extractFileServerArtifacts(
record: Record<string, unknown>,
context: ArtifactExtractionContext,
): ExtractedArtifact[] {
const sourceTool = normalizeFileServerTool(record);
if (!isFileServerPayload(record, sourceTool, context)) return [];
const values = [
...unknownArray(record.artifacts),
...unknownArray(record.outputs),
...unknownArray(record.files),
...unknownArray(record.attachments),
];
if (values.length === 0) values.push(record);
return values.map((value, index) => {
const item = objectRecord(value);
return fileServerArtifactFromRecord(item ? { ...record, ...item } : record, sourceTool, context, index);
}).filter((artifact) => artifact.name || artifact.uri);
}
function fileServerArtifactFromRecord(
record: Record<string, unknown>,
sourceTool: string,
context: ArtifactExtractionContext,
index: number,
): ExtractedArtifact {
const delivery = buildFileServerDelivery(record, sourceTool, context);
const name = delivery.filename
|| stringValue(record.name ?? record.title ?? record.projectName ?? record.project_name)
|| delivery.workspace_id
|| artifactName(delivery.uri || "")
|| `File Server 产物 ${index + 1}`;
return {
key: delivery.file_id
|| stringValue(record.id ?? record.artifact_id)
|| delivery.uri
|| `${delivery.stage}:${delivery.workspace_id || delivery.project_id || name}`,
kind: delivery.stage,
name,
uri: delivery.uri,
payload: {
...record,
delivery,
},
};
}
function buildFileServerDelivery(
record: Record<string, unknown>,
sourceTool: string,
context: ArtifactExtractionContext,
): DeliveryMetadata {
const uri = stringValue(
record.downloadUrl
?? record.download_url
?? record.exportPath
?? record.export_path
?? record.uri
?? record.url
?? record.path
?? record.filePath
?? record.file_path
?? record.outputPath
?? record.output_path,
);
return {
stage: fileServerDeliveryStage(sourceTool),
status: stringValue(record.status ?? record.deliveryStatus ?? record.delivery_status) || null,
workspace_id: stringValue(record.workspaceId ?? record.workspace_id ?? record.workspace) || null,
project_id: stringValue(record.projectId ?? record.project_id ?? record.project) || null,
file_id: stringValue(record.fileId ?? record.file_id ?? record.id ?? record.artifact_id) || null,
version: stringValue(record.version ?? record.fileVersion ?? record.file_version) || null,
filename: stringValue(record.filename ?? record.fileName ?? record.file_name ?? record.name) || artifactName(uri) || null,
size: numberValue(record.size ?? record.fileSize ?? record.file_size ?? record.bytes),
uri: uri || null,
error: stringValue(record.error ?? record.message_error ?? record.lastError ?? record.last_error) || null,
source_tool: sourceTool || null,
source_event_kind: context.sourceEventKind || null,
};
}
function isFileServerPayload(
record: Record<string, unknown>,
sourceTool: string,
context: ArtifactExtractionContext,
): boolean {
if (sourceTool && FILE_SERVER_TOOLS.has(sourceTool)) return true;
const text = [
record.source,
record.service,
record.provider,
record.server,
context.sourceEventKind,
].map((value) => stringValue(value).toLowerCase()).join(" ");
return text.includes("qiming-file-server")
|| text.includes("qiming_file_server")
|| text.includes("file_server")
|| text.includes("file-server");
}
function normalizeFileServerTool(record: Record<string, unknown>): string {
return stringValue(record.toolName ?? record.tool_name ?? record.tool ?? record.action ?? record.command)
.toLowerCase()
.replace(/-/g, "_");
}
function fileServerDeliveryStage(tool: string): string {
if (tool.includes("workspace")) return "workspace";
if (tool.includes("download")) return "download";
if (tool.includes("export")) return "export";
if (tool.includes("upload") || tool.includes("push_skills")) return "upload";
if (tool.includes("files_update") || tool.includes("file_list") || tool.includes("project_content")) return "file_update";
return "artifact";
}
function numberValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
interface ExtractedApproval {
key: string;
title: string;

View File

@@ -573,9 +573,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕技能权限 scope 和调用审计,把策略从“路由候选过滤”推进到“实际工具调用前校验”。
7. **产物交付闭环**
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步。
- 缺口:缺少文件上传/下载生命周期、产物预览、版本、保留策略、交付状态和管理端业务产物视图。
- 建议:先把 File Server 的 workspace / upload / download 状态写入 Artifact payload再补管理端产物列表
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息
- 缺口:缺少产物预览、版本完整治理、保留策略、下载权限和管理端业务产物视图。
- 建议:下一轮围绕 Artifact 预览和下载权限,把 File Server 交付状态从事实归集推进到可操作交付视图
8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形。