fix(artifacts): apply per-run artifact ignore rules
This commit is contained in:
parent
9bc52e7861
commit
1e0658e004
108
dist/src/exportArtifacts.js
vendored
108
dist/src/exportArtifacts.js
vendored
@ -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("/");
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<XWor
|
||||
sinceUnixMs,
|
||||
warnSkippedSymlinks: true,
|
||||
warnings,
|
||||
ignoreRules: await loadArtifactIgnoreRules(scopeRoot, warnings),
|
||||
})
|
||||
: [];
|
||||
const candidates = scopedCandidates;
|
||||
@ -395,6 +397,7 @@ async function collectCandidates(input: {
|
||||
sinceUnixMs: number;
|
||||
warnSkippedSymlinks: boolean;
|
||||
warnings: string[];
|
||||
ignoreRules: ArtifactIgnoreRule[];
|
||||
}): Promise<Candidate[]> {
|
||||
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<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 {
|
||||
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user