From 1e0658e0046f100e0eeb61f7a5359c7c4dfd17d7 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 2 Jun 2026 04:46:01 +0800 Subject: [PATCH] fix(artifacts): apply per-run artifact ignore rules --- dist/src/exportArtifacts.js | 108 ++++++++++++++++++++++++++++++++ package.json | 2 +- src/exportArtifacts.test.ts | 39 ++++++++++++ src/exportArtifacts.ts | 119 ++++++++++++++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 1 deletion(-) diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index ef9226a..3f0c753 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -5,6 +5,7 @@ import path from "node:path"; const DEFAULT_MAX_FILES = 64; const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024; const TASK_SCOPE_ROOT = "tasks"; +const ARTIFACT_IGNORE_FILE = "artifact-ignore.md"; const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex"); const SKIPPED_DIRS = new Set([ ".git", @@ -85,6 +86,7 @@ export async function exportXWorkmateArtifacts(input) { sinceUnixMs, warnSkippedSymlinks: true, warnings, + ignoreRules: await loadArtifactIgnoreRules(scopeRoot, warnings), }) : []; const candidates = scopedCandidates; @@ -333,6 +335,9 @@ async function collectCandidates(input) { if (!relativePath) { continue; } + if (isIgnoredArtifactPath(relativePath, input.ignoreRules)) { + continue; + } candidates.push({ absolutePath: realPath, relativePath, @@ -342,6 +347,109 @@ async function collectCandidates(input) { } } } +async function loadArtifactIgnoreRules(scopeRoot, warnings) { + const rules = [{ kind: "exact", path: ARTIFACT_IGNORE_FILE }]; + const ignorePath = path.join(scopeRoot, ARTIFACT_IGNORE_FILE); + let content = ""; + try { + content = await fs.readFile(ignorePath, "utf8"); + } + catch (error) { + if (error?.code !== "ENOENT") { + warnings.push(`cannot read ${ARTIFACT_IGNORE_FILE}: ${String(error)}`); + } + return rules; + } + for (const line of artifactIgnoreRuleLines(content)) { + const rule = parseArtifactIgnoreRule(line, warnings); + if (rule) { + rules.push(rule); + } + } + return rules; +} +function artifactIgnoreRuleLines(content) { + const lines = content.split(/\r?\n/); + const fencedLines = []; + let insideBlock = false; + let sawBlock = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!insideBlock && trimmed === "```artifact-ignore") { + insideBlock = true; + sawBlock = true; + continue; + } + if (insideBlock && trimmed === "```") { + insideBlock = false; + continue; + } + if (insideBlock) { + fencedLines.push(line); + } + } + return sawBlock ? fencedLines : lines; +} +function parseArtifactIgnoreRule(line, warnings) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return undefined; + } + if (trimmed.includes("\0") || path.isAbsolute(trimmed) || trimmed.split(/[\\/]/).some((part) => part === ".." || part === ".")) { + warnings.push(`ignored unsafe artifact ignore rule: ${trimmed}`); + return undefined; + } + const directoryRule = /[\\/]$/.test(trimmed); + const normalized = trimmed.split(/[\\/]/).filter(Boolean).join("/"); + if (!normalized) { + return undefined; + } + if (directoryRule) { + if (normalized.includes("*")) { + warnings.push(`ignored unsupported artifact ignore rule: ${trimmed}`); + return undefined; + } + return { kind: "directory", path: normalized }; + } + if (normalized.startsWith("**/*") && normalized.length > 4) { + return { kind: "any-suffix", suffix: normalized.slice(4) }; + } + if (!normalized.includes("/") && normalized.startsWith("*") && normalized.length > 1) { + return { kind: "root-suffix", suffix: normalized.slice(1) }; + } + if (normalized.includes("*")) { + warnings.push(`ignored unsupported artifact ignore rule: ${trimmed}`); + return undefined; + } + return { kind: "exact", path: normalized }; +} +function isIgnoredArtifactPath(relativePath, rules) { + for (const rule of rules) { + switch (rule.kind) { + case "directory": + if (relativePath === rule.path || relativePath.startsWith(`${rule.path}/`)) { + return true; + } + break; + case "exact": + if (relativePath === rule.path) { + return true; + } + break; + case "root-suffix": + if (!relativePath.includes("/") && relativePath.endsWith(rule.suffix)) { + return true; + } + break; + case "any-suffix": + if (relativePath.endsWith(rule.suffix)) { + return true; + } + break; + } + } + return false; +} function artifactScopeFor(sessionKey, runId) { return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/"); } diff --git a/package.json b/package.json index 7e72e16..0f53558 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-multi-session-plugins", - "version": "0.1.14", + "version": "0.1.15", "description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts", "type": "module", "license": "MIT", diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index 2323770..5f0e914 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -127,6 +127,45 @@ describe("exportXWorkmateArtifacts", () => { expect(result.warnings.some((entry) => entry.includes("linked.txt"))).toBe(true); }); + it("applies artifact-ignore.md inside the current task scope", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { sessionKey: "thread-main", runId: "run-1" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.mkdir(path.join(prepared.artifactDirectory, "tmp"), { recursive: true }); + await fs.mkdir(path.join(prepared.artifactDirectory, "reports", "debug"), { recursive: true }); + await fs.writeFile( + path.join(prepared.artifactDirectory, "artifact-ignore.md"), + [ + "# Artifact Ignore", + "", + "```artifact-ignore", + "tmp/", + "*.log", + "**/*.tmp", + "reports/debug/trace.json", + "```", + "", + ].join("\n"), + ); + await fs.writeFile(path.join(prepared.artifactDirectory, "tmp", "scratch.md"), "scratch"); + await fs.writeFile(path.join(prepared.artifactDirectory, "root.log"), "log"); + await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "draft.tmp"), "tmp"); + await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "debug", "trace.json"), "{}"); + await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "final.md"), "final"); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "thread-main", + runId: "run-1", + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/final.md"]); + }); + it("exports only files inside a task artifact scope", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const first = await prepareXWorkmateArtifacts({ diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index 583efa5..ae2ef75 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -6,6 +6,7 @@ import path from "node:path"; const DEFAULT_MAX_FILES = 64; const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024; const TASK_SCOPE_ROOT = "tasks"; +const ARTIFACT_IGNORE_FILE = "artifact-ignore.md"; const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex"); const SKIPPED_DIRS = new Set([ @@ -166,6 +167,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise { const candidates: Candidate[] = []; await walk(input.scanRoot); @@ -444,6 +447,9 @@ async function collectCandidates(input: { if (!relativePath) { continue; } + if (isIgnoredArtifactPath(relativePath, input.ignoreRules)) { + continue; + } candidates.push({ absolutePath: realPath, relativePath, @@ -454,6 +460,119 @@ async function collectCandidates(input: { } } +type ArtifactIgnoreRule = + | { kind: "directory"; path: string } + | { kind: "exact"; path: string } + | { kind: "root-suffix"; suffix: string } + | { kind: "any-suffix"; suffix: string }; + +async function loadArtifactIgnoreRules(scopeRoot: string, warnings: string[]): Promise { + const rules: ArtifactIgnoreRule[] = [{ kind: "exact", path: ARTIFACT_IGNORE_FILE }]; + const ignorePath = path.join(scopeRoot, ARTIFACT_IGNORE_FILE); + let content = ""; + try { + content = await fs.readFile(ignorePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + warnings.push(`cannot read ${ARTIFACT_IGNORE_FILE}: ${String(error)}`); + } + return rules; + } + + for (const line of artifactIgnoreRuleLines(content)) { + const rule = parseArtifactIgnoreRule(line, warnings); + if (rule) { + rules.push(rule); + } + } + return rules; +} + +function artifactIgnoreRuleLines(content: string): string[] { + const lines = content.split(/\r?\n/); + const fencedLines: string[] = []; + let insideBlock = false; + let sawBlock = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!insideBlock && trimmed === "```artifact-ignore") { + insideBlock = true; + sawBlock = true; + continue; + } + if (insideBlock && trimmed === "```") { + insideBlock = false; + continue; + } + if (insideBlock) { + fencedLines.push(line); + } + } + return sawBlock ? fencedLines : lines; +} + +function parseArtifactIgnoreRule(line: string, warnings: string[]): ArtifactIgnoreRule | undefined { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return undefined; + } + if (trimmed.includes("\0") || path.isAbsolute(trimmed) || trimmed.split(/[\\/]/).some((part) => part === ".." || part === ".")) { + warnings.push(`ignored unsafe artifact ignore rule: ${trimmed}`); + return undefined; + } + const directoryRule = /[\\/]$/.test(trimmed); + const normalized = trimmed.split(/[\\/]/).filter(Boolean).join("/"); + if (!normalized) { + return undefined; + } + if (directoryRule) { + if (normalized.includes("*")) { + warnings.push(`ignored unsupported artifact ignore rule: ${trimmed}`); + return undefined; + } + return { kind: "directory", path: normalized }; + } + if (normalized.startsWith("**/*") && normalized.length > 4) { + return { kind: "any-suffix", suffix: normalized.slice(4) }; + } + if (!normalized.includes("/") && normalized.startsWith("*") && normalized.length > 1) { + return { kind: "root-suffix", suffix: normalized.slice(1) }; + } + if (normalized.includes("*")) { + warnings.push(`ignored unsupported artifact ignore rule: ${trimmed}`); + return undefined; + } + return { kind: "exact", path: normalized }; +} + +function isIgnoredArtifactPath(relativePath: string, rules: ArtifactIgnoreRule[]): boolean { + for (const rule of rules) { + switch (rule.kind) { + case "directory": + if (relativePath === rule.path || relativePath.startsWith(`${rule.path}/`)) { + return true; + } + break; + case "exact": + if (relativePath === rule.path) { + return true; + } + break; + case "root-suffix": + if (!relativePath.includes("/") && relativePath.endsWith(rule.suffix)) { + return true; + } + break; + case "any-suffix": + if (relativePath.endsWith(rule.suffix)) { + return true; + } + break; + } + } + return false; +} + function artifactScopeFor(sessionKey: string, runId: string): string { return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/"); }