diff --git a/README.md b/README.md index fd4645d..f3ad07a 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,21 @@ 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. +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. 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 @@ -205,6 +217,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. - 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 f7c3e84..2d1fbac 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -18,6 +18,32 @@ 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([ + "renders", + "render", + "exports", + "output", + "outputs", +]); +const THREAD_DELIVERY_EXTENSIONS = new Set([ + ".mp4", + ".mov", + ".webm", + ".pdf", + ".docx", + ".pptx", + ".xlsx", + ".md", + ".html", + ".json", +]); export async function prepareXWorkmateArtifacts(input) { const params = input.params ?? {}; const pluginConfig = input.pluginConfig ?? {}; @@ -86,6 +112,7 @@ export async function exportXWorkmateArtifacts(input) { relativeRoot: scopeRoot, sinceUnixMs, skipTaskScopeRoot: false, + warnSkippedSymlinks: true, warnings, }) : []; @@ -99,7 +126,17 @@ export async function exportXWorkmateArtifacts(input) { warnings, }) : []; - const candidates = [...scopedCandidates, ...adoptedCandidates]; + 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]; if (!scopePrepared && candidates.length === 0) { warnings.push("artifact scope is not prepared for this task run"); } @@ -302,6 +339,7 @@ async function adoptWorkspaceRootCandidatesIntoScope(input) { relativeRoot: input.workspaceRoot, sinceUnixMs: input.sinceUnixMs, skipTaskScopeRoot: true, + warnSkippedSymlinks: false, warnings: input.warnings, }); const adopted = []; @@ -334,6 +372,152 @@ 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); @@ -354,7 +538,9 @@ async function collectCandidates(input) { } const absolutePath = path.join(currentDir, entry.name); if (entry.isSymbolicLink()) { - input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`); + if (input.warnSkippedSymlinks) { + input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`); + } continue; } if (entry.isDirectory()) { @@ -589,6 +775,12 @@ function contentTypeForPath(relativePath) { return "image/gif"; case ".svg": return "image/svg+xml"; + case ".mp4": + return "video/mp4"; + case ".mov": + return "video/quicktime"; + case ".webm": + return "video/webm"; default: return "application/octet-stream"; } diff --git a/package.json b/package.json index 2978521..117e330 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-multi-session-plugins", - "version": "0.1.10", + "version": "0.1.11", "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 fd5b615..b0baa11 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -389,6 +389,104 @@ describe("exportXWorkmateArtifacts", () => { expect(result.warnings).toEqual([]); }); + it("adopts 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: { + sessionKey: "draft:1779524982823421-3", + runId: "turn-1779685283403237342", + }, + pluginConfig: { workspaceDir: root }, + }); + const threadRoot = path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:1779524982823421-3"); + await fs.mkdir(path.join(threadRoot, "renders"), { recursive: true }); + await fs.writeFile(path.join(threadRoot, "renders", "cloud-native-servicemesh-network.mp4"), "mp4"); + await fs.writeFile(path.join(threadRoot, "DELIVERY.md"), "delivered"); + await fs.writeFile(path.join(threadRoot, "scratch.txt"), "scratch"); + await fs.symlink(threadRoot, path.join(threadRoot, "venv")); + await fs.mkdir(path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:other", "renders"), { + recursive: true, + }); + await fs.writeFile( + path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:other", "renders", "other.mp4"), + "other", + ); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "draft:1779524982823421-3", + runId: "turn-1779685283403237342", + sinceUnixMs: Date.now() + 10_000, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifactScope).toBe("tasks/draft_1779524982823421-3/turn-1779685283403237342"); + expect(result.artifacts.map((entry) => entry.relativePath).sort()).toEqual([ + "DELIVERY.md", + "renders/cloud-native-servicemesh-network.mp4", + ]); + expect(result.artifacts.map((entry) => entry.contentType).sort()).toEqual([ + "text/markdown", + "video/mp4", + ]); + expect( + await fs.readFile( + path.join( + root, + "tasks", + "draft_1779524982823421-3", + "turn-1779685283403237342", + "renders", + "cloud-native-servicemesh-network.mp4", + ), + "utf8", + ), + ).toBe("mp4"); + 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")), + ).rejects.toThrow(); + }); + + it("does not adopt same-thread delivery files without a current-run timestamp", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + await prepareXWorkmateArtifacts({ + params: { + sessionKey: "draft:1779524982823421-3", + runId: "turn-1779685283403237342", + }, + pluginConfig: { workspaceDir: root }, + }); + const threadRoot = path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:1779524982823421-3"); + await fs.mkdir(path.join(threadRoot, "renders"), { recursive: true }); + await fs.writeFile(path.join(threadRoot, "renders", "cloud-native-servicemesh-network.mp4"), "mp4"); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "draft:1779524982823421-3", + runId: "turn-1779685283403237342", + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts).toEqual([]); + await expect( + fs.stat( + path.join( + root, + "tasks", + "draft_1779524982823421-3", + "turn-1779685283403237342", + "renders", + "cloud-native-servicemesh-network.mp4", + ), + ), + ).rejects.toThrow(); + }); + it("exports concurrent task scopes independently", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const prepared = await Promise.all([ diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index 98fb359..eefb229 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -21,6 +21,35 @@ 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([ + "renders", + "render", + "exports", + "output", + "outputs", +]); + +const THREAD_DELIVERY_EXTENSIONS = new Set([ + ".mp4", + ".mov", + ".webm", + ".pdf", + ".docx", + ".pptx", + ".xlsx", + ".md", + ".html", + ".json", +]); + export type XWorkmateArtifact = { relativePath: string; label: string; @@ -167,6 +196,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]; if (!scopePrepared && candidates.length === 0) { warnings.push("artifact scope is not prepared for this task run"); } @@ -415,6 +456,7 @@ async function adoptWorkspaceRootCandidatesIntoScope(input: { relativeRoot: input.workspaceRoot, sinceUnixMs: input.sinceUnixMs, skipTaskScopeRoot: true, + warnSkippedSymlinks: false, warnings: input.warnings, }); const adopted: Candidate[] = []; @@ -448,11 +490,173 @@ 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; sinceUnixMs: number; skipTaskScopeRoot: boolean; + warnSkippedSymlinks: boolean; warnings: string[]; }): Promise { const candidates: Candidate[] = []; @@ -474,7 +678,9 @@ async function collectCandidates(input: { } const absolutePath = path.join(currentDir, entry.name); if (entry.isSymbolicLink()) { - input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`); + if (input.warnSkippedSymlinks) { + input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`); + } continue; } if (entry.isDirectory()) { @@ -737,6 +943,12 @@ function contentTypeForPath(relativePath: string): string { return "image/gif"; case ".svg": return "image/svg+xml"; + case ".mp4": + return "video/mp4"; + case ".mov": + return "video/quicktime"; + case ".webm": + return "video/webm"; default: return "application/octet-stream"; }