feat(artifacts): validate required export constraints

This commit is contained in:
Haitao Pan 2026-06-12 14:08:08 +08:00
parent fc6a02ac1a
commit e75176db39
4 changed files with 224 additions and 0 deletions

View File

@ -22,6 +22,8 @@ export type XWorkmateArtifactExport = {
warnings: string[]; warnings: string[];
expectedArtifactDirs: string[]; expectedArtifactDirs: string[];
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[]; expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
constraintSatisfied: boolean;
missingRequiredExtensions: string[];
}; };
export type XWorkmateArtifactPrepare = { export type XWorkmateArtifactPrepare = {
runId: string; runId: string;
@ -67,6 +69,7 @@ export declare function collectAndSnapshotXWorkmateArtifacts(input: ExportInput)
export declare function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>; export declare function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>;
export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>; export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>;
export declare function normalizeExpectedArtifactDirs(value: unknown): string[]; export declare function normalizeExpectedArtifactDirs(value: unknown): string[];
export declare function normalizeRequiredExtensions(value: unknown): string[];
export declare function formatArtifactManifestMarkdown(input: { export declare function formatArtifactManifestMarkdown(input: {
remoteWorkingDirectory: string; remoteWorkingDirectory: string;
artifactScope?: string; artifactScope?: string;

View File

@ -129,6 +129,7 @@ export async function exportXWorkmateArtifacts(input) {
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES); const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0); const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const includeContent = optionalBoolean(params.includeContent, true); const includeContent = optionalBoolean(params.includeContent, true);
const requiredArtifactExtensions = normalizeRequiredExtensions(params.requiredArtifactExtensions);
const workspaceDir = resolveWorkspaceDir({ const workspaceDir = resolveWorkspaceDir({
config: input.config, config: input.config,
pluginConfig, pluginConfig,
@ -194,6 +195,11 @@ export async function exportXWorkmateArtifacts(input) {
warnings.push("artifact scope is not prepared for this task run"); warnings.push("artifact scope is not prepared for this task run");
} }
candidates.sort((left, right) => { 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) { if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs; return right.mtimeMs - left.mtimeMs;
} }
@ -240,6 +246,7 @@ export async function exportXWorkmateArtifacts(input) {
} }
artifacts.push(artifact); artifacts.push(artifact);
} }
const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions);
const result = { const result = {
runId, runId,
sessionKey, sessionKey,
@ -251,6 +258,8 @@ export async function exportXWorkmateArtifacts(input) {
warnings, warnings,
expectedArtifactDirs: expectedDirs, expectedArtifactDirs: expectedDirs,
expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs), expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs),
constraintSatisfied: missingRequiredExtensions.length === 0,
missingRequiredExtensions,
}; };
return result; return result;
} }
@ -355,6 +364,8 @@ export async function readXWorkmateArtifact(input) {
warnings, warnings,
expectedArtifactDirs: [], expectedArtifactDirs: [],
expectedArtifactDirStatus: [], expectedArtifactDirStatus: [],
constraintSatisfied: true,
missingRequiredExtensions: [],
}; };
return result; return result;
} }
@ -375,6 +386,40 @@ export function normalizeExpectedArtifactDirs(value) {
} }
return result; 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) { async function expectedArtifactDirStatuses(workspaceRoot, expectedArtifactDirs) {
const statuses = []; const statuses = [];
for (const relativePath of expectedArtifactDirs) { for (const relativePath of expectedArtifactDirs) {

View File

@ -134,7 +134,128 @@ describe("exportXWorkmateArtifacts", () => {
).rejects.toThrow("expectedArtifactDir must stay inside the workspace"); ).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 () => { 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-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));

View File

@ -46,6 +46,8 @@ export type XWorkmateArtifactExport = {
warnings: string[]; warnings: string[];
expectedArtifactDirs: string[]; expectedArtifactDirs: string[];
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[]; expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
constraintSatisfied: boolean;
missingRequiredExtensions: string[];
}; };
export type XWorkmateArtifactPrepare = { export type XWorkmateArtifactPrepare = {
@ -234,6 +236,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
); );
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0); const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const includeContent = optionalBoolean(params.includeContent, true); const includeContent = optionalBoolean(params.includeContent, true);
const requiredArtifactExtensions = normalizeRequiredExtensions(params.requiredArtifactExtensions);
const workspaceDir = resolveWorkspaceDir({ const workspaceDir = resolveWorkspaceDir({
config: input.config, config: input.config,
pluginConfig, pluginConfig,
@ -301,6 +304,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
} }
candidates.sort((left, right) => { 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) { if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs; return right.mtimeMs - left.mtimeMs;
} }
@ -351,6 +359,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
} }
artifacts.push(artifact); artifacts.push(artifact);
} }
const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions);
const result = { const result = {
runId, runId,
@ -363,6 +372,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
warnings, warnings,
expectedArtifactDirs: expectedDirs, expectedArtifactDirs: expectedDirs,
expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs), expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs),
constraintSatisfied: missingRequiredExtensions.length === 0,
missingRequiredExtensions,
}; };
return result; return result;
} }
@ -474,6 +485,8 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
warnings, warnings,
expectedArtifactDirs: [], expectedArtifactDirs: [],
expectedArtifactDirStatus: [], expectedArtifactDirStatus: [],
constraintSatisfied: true,
missingRequiredExtensions: [],
}; };
return result; return result;
} }
@ -496,6 +509,48 @@ export function normalizeExpectedArtifactDirs(value: unknown): string[] {
return result; return result;
} }
export function normalizeRequiredExtensions(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
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( async function expectedArtifactDirStatuses(
workspaceRoot: string, workspaceRoot: string,
expectedArtifactDirs: string[], expectedArtifactDirs: string[],