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_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("/");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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("/");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user