diff --git a/README.md b/README.md index f3ad07a..d63cd05 100644 --- a/README.md +++ b/README.md @@ -145,19 +145,9 @@ 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//`. -When `sinceUnixMs` is provided and the prepared task scope plus current-run -workspace-root adoption are both empty, export falls back to explicit delivery -files in the same OpenClaw thread workspace for the supplied `sessionKey`. This -handles long-running agents that finish in OpenClaw's owner/thread workspace -instead of the prepared `tasks//` directory. The fallback only -copies known deliverables such as `DELIVERY.md`, `ffprobe.json`, -`video.config.json`, `index.html`, and files under delivery directories like -`renders/`; it does not scan other threads or borrow artifacts from earlier -task scopes. - 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. +never scans `tasks/`, `owners/*/threads/*`, or any previous thread workspace 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 @@ -217,7 +207,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. -- If `sinceUnixMs` is provided and task-scope plus workspace-root adoption are empty, export may adopt explicit deliverables from the same `sessionKey` owner/thread workspace. This fallback is limited to known delivery files and delivery directories, and never scans other thread workspaces. +- Export never adopts files from OpenClaw owner/thread workspaces; agents must write into the prepared task scope or into the current-run workspace root for timestamp-gated adoption. - 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 98f4301..b6c8132 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -18,39 +18,6 @@ const SKIPPED_DIRS = new Set([ "dist", "node_modules", ]); -const THREAD_DELIVERY_FILE_NAMES = new Set([ - "DELIVERY.md", - "delivery.md", - "ffprobe.json", - "video.config.json", - "index.html", -]); -const THREAD_DELIVERY_DIRS = new Set([ - "assets", - "renders", - "render", - "exports", - "output", - "outputs", -]); -const THREAD_DELIVERY_EXTENSIONS = new Set([ - ".mp4", - ".mov", - ".webm", - ".pdf", - ".docx", - ".pptx", - ".xlsx", - ".md", - ".html", - ".json", - ".png", - ".jpg", - ".jpeg", - ".webp", - ".gif", - ".svg", -]); export async function prepareXWorkmateArtifacts(input) { const params = input.params ?? {}; const pluginConfig = input.pluginConfig ?? {}; @@ -133,17 +100,7 @@ export async function exportXWorkmateArtifacts(input) { warnings, }) : []; - const threadDeliveryCandidates = sinceUnixMs > 0 && scopedCandidates.length === 0 && adoptedCandidates.length === 0 - ? await adoptThreadWorkspaceDeliveryCandidatesIntoScope({ - workspaceRoot, - scopeRoot, - artifactScope, - sessionKey, - existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)), - warnings, - }) - : []; - const candidates = [...scopedCandidates, ...adoptedCandidates, ...threadDeliveryCandidates]; + const candidates = [...scopedCandidates, ...adoptedCandidates]; if (!scopePrepared && candidates.length === 0) { warnings.push("artifact scope is not prepared for this task run"); } @@ -379,152 +336,6 @@ async function adoptWorkspaceRootCandidatesIntoScope(input) { } return adopted; } -async function adoptThreadWorkspaceDeliveryCandidatesIntoScope(input) { - const threadRoots = await resolveCurrentThreadWorkspaceRoots(input.workspaceRoot, input.sessionKey); - const adopted = []; - for (const threadRoot of threadRoots) { - const candidates = await collectThreadDeliveryCandidates({ - scanRoot: threadRoot, - relativeRoot: threadRoot, - warnings: input.warnings, - }); - for (const candidate of candidates) { - 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 resolveCurrentThreadWorkspaceRoots(workspaceRoot, sessionKey) { - const roots = new Set(); - const realWorkspaceRoot = await fs.realpath(workspaceRoot); - if (path.basename(realWorkspaceRoot) === sessionKey) { - roots.add(realWorkspaceRoot); - } - const ownerRoots = [ - path.join(realWorkspaceRoot, "owners", "local", "user"), - path.join(realWorkspaceRoot, "owners", "remote", "user"), - ]; - for (const ownerRoot of ownerRoots) { - if (!(await directoryExists(ownerRoot))) { - continue; - } - let ownerEntries; - try { - ownerEntries = await fs.readdir(ownerRoot, { withFileTypes: true }); - } - catch { - continue; - } - for (const ownerEntry of ownerEntries) { - if (!ownerEntry.isDirectory()) { - continue; - } - const candidate = path.join(ownerRoot, ownerEntry.name, "threads", sessionKey); - if (!(await directoryExists(candidate))) { - continue; - } - const realCandidate = await fs.realpath(candidate); - if (isWithinRoot(realWorkspaceRoot, realCandidate)) { - roots.add(realCandidate); - } - } - } - return [...roots]; -} -async function collectThreadDeliveryCandidates(input) { - const candidates = []; - await walk(input.scanRoot, []); - return candidates; - async function walk(currentDir, segments) { - let entries; - try { - entries = await fs.readdir(currentDir, { withFileTypes: true }); - } - catch (error) { - input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`); - return; - } - entries.sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { - if (entry.name === "." || entry.name === "..") { - continue; - } - const absolutePath = path.join(currentDir, entry.name); - const relativeEntryPath = [...segments, entry.name].join("/"); - if (entry.isSymbolicLink()) { - const isDeliveryPath = isThreadDeliveryFile(relativeEntryPath) || THREAD_DELIVERY_DIRS.has(segments[0] ?? entry.name); - if (isDeliveryPath) { - input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`); - } - continue; - } - if (entry.isDirectory()) { - if (segments.length === 0 && !THREAD_DELIVERY_DIRS.has(entry.name)) { - continue; - } - if (SKIPPED_DIRS.has(entry.name)) { - continue; - } - await walk(absolutePath, [...segments, entry.name]); - continue; - } - if (!entry.isFile()) { - continue; - } - const relativePath = safeRelativePath(input.relativeRoot, absolutePath); - if (!relativePath || !isThreadDeliveryFile(relativePath)) { - continue; - } - const stat = await fs.stat(absolutePath); - const realPath = await fs.realpath(absolutePath); - if (!isWithinRoot(input.relativeRoot, realPath)) { - input.warnings.push(`skipped path outside workspace ${entry.name}`); - continue; - } - candidates.push({ - absolutePath: realPath, - relativePath, - sizeBytes: stat.size, - mtimeMs: Math.max(stat.mtimeMs, stat.ctimeMs), - }); - } - } -} -function isThreadDeliveryFile(relativePath) { - const parts = relativePath.split("/"); - const fileName = parts[parts.length - 1] ?? ""; - if (parts.length === 1) { - return THREAD_DELIVERY_FILE_NAMES.has(fileName); - } - if (!THREAD_DELIVERY_DIRS.has(parts[0] ?? "")) { - return false; - } - return THREAD_DELIVERY_EXTENSIONS.has(path.extname(fileName).toLowerCase()); -} async function collectCandidates(input) { const candidates = []; await walk(input.scanRoot); diff --git a/package.json b/package.json index 4bc6902..d889fa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-multi-session-plugins", - "version": "0.1.12", + "version": "0.1.13", "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 2ebf985..680c079 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -389,7 +389,7 @@ describe("exportXWorkmateArtifacts", () => { expect(result.warnings).toEqual([]); }); - it("adopts same-thread delivery files when the prepared task scope is empty", async () => { + it("does not adopt same-thread delivery files when the prepared task scope is empty", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); await prepareXWorkmateArtifacts({ params: { @@ -428,20 +428,12 @@ describe("exportXWorkmateArtifacts", () => { }); expect(result.artifactScope).toBe("tasks/draft_1779524982823421-3/turn-1779685283403237342"); - expect(result.artifacts.map((entry) => entry.relativePath).sort()).toEqual([ - "DELIVERY.md", - "assets/images/manifest.md", - "assets/images/security-identity-evolution/001-local-permission.png", - "renders/cloud-native-servicemesh-network.mp4", - ]); - expect(result.artifacts.map((entry) => entry.contentType).sort()).toEqual([ - "image/png", - "text/markdown", - "text/markdown", - "video/mp4", - ]); - expect( - await fs.readFile( + expect(result.artifacts).toEqual([]); + await expect( + fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "scratch.txt")), + ).rejects.toThrow(); + await expect( + fs.stat( path.join( root, "tasks", @@ -450,26 +442,7 @@ describe("exportXWorkmateArtifacts", () => { "renders", "cloud-native-servicemesh-network.mp4", ), - "utf8", ), - ).toBe("mp4"); - expect( - await fs.readFile( - path.join( - root, - "tasks", - "draft_1779524982823421-3", - "turn-1779685283403237342", - "assets", - "images", - "security-identity-evolution", - "001-local-permission.png", - ), - "utf8", - ), - ).toBe("png"); - await expect( - fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "scratch.txt")), ).rejects.toThrow(); await expect( fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "renders", "other.mp4")), diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index ce68569..f0f2505 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -21,42 +21,6 @@ const SKIPPED_DIRS = new Set([ "node_modules", ]); -const THREAD_DELIVERY_FILE_NAMES = new Set([ - "DELIVERY.md", - "delivery.md", - "ffprobe.json", - "video.config.json", - "index.html", -]); - -const THREAD_DELIVERY_DIRS = new Set([ - "assets", - "renders", - "render", - "exports", - "output", - "outputs", -]); - -const THREAD_DELIVERY_EXTENSIONS = new Set([ - ".mp4", - ".mov", - ".webm", - ".pdf", - ".docx", - ".pptx", - ".xlsx", - ".md", - ".html", - ".json", - ".png", - ".jpg", - ".jpeg", - ".webp", - ".gif", - ".svg", -]); - export type XWorkmateArtifact = { relativePath: string; label: string; @@ -218,18 +182,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise 0 && scopedCandidates.length === 0 && adoptedCandidates.length === 0 - ? await adoptThreadWorkspaceDeliveryCandidatesIntoScope({ - workspaceRoot, - scopeRoot, - artifactScope, - sessionKey, - existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)), - warnings, - }) - : []; - const candidates = [...scopedCandidates, ...adoptedCandidates, ...threadDeliveryCandidates]; + const candidates = [...scopedCandidates, ...adoptedCandidates]; if (!scopePrepared && candidates.length === 0) { warnings.push("artifact scope is not prepared for this task run"); } @@ -497,167 +450,6 @@ async function adoptWorkspaceRootCandidatesIntoScope(input: { return adopted; } -async function adoptThreadWorkspaceDeliveryCandidatesIntoScope(input: { - workspaceRoot: string; - scopeRoot: string; - artifactScope: string; - sessionKey: string; - existingRelativePaths: Set; - warnings: string[]; -}): Promise { - const threadRoots = await resolveCurrentThreadWorkspaceRoots(input.workspaceRoot, input.sessionKey); - const adopted: Candidate[] = []; - for (const threadRoot of threadRoots) { - const candidates = await collectThreadDeliveryCandidates({ - scanRoot: threadRoot, - relativeRoot: threadRoot, - warnings: input.warnings, - }); - for (const candidate of candidates) { - 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 resolveCurrentThreadWorkspaceRoots(workspaceRoot: string, sessionKey: string): Promise { - const roots = new Set(); - const realWorkspaceRoot = await fs.realpath(workspaceRoot); - if (path.basename(realWorkspaceRoot) === sessionKey) { - roots.add(realWorkspaceRoot); - } - const ownerRoots = [ - path.join(realWorkspaceRoot, "owners", "local", "user"), - path.join(realWorkspaceRoot, "owners", "remote", "user"), - ]; - for (const ownerRoot of ownerRoots) { - if (!(await directoryExists(ownerRoot))) { - continue; - } - let ownerEntries; - try { - ownerEntries = await fs.readdir(ownerRoot, { withFileTypes: true }); - } catch { - continue; - } - for (const ownerEntry of ownerEntries) { - if (!ownerEntry.isDirectory()) { - continue; - } - const candidate = path.join(ownerRoot, ownerEntry.name, "threads", sessionKey); - if (!(await directoryExists(candidate))) { - continue; - } - const realCandidate = await fs.realpath(candidate); - if (isWithinRoot(realWorkspaceRoot, realCandidate)) { - roots.add(realCandidate); - } - } - } - return [...roots]; -} - -async function collectThreadDeliveryCandidates(input: { - scanRoot: string; - relativeRoot: string; - warnings: string[]; -}): Promise { - const candidates: Candidate[] = []; - await walk(input.scanRoot, []); - return candidates; - - async function walk(currentDir: string, segments: string[]): Promise { - let entries; - try { - entries = await fs.readdir(currentDir, { withFileTypes: true }); - } catch (error) { - input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`); - return; - } - entries.sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { - if (entry.name === "." || entry.name === "..") { - continue; - } - const absolutePath = path.join(currentDir, entry.name); - const relativeEntryPath = [...segments, entry.name].join("/"); - if (entry.isSymbolicLink()) { - const isDeliveryPath = - isThreadDeliveryFile(relativeEntryPath) || THREAD_DELIVERY_DIRS.has(segments[0] ?? entry.name); - if (isDeliveryPath) { - input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`); - } - continue; - } - if (entry.isDirectory()) { - if (segments.length === 0 && !THREAD_DELIVERY_DIRS.has(entry.name)) { - continue; - } - if (SKIPPED_DIRS.has(entry.name)) { - continue; - } - await walk(absolutePath, [...segments, entry.name]); - continue; - } - if (!entry.isFile()) { - continue; - } - const relativePath = safeRelativePath(input.relativeRoot, absolutePath); - if (!relativePath || !isThreadDeliveryFile(relativePath)) { - continue; - } - const stat = await fs.stat(absolutePath); - const realPath = await fs.realpath(absolutePath); - if (!isWithinRoot(input.relativeRoot, realPath)) { - input.warnings.push(`skipped path outside workspace ${entry.name}`); - continue; - } - candidates.push({ - absolutePath: realPath, - relativePath, - sizeBytes: stat.size, - mtimeMs: Math.max(stat.mtimeMs, stat.ctimeMs), - }); - } - } -} - -function isThreadDeliveryFile(relativePath: string): boolean { - const parts = relativePath.split("/"); - const fileName = parts[parts.length - 1] ?? ""; - if (parts.length === 1) { - return THREAD_DELIVERY_FILE_NAMES.has(fileName); - } - if (!THREAD_DELIVERY_DIRS.has(parts[0] ?? "")) { - return false; - } - return THREAD_DELIVERY_EXTENSIONS.has(path.extname(fileName).toLowerCase()); -} - async function collectCandidates(input: { scanRoot: string; relativeRoot: string;