fix(artifacts): remove thread workspace adoption

This commit is contained in:
Haitao Pan 2026-05-29 13:32:15 +08:00
parent c8f379847b
commit 260380531b
5 changed files with 13 additions and 447 deletions

View File

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

View File

@ -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);

View File

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

View File

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

View File

@ -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<XWor
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");
}
@ -497,167 +450,6 @@ 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;