feat(artifacts): validate required export constraints
This commit is contained in:
parent
fc6a02ac1a
commit
e75176db39
3
dist/src/exportArtifacts.d.ts
vendored
3
dist/src/exportArtifacts.d.ts
vendored
@ -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<XWorkmateArtifactExport>;
|
||||
export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>;
|
||||
export declare function normalizeExpectedArtifactDirs(value: unknown): string[];
|
||||
export declare function normalizeRequiredExtensions(value: unknown): string[];
|
||||
export declare function formatArtifactManifestMarkdown(input: {
|
||||
remoteWorkingDirectory: string;
|
||||
artifactScope?: string;
|
||||
|
||||
45
dist/src/exportArtifacts.js
vendored
45
dist/src/exportArtifacts.js
vendored
@ -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) {
|
||||
|
||||
@ -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-"));
|
||||
|
||||
@ -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<XWor
|
||||
);
|
||||
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
|
||||
const includeContent = optionalBoolean(params.includeContent, true);
|
||||
const requiredArtifactExtensions = normalizeRequiredExtensions(params.requiredArtifactExtensions);
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
@ -301,6 +304,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@ -351,6 +359,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
}
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions);
|
||||
|
||||
const result = {
|
||||
runId,
|
||||
@ -363,6 +372,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
warnings,
|
||||
expectedArtifactDirs: expectedDirs,
|
||||
expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs),
|
||||
constraintSatisfied: missingRequiredExtensions.length === 0,
|
||||
missingRequiredExtensions,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
@ -474,6 +485,8 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
||||
warnings,
|
||||
expectedArtifactDirs: [],
|
||||
expectedArtifactDirStatus: [],
|
||||
constraintSatisfied: true,
|
||||
missingRequiredExtensions: [],
|
||||
};
|
||||
return result;
|
||||
}
|
||||
@ -496,6 +509,48 @@ export function normalizeExpectedArtifactDirs(value: unknown): string[] {
|
||||
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(
|
||||
workspaceRoot: string,
|
||||
expectedArtifactDirs: string[],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user