Fix XWorkmate artifact export fallback

This commit is contained in:
Haitao Pan 2026-05-25 13:16:17 +08:00
parent 961e8391db
commit ccbad4934e
5 changed files with 523 additions and 8 deletions

View File

@ -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/<session>/<run>`. 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/<session>/<run>`.
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/<session>/<run>` 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/<safe-session-key>/<safe-run-id>` 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/<safe-session-key>/<safe-run-id>`.

View File

@ -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()) {
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";
}

View File

@ -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",

View File

@ -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([

View File

@ -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<XWor
relativeRoot: scopeRoot,
sinceUnixMs,
skipTaskScopeRoot: false,
warnSkippedSymlinks: true,
warnings,
})
: [];
@ -181,7 +211,18 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
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");
}
@ -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<string>;
warnings: string[];
}): Promise<Candidate[]> {
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<string[]> {
const roots = new Set<string>();
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<Candidate[]> {
const candidates: Candidate[] = [];
await walk(input.scanRoot, []);
return candidates;
async function walk(currentDir: string, segments: string[]): Promise<void> {
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<Candidate[]> {
const candidates: Candidate[] = [];
@ -474,7 +678,9 @@ async function collectCandidates(input: {
}
const absolutePath = path.join(currentDir, entry.name);
if (entry.isSymbolicLink()) {
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";
}