fix(artifacts): apply per-run artifact ignore rules

This commit is contained in:
Haitao Pan 2026-06-02 04:46:01 +08:00
parent 9bc52e7861
commit 1e0658e004
4 changed files with 267 additions and 1 deletions

View File

@ -5,6 +5,7 @@ import path from "node:path";
const DEFAULT_MAX_FILES = 64; const DEFAULT_MAX_FILES = 64;
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
const TASK_SCOPE_ROOT = "tasks"; const TASK_SCOPE_ROOT = "tasks";
const ARTIFACT_IGNORE_FILE = "artifact-ignore.md";
const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex"); const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex");
const SKIPPED_DIRS = new Set([ const SKIPPED_DIRS = new Set([
".git", ".git",
@ -85,6 +86,7 @@ export async function exportXWorkmateArtifacts(input) {
sinceUnixMs, sinceUnixMs,
warnSkippedSymlinks: true, warnSkippedSymlinks: true,
warnings, warnings,
ignoreRules: await loadArtifactIgnoreRules(scopeRoot, warnings),
}) })
: []; : [];
const candidates = scopedCandidates; const candidates = scopedCandidates;
@ -333,6 +335,9 @@ async function collectCandidates(input) {
if (!relativePath) { if (!relativePath) {
continue; continue;
} }
if (isIgnoredArtifactPath(relativePath, input.ignoreRules)) {
continue;
}
candidates.push({ candidates.push({
absolutePath: realPath, absolutePath: realPath,
relativePath, 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) { function artifactScopeFor(sessionKey, runId) {
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/"); return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "openclaw-multi-session-plugins", "name": "openclaw-multi-session-plugins",
"version": "0.1.14", "version": "0.1.15",
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts", "description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View File

@ -127,6 +127,45 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.warnings.some((entry) => entry.includes("linked.txt"))).toBe(true); 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 () => { 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 root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const first = await prepareXWorkmateArtifacts({ const first = await prepareXWorkmateArtifacts({

View File

@ -6,6 +6,7 @@ import path from "node:path";
const DEFAULT_MAX_FILES = 64; const DEFAULT_MAX_FILES = 64;
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
const TASK_SCOPE_ROOT = "tasks"; const TASK_SCOPE_ROOT = "tasks";
const ARTIFACT_IGNORE_FILE = "artifact-ignore.md";
const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex"); const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex");
const SKIPPED_DIRS = new Set([ const SKIPPED_DIRS = new Set([
@ -166,6 +167,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
sinceUnixMs, sinceUnixMs,
warnSkippedSymlinks: true, warnSkippedSymlinks: true,
warnings, warnings,
ignoreRules: await loadArtifactIgnoreRules(scopeRoot, warnings),
}) })
: []; : [];
const candidates = scopedCandidates; const candidates = scopedCandidates;
@ -395,6 +397,7 @@ async function collectCandidates(input: {
sinceUnixMs: number; sinceUnixMs: number;
warnSkippedSymlinks: boolean; warnSkippedSymlinks: boolean;
warnings: string[]; warnings: string[];
ignoreRules: ArtifactIgnoreRule[];
}): Promise<Candidate[]> { }): Promise<Candidate[]> {
const candidates: Candidate[] = []; const candidates: Candidate[] = [];
await walk(input.scanRoot); await walk(input.scanRoot);
@ -444,6 +447,9 @@ async function collectCandidates(input: {
if (!relativePath) { if (!relativePath) {
continue; continue;
} }
if (isIgnoredArtifactPath(relativePath, input.ignoreRules)) {
continue;
}
candidates.push({ candidates.push({
absolutePath: realPath, absolutePath: realPath,
relativePath, 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<ArtifactIgnoreRule[]> {
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 { function artifactScopeFor(sessionKey: string, runId: string): string {
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/"); return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
} }