From e75176db390849fc7e1a073fd3da7ab8cf8b4e0c Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 12 Jun 2026 14:08:08 +0800 Subject: [PATCH] feat(artifacts): validate required export constraints --- dist/src/exportArtifacts.d.ts | 3 + dist/src/exportArtifacts.js | 45 +++++++++++++ src/exportArtifacts.test.ts | 121 ++++++++++++++++++++++++++++++++++ src/exportArtifacts.ts | 55 ++++++++++++++++ 4 files changed, 224 insertions(+) diff --git a/dist/src/exportArtifacts.d.ts b/dist/src/exportArtifacts.d.ts index 7992c2c..ff435b1 100644 --- a/dist/src/exportArtifacts.d.ts +++ b/dist/src/exportArtifacts.d.ts @@ -22,6 +22,8 @@ export type XWorkmateArtifactExport = { warnings: string[]; expectedArtifactDirs: string[]; expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[]; + constraintSatisfied: boolean; + missingRequiredExtensions: string[]; }; export type XWorkmateArtifactPrepare = { runId: string; @@ -67,6 +69,7 @@ export declare function collectAndSnapshotXWorkmateArtifacts(input: ExportInput) export declare function exportXWorkmateArtifacts(input: ExportInput): Promise; export declare function readXWorkmateArtifact(input: ReadInput): Promise; export declare function normalizeExpectedArtifactDirs(value: unknown): string[]; +export declare function normalizeRequiredExtensions(value: unknown): string[]; export declare function formatArtifactManifestMarkdown(input: { remoteWorkingDirectory: string; artifactScope?: string; diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index 9e9ab03..0e6cc5a 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -129,6 +129,7 @@ export async function exportXWorkmateArtifacts(input) { const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES); const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0); const includeContent = optionalBoolean(params.includeContent, true); + const requiredArtifactExtensions = normalizeRequiredExtensions(params.requiredArtifactExtensions); const workspaceDir = resolveWorkspaceDir({ config: input.config, pluginConfig, @@ -194,6 +195,11 @@ export async function exportXWorkmateArtifacts(input) { warnings.push("artifact scope is not prepared for this task run"); } candidates.sort((left, right) => { + const leftRequiredMatch = matchesRequiredExtension(left.relativePath, requiredArtifactExtensions) ? 1 : 0; + const rightRequiredMatch = matchesRequiredExtension(right.relativePath, requiredArtifactExtensions) ? 1 : 0; + if (rightRequiredMatch !== leftRequiredMatch) { + return rightRequiredMatch - leftRequiredMatch; + } if (right.mtimeMs !== left.mtimeMs) { return right.mtimeMs - left.mtimeMs; } @@ -240,6 +246,7 @@ export async function exportXWorkmateArtifacts(input) { } artifacts.push(artifact); } + const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions); const result = { runId, sessionKey, @@ -251,6 +258,8 @@ export async function exportXWorkmateArtifacts(input) { warnings, expectedArtifactDirs: expectedDirs, expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs), + constraintSatisfied: missingRequiredExtensions.length === 0, + missingRequiredExtensions, }; return result; } @@ -355,6 +364,8 @@ export async function readXWorkmateArtifact(input) { warnings, expectedArtifactDirs: [], expectedArtifactDirStatus: [], + constraintSatisfied: true, + missingRequiredExtensions: [], }; return result; } @@ -375,6 +386,40 @@ export function normalizeExpectedArtifactDirs(value) { } return result; } +export function normalizeRequiredExtensions(value) { + if (!Array.isArray(value)) { + return []; + } + const seen = new Set(); + const result = []; + for (const entry of value) { + const normalized = optionalString(entry) + .toLowerCase() + .replace(/^\.+/u, ""); + if (!normalized || normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) { + continue; + } + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} +function matchesRequiredExtension(relativePath, requiredExtensions) { + if (requiredExtensions.length === 0) { + return false; + } + const lowerPath = relativePath.toLowerCase(); + return requiredExtensions.some((extension) => lowerPath.endsWith(`.${extension}`)); +} +function missingRequiredArtifactExtensions(artifacts, requiredExtensions) { + if (requiredExtensions.length === 0) { + return []; + } + return requiredExtensions.filter((extension) => !artifacts.some((artifact) => artifact.relativePath.toLowerCase().endsWith(`.${extension}`))); +} async function expectedArtifactDirStatuses(workspaceRoot, expectedArtifactDirs) { const statuses = []; for (const relativePath of expectedArtifactDirs) { diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index cc0fee7..bd36bb4 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -134,7 +134,128 @@ describe("exportXWorkmateArtifacts", () => { ).rejects.toThrow("expectedArtifactDir must stay inside the workspace"); }); + it("satisfies required artifact extensions when a matching artifact is exported", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { openclawSessionKey: "thread-main", runId: "run-required" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.writeFile(path.join(prepared.artifactDirectory, "final.PDF"), "pdf"); + await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes"); + const result = await exportXWorkmateArtifacts({ + params: { + openclawSessionKey: "thread-main", + runId: "run-required", + requiredArtifactExtensions: [".pdf"], + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.constraintSatisfied).toBe(true); + expect(result.missingRequiredExtensions).toEqual([]); + }); + + it("reports missing required artifact extensions", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { openclawSessionKey: "thread-main", runId: "run-missing-required" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes"); + + const result = await exportXWorkmateArtifacts({ + params: { + openclawSessionKey: "thread-main", + runId: "run-missing-required", + requiredArtifactExtensions: ["pdf"], + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.constraintSatisfied).toBe(false); + expect(result.missingRequiredExtensions).toEqual(["pdf"]); + }); + + it("treats an empty required artifact extension list as satisfied", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { openclawSessionKey: "thread-main", runId: "run-empty-required" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes"); + + const result = await exportXWorkmateArtifacts({ + params: { + openclawSessionKey: "thread-main", + runId: "run-empty-required", + requiredArtifactExtensions: [], + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.constraintSatisfied).toBe(true); + expect(result.missingRequiredExtensions).toEqual([]); + }); + + it("keeps required-extension artifacts before truncating export results", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { openclawSessionKey: "thread-main", runId: "run-required-limit" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.writeFile(path.join(prepared.artifactDirectory, "final.mp4"), "mp4"); + await new Promise((resolve) => setTimeout(resolve, 30)); + for (let index = 0; index < 64; index += 1) { + await fs.writeFile(path.join(prepared.artifactDirectory, `part-${String(index).padStart(2, "0")}.txt`), "txt"); + } + + const result = await exportXWorkmateArtifacts({ + params: { + openclawSessionKey: "thread-main", + runId: "run-required-limit", + requiredArtifactExtensions: ["mp4"], + maxFiles: 64, + maxInlineBytes: 0, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts).toHaveLength(64); + expect(result.artifacts.some((entry) => entry.relativePath === "final.mp4")).toBe(true); + expect(result.constraintSatisfied).toBe(true); + expect(result.missingRequiredExtensions).toEqual([]); + expect(result.warnings).toContain("artifact limit reached; skipped remaining files after 64"); + }); + + it("keeps mtime and relative path ordering when no required extensions are provided", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { openclawSessionKey: "thread-main", runId: "run-no-required-order" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.writeFile(path.join(prepared.artifactDirectory, "older.txt"), "older"); + await new Promise((resolve) => setTimeout(resolve, 30)); + await fs.writeFile(path.join(prepared.artifactDirectory, "newer-b.txt"), "newer"); + await fs.writeFile(path.join(prepared.artifactDirectory, "newer-a.txt"), "newer"); + + const result = await exportXWorkmateArtifacts({ + params: { + openclawSessionKey: "thread-main", + runId: "run-no-required-order", + maxInlineBytes: 0, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([ + "newer-a.txt", + "newer-b.txt", + "older.txt", + ]); + expect(result.constraintSatisfied).toBe(true); + expect(result.missingRequiredExtensions).toEqual([]); + }); it("snapshots OpenClaw media and tmp outputs into the current task artifact scope", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index 16d1932..00d1243 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -46,6 +46,8 @@ export type XWorkmateArtifactExport = { warnings: string[]; expectedArtifactDirs: string[]; expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[]; + constraintSatisfied: boolean; + missingRequiredExtensions: string[]; }; export type XWorkmateArtifactPrepare = { @@ -234,6 +236,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise { + const leftRequiredMatch = matchesRequiredExtension(left.relativePath, requiredArtifactExtensions) ? 1 : 0; + const rightRequiredMatch = matchesRequiredExtension(right.relativePath, requiredArtifactExtensions) ? 1 : 0; + if (rightRequiredMatch !== leftRequiredMatch) { + return rightRequiredMatch - leftRequiredMatch; + } if (right.mtimeMs !== left.mtimeMs) { return right.mtimeMs - left.mtimeMs; } @@ -351,6 +359,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise(); + const result: string[] = []; + for (const entry of value) { + const normalized = optionalString(entry) + .toLowerCase() + .replace(/^\.+/u, ""); + if (!normalized || normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) { + continue; + } + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function matchesRequiredExtension(relativePath: string, requiredExtensions: string[]): boolean { + if (requiredExtensions.length === 0) { + return false; + } + const lowerPath = relativePath.toLowerCase(); + return requiredExtensions.some((extension) => lowerPath.endsWith(`.${extension}`)); +} + +function missingRequiredArtifactExtensions( + artifacts: XWorkmateArtifact[], + requiredExtensions: string[], +): string[] { + if (requiredExtensions.length === 0) { + return []; + } + return requiredExtensions.filter( + (extension) => !artifacts.some((artifact) => artifact.relativePath.toLowerCase().endsWith(`.${extension}`)), + ); +} + async function expectedArtifactDirStatuses( workspaceRoot: string, expectedArtifactDirs: string[],