From b68fc811c935ba5cd47f3b01d40fa1dd24b8e3cc Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 18 May 2026 06:26:53 +0800 Subject: [PATCH] Adopt OpenClaw root artifacts into task scope --- README.md | 12 ++++-- dist/src/exportArtifacts.js | 65 ++++++++++++++++++++++++++++- src/exportArtifacts.test.ts | 82 ++++++++++++++++++++++++++++++++++++- src/exportArtifacts.ts | 74 ++++++++++++++++++++++++++++++++- 4 files changed, 223 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b1ca9d5..7816c4b 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,13 @@ Export response payload: Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`. When `artifactScope` is omitted, export/list defaults to the current task scope -derived from `sessionKey/runId`. If that scope has no files, export/list returns -an empty artifact list with the manifest text `No artifacts found for this task -run.` The plugin does not scan the workspace root and does not borrow artifacts -from earlier task scopes. +derived from `sessionKey/runId`. If `sinceUnixMs` is provided, export also +adopts files created or changed in the workspace root during the current run by +copying them into that task scope before returning the manifest. This covers +agents that save output as `./file.md` while still keeping XWorkmate sync scoped +to `tasks//`. Without `sinceUnixMs`, export/list only reads the +current task scope. The plugin never scans `tasks/` as a fallback and does not +borrow artifacts from earlier task scopes. Each exported artifact includes `artifactRef`, a plugin-signed reference over the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts @@ -191,6 +194,7 @@ only remote file access path. - Only files inside the resolved OpenClaw workspace are exported. - `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are excluded from task artifact exports. +- Workspace-root files are adopted only when `sinceUnixMs` is provided; adopted files are copied into the current `tasks//` scope before listing or reading. - Symlinks are skipped to avoid workspace escape. - Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined. - `artifactScope` must be `tasks//`. diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index a7c89bf..f7c3e84 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -77,7 +77,10 @@ export async function exportXWorkmateArtifacts(input) { const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope); const scopeKind = "task"; const scopePrepared = await directoryExists(scopeRoot); - const candidates = scopePrepared + if (!scopePrepared && sinceUnixMs > 0) { + await fs.mkdir(scopeRoot, { recursive: true }); + } + const scopedCandidates = (await directoryExists(scopeRoot)) ? await collectCandidates({ scanRoot: scopeRoot, relativeRoot: scopeRoot, @@ -86,7 +89,18 @@ export async function exportXWorkmateArtifacts(input) { warnings, }) : []; - if (!scopePrepared) { + const adoptedCandidates = sinceUnixMs > 0 + ? await adoptWorkspaceRootCandidatesIntoScope({ + workspaceRoot, + scopeRoot, + artifactScope, + sinceUnixMs, + existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)), + warnings, + }) + : []; + const candidates = [...scopedCandidates, ...adoptedCandidates]; + if (!scopePrepared && candidates.length === 0) { warnings.push("artifact scope is not prepared for this task run"); } candidates.sort((left, right) => { @@ -282,6 +296,44 @@ export function formatArtifactManifestMarkdown(input) { } return lines.join("\n"); } +async function adoptWorkspaceRootCandidatesIntoScope(input) { + const rootCandidates = await collectCandidates({ + scanRoot: input.workspaceRoot, + relativeRoot: input.workspaceRoot, + sinceUnixMs: input.sinceUnixMs, + skipTaskScopeRoot: true, + warnings: input.warnings, + }); + const adopted = []; + for (const candidate of rootCandidates) { + if (input.existingRelativePaths.has(candidate.relativePath)) { + continue; + } + const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep)); + if (!isWithinRoot(input.scopeRoot, targetPath)) { + input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`); + continue; + } + if (await fileExists(targetPath)) { + input.existingRelativePaths.add(candidate.relativePath); + continue; + } + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(candidate.absolutePath, targetPath); + const stat = await fs.stat(targetPath); + const realPath = await fs.realpath(targetPath); + adopted.push({ + absolutePath: realPath, + relativePath: candidate.relativePath, + sizeBytes: stat.size, + mtimeMs: candidate.mtimeMs, + artifactScope: input.artifactScope, + scopeKind: "task", + }); + input.existingRelativePaths.add(candidate.relativePath); + } + return adopted; +} async function collectCandidates(input) { const candidates = []; await walk(input.scanRoot); @@ -412,6 +464,15 @@ async function directoryExists(absolutePath) { return false; } } +async function fileExists(absolutePath) { + try { + const stat = await fs.stat(absolutePath); + return stat.isFile(); + } + catch { + return false; + } +} function safeArtifactRefRunScope(value) { try { return safeTaskArtifactScope(value); diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index 607e243..d03c818 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -176,7 +176,7 @@ describe("exportXWorkmateArtifacts", () => { expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["current.txt"]); }); - it("does not scan the workspace root when the current task scope is empty", async () => { + it("does not scan the workspace root without a current-run timestamp", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); await prepareXWorkmateArtifacts({ params: { sessionKey: "thread-main", runId: "turn-1" }, @@ -198,6 +198,84 @@ describe("exportXWorkmateArtifacts", () => { expect(result.manifestMarkdown).toContain("Artifact scope: `tasks/thread-main/turn-1`"); }); + it("adopts current-run workspace root files into the task artifact scope", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + await prepareXWorkmateArtifacts({ + params: { sessionKey: "thread-main", runId: "turn-1" }, + pluginConfig: { workspaceDir: root }, + }); + const sinceUnixMs = Date.now() - 1_000; + await fs.writeFile(path.join(root, "xhs_account_security.md"), "# Account security\n"); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "thread-main", + runId: "turn-1", + sinceUnixMs, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.scopeKind).toBe("task"); + expect(result.artifactScope).toBe("tasks/thread-main/turn-1"); + expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["xhs_account_security.md"]); + expect(result.artifacts[0]).toMatchObject({ + artifactScope: "tasks/thread-main/turn-1", + scopeKind: "task", + contentType: "text/markdown", + encoding: "base64", + content: Buffer.from("# Account security\n").toString("base64"), + }); + expect(await fs.readFile(path.join(root, "tasks", "thread-main", "turn-1", "xhs_account_security.md"), "utf8")).toBe( + "# Account security\n", + ); + }); + + it("creates the current task scope when adopting root files after bridge export", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const sinceUnixMs = Date.now() - 1_000; + await fs.mkdir(path.join(root, "reports"), { recursive: true }); + await fs.writeFile(path.join(root, "reports", "final.md"), "final"); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "thread-main", + runId: "turn-1", + sinceUnixMs, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifactScope).toBe("tasks/thread-main/turn-1"); + expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/final.md"]); + expect(result.warnings).toEqual([]); + expect(await fs.readFile(path.join(root, "tasks", "thread-main", "turn-1", "reports", "final.md"), "utf8")).toBe( + "final", + ); + }); + + it("does not adopt old workspace root files into a later task scope", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + await prepareXWorkmateArtifacts({ + params: { sessionKey: "thread-main", runId: "turn-1" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.writeFile(path.join(root, "old-root.md"), "old"); + const stat = await fs.stat(path.join(root, "old-root.md")); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "thread-main", + runId: "turn-1", + sinceUnixMs: stat.mtimeMs + 10_000, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts).toEqual([]); + await expect(fs.stat(path.join(root, "tasks", "thread-main", "turn-1", "old-root.md"))).rejects.toThrow(); + }); + it("rejects scoped exports that do not match the requested session/run", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const first = await prepareXWorkmateArtifacts({ @@ -221,7 +299,7 @@ describe("exportXWorkmateArtifacts", () => { ).rejects.toThrow("artifactScope does not match sessionKey/runId"); }); - it("does not fall back to workspace files when the scoped directory is empty", async () => { + it("does not adopt old workspace files when the scoped directory is empty", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const prepared = await prepareXWorkmateArtifacts({ params: { sessionKey: "thread-main", runId: "turn-1" }, diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index 2ab1901..98fb359 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -158,7 +158,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise 0) { + await fs.mkdir(scopeRoot, { recursive: true }); + } + const scopedCandidates = (await directoryExists(scopeRoot)) ? await collectCandidates({ scanRoot: scopeRoot, relativeRoot: scopeRoot, @@ -167,7 +170,19 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise 0 + ? await adoptWorkspaceRootCandidatesIntoScope({ + workspaceRoot, + scopeRoot, + artifactScope, + sinceUnixMs, + existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)), + warnings, + }) + : []; + const candidates = [...scopedCandidates, ...adoptedCandidates]; + if (!scopePrepared && candidates.length === 0) { warnings.push("artifact scope is not prepared for this task run"); } @@ -387,6 +402,52 @@ export function formatArtifactManifestMarkdown(input: { return lines.join("\n"); } +async function adoptWorkspaceRootCandidatesIntoScope(input: { + workspaceRoot: string; + scopeRoot: string; + artifactScope: string; + sinceUnixMs: number; + existingRelativePaths: Set; + warnings: string[]; +}): Promise { + const rootCandidates = await collectCandidates({ + scanRoot: input.workspaceRoot, + relativeRoot: input.workspaceRoot, + sinceUnixMs: input.sinceUnixMs, + skipTaskScopeRoot: true, + warnings: input.warnings, + }); + const adopted: Candidate[] = []; + for (const candidate of rootCandidates) { + if (input.existingRelativePaths.has(candidate.relativePath)) { + continue; + } + const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep)); + if (!isWithinRoot(input.scopeRoot, targetPath)) { + input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`); + continue; + } + if (await fileExists(targetPath)) { + input.existingRelativePaths.add(candidate.relativePath); + continue; + } + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(candidate.absolutePath, targetPath); + const stat = await fs.stat(targetPath); + const realPath = await fs.realpath(targetPath); + adopted.push({ + absolutePath: realPath, + relativePath: candidate.relativePath, + sizeBytes: stat.size, + mtimeMs: candidate.mtimeMs, + artifactScope: input.artifactScope, + scopeKind: "task", + }); + input.existingRelativePaths.add(candidate.relativePath); + } + return adopted; +} + async function collectCandidates(input: { scanRoot: string; relativeRoot: string; @@ -538,6 +599,15 @@ async function directoryExists(absolutePath: string): Promise { } } +async function fileExists(absolutePath: string): Promise { + try { + const stat = await fs.stat(absolutePath); + return stat.isFile(); + } catch { + return false; + } +} + function safeArtifactRefRunScope(value: unknown): string { try { return safeTaskArtifactScope(value);