Adapt plugin to OpenClaw 2026.5.28

This commit is contained in:
Haitao Pan 2026-06-01 10:54:17 +08:00
parent 260380531b
commit 9bc52e7861
9 changed files with 393 additions and 3227 deletions

View File

@ -67,10 +67,11 @@ Equivalent config shape for a linked checkout:
## Contract
Prepare request params are supplied by the OpenClaw host, bridge, or APP
runtime. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the
trusted mapping into OpenClaw's built-in multi-session model; it does not parse
paths from chat text and does not invent fallback session/run identities.
Gateway methods accept these fields from bridge/app runtime params. The optional
runtime. On OpenClaw runtimes that expose a trusted plugin `sessionScope`, the
plugin uses that scope first. Otherwise it falls back to bridge/app runtime
params. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the
mapping into OpenClaw's built-in multi-session model; it does not parse paths
from chat text and does not invent fallback session/run identities. The optional
agent tool does not expose these fields to the model; it only uses host-injected
tool context.
@ -139,15 +140,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 `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>`.
derived from `sessionKey/runId`. `sinceUnixMs` is only a filter inside that task
scope. The plugin does not adopt files from the workspace root; agents must
write final deliverables directly into the prepared `artifactDirectory`.
Without `sinceUnixMs`, export/list only reads the current task scope. The plugin
never scans `tasks/`, `owners/*/threads/*`, or any previous thread workspace as
a fallback and does not borrow artifacts from earlier task scopes.
The plugin never scans workspace root, `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
@ -205,9 +204,9 @@ only remote file access path.
## Limits
- 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.
- 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.
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, transient framework state, and dependency folders are excluded from task artifact exports.
- `dist/`, `build/`, and other delivery directories inside the prepared task scope are exported recursively.
- Export never adopts files from the workspace root or OpenClaw owner/thread workspaces; agents must write into the prepared task scope.
- 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>`.

47
dist/index.js vendored
View File

@ -1,5 +1,34 @@
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js";
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js";
function scopedGatewayParams(params) {
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
const runScope = resolveRunScope({ sessionScope });
if (!runScope) {
return params;
}
return {
...params,
sessionKey: runScope.sessionKey,
runId: runScope.runId,
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
};
}
function resolveRunScope(ctx) {
const scope = ctx.sessionScope;
const sessionKey = scope?.sessionKey || ctx.sessionKey;
const runId = scope?.runId || ctx.runId || "";
if (!sessionKey || !runId) {
return undefined;
}
return {
sessionKey,
runId,
...(scope?.workspaceDir || ctx.workspaceDir ? { workspaceDir: scope?.workspaceDir || ctx.workspaceDir } : {}),
...(scope?.relativeTaskDirectory ? { artifactScope: scope.relativeTaskDirectory } : {}),
};
}
const plugin = {
id: "openclaw-multi-session-plugins",
name: "openclaw-multi-session-plugins",
@ -11,7 +40,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts) => {
try {
const payload = await prepareXWorkmateArtifacts({
params: opts.params,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -27,7 +56,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.export", async (opts) => {
try {
const payload = await exportXWorkmateArtifacts({
params: opts.params,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -43,7 +72,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts) => {
try {
const payload = await exportXWorkmateArtifacts({
params: { ...opts.params, includeContent: false },
params: { ...scopedGatewayParams(opts.params), includeContent: false },
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -59,7 +88,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.read", async (opts) => {
try {
const payload = await readXWorkmateArtifact({
params: opts.params,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -75,7 +104,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.agents.run", async (opts) => {
try {
const payload = await runXWorkmateBridgeAgents({
params: opts.params,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -140,21 +169,23 @@ function createXWorkmateArtifactsTool(api, ctx) {
},
async execute(_id, params) {
const action = typeof params.action === "string" ? params.action : "";
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
const baseParams = {
...operationParams,
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
};
if (action === "list") {
const payload = await exportXWorkmateArtifacts({
@ -241,15 +272,16 @@ function createXWorkmateAgentsTool(api, ctx) {
required: ["taskPrompt"],
},
async execute(_id, params) {
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
const payload = await runXWorkmateBridgeAgents({
params: {
@ -257,6 +289,7 @@ function createXWorkmateAgentsTool(api, ctx) {
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
},
config: ctx.config ?? api.config,
pluginConfig: api.pluginConfig,

View File

@ -14,8 +14,6 @@ const SKIPPED_DIRS = new Set([
".dart_tool",
".next",
".turbo",
"build",
"dist",
"node_modules",
]);
export async function prepareXWorkmateArtifacts(input) {
@ -85,22 +83,11 @@ export async function exportXWorkmateArtifacts(input) {
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs,
skipTaskScopeRoot: false,
warnSkippedSymlinks: true,
warnings,
})
: [];
const adoptedCandidates = sinceUnixMs > 0
? await adoptWorkspaceRootCandidatesIntoScope({
workspaceRoot,
scopeRoot,
artifactScope,
sinceUnixMs,
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
warnings,
})
: [];
const candidates = [...scopedCandidates, ...adoptedCandidates];
const candidates = scopedCandidates;
if (!scopePrepared && candidates.length === 0) {
warnings.push("artifact scope is not prepared for this task run");
}
@ -297,45 +284,6 @@ 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,
warnSkippedSymlinks: false,
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);
@ -362,9 +310,6 @@ async function collectCandidates(input) {
continue;
}
if (entry.isDirectory()) {
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
continue;
}
if (SKIPPED_DIRS.has(entry.name)) {
continue;
}
@ -419,6 +364,7 @@ function safeScopeSegment(value) {
.trim()
.replace(/[\\/]+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 96) || "scope";
}
@ -468,15 +414,6 @@ 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);

View File

@ -339,7 +339,15 @@ describe("plugin registration", () => {
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>;
};
const tool = factory({ sessionKey: "thread-main", runId: "turn-1", workspaceDir: root });
const tool = factory({
sessionScope: {
scopeKind: "run",
sessionKey: "thread-main",
runId: "turn-1",
workspaceDir: root,
relativeTaskDirectory: "tasks/thread-main/turn-1",
},
});
const result = await tool.execute("call-1", {
action: "list",
sessionKey: "thread-other",

View File

@ -3,6 +3,7 @@ import type {
GatewayRequestHandlerOptions,
OpenClawPluginApi,
} from "openclaw/plugin-sdk/core";
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import {
exportXWorkmateArtifacts,
prepareXWorkmateArtifacts,
@ -15,13 +16,63 @@ type XWorkmateToolContext = {
workspaceDir?: string;
sessionKey?: string;
runId?: string;
sessionScope?: {
sessionScope?: XWorkmatePluginSessionScope;
};
type XWorkmatePluginSessionScope = {
scopeKind?: "global" | "session" | "run";
sessionKey?: string;
runId?: string;
workspaceDir?: string;
};
relativeTaskDirectory?: string;
};
type XWorkmateResolvedRunScope = {
sessionKey: string;
runId: string;
workspaceDir?: string;
artifactScope?: string;
};
type XWorkmateGatewayRequestScope = {
sessionScope?: XWorkmatePluginSessionScope;
};
function scopedGatewayParams(params: Record<string, unknown>): Record<string, unknown> {
const sessionScope = (getPluginRuntimeGatewayRequestScope() as XWorkmateGatewayRequestScope | undefined)?.sessionScope;
const runScope = resolveRunScope({ sessionScope });
if (!runScope) {
return params;
}
return {
...params,
sessionKey: runScope.sessionKey,
runId: runScope.runId,
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
};
}
function resolveRunScope(ctx: {
sessionScope?: XWorkmatePluginSessionScope;
sessionKey?: string;
runId?: string;
workspaceDir?: string;
}): XWorkmateResolvedRunScope | undefined {
const scope = ctx.sessionScope;
const sessionKey = scope?.sessionKey || ctx.sessionKey;
const runId = scope?.runId || ctx.runId || "";
if (!sessionKey || !runId) {
return undefined;
}
return {
sessionKey,
runId,
...(scope?.workspaceDir || ctx.workspaceDir ? { workspaceDir: scope?.workspaceDir || ctx.workspaceDir } : {}),
...(scope?.relativeTaskDirectory ? { artifactScope: scope.relativeTaskDirectory } : {}),
};
}
const plugin = {
id: "openclaw-multi-session-plugins",
name: "openclaw-multi-session-plugins",
@ -35,7 +86,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await prepareXWorkmateArtifacts({
params: opts.params,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -50,7 +101,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.artifacts.export", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await exportXWorkmateArtifacts({
params: opts.params,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -65,7 +116,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await exportXWorkmateArtifacts({
params: { ...opts.params, includeContent: false },
params: { ...scopedGatewayParams(opts.params), includeContent: false },
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -80,7 +131,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.artifacts.read", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await readXWorkmateArtifact({
params: opts.params,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -95,7 +146,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.agents.run", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await runXWorkmateBridgeAgents({
params: opts.params,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
@ -164,15 +215,16 @@ function createXWorkmateArtifactsTool(
},
async execute(_id: string, params: Record<string, unknown>) {
const action = typeof params.action === "string" ? params.action : "";
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const {
sessionKey: _ignoredSessionKey,
runId: _ignoredRunId,
@ -184,6 +236,7 @@ function createXWorkmateArtifactsTool(
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
};
if (action === "list") {
const payload = await exportXWorkmateArtifacts({
@ -275,15 +328,16 @@ function createXWorkmateAgentsTool(
required: ["taskPrompt"],
},
async execute(_id: string, params: Record<string, unknown>) {
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const {
sessionKey: _ignoredSessionKey,
runId: _ignoredRunId,
@ -296,6 +350,7 @@ function createXWorkmateAgentsTool(
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
},
config: ctx.config ?? api.config,
pluginConfig: api.pluginConfig,

View File

@ -1,6 +1,6 @@
{
"name": "openclaw-multi-session-plugins",
"version": "0.1.13",
"version": "0.1.14",
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
"type": "module",
"license": "MIT",
@ -44,10 +44,9 @@
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^24.10.1",
"openclaw": "2026.5.3-1",
"openclaw": "2026.5.28",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},

3197
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,17 @@ describe("exportXWorkmateArtifacts", () => {
expect(first.scopeKind).toBe("task");
});
it("normalizes task scope segments like the OpenClaw session scope runtime", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "agent::main:main", runId: "run alpha" },
pluginConfig: { workspaceDir: root },
});
expect(prepared.artifactScope).toBe("tasks/agent_main_main/run_alpha");
});
it("exports changed files with metadata and base64 content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
@ -149,6 +160,32 @@ describe("exportXWorkmateArtifacts", () => {
});
});
it("exports nested dist and build deliverables inside the task scope", 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" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "dist"), { recursive: true });
await fs.mkdir(path.join(prepared.artifactDirectory, "build", "assets"), { recursive: true });
await fs.writeFile(path.join(prepared.artifactDirectory, "dist", "final.pdf"), "pdf");
await fs.writeFile(path.join(prepared.artifactDirectory, "build", "assets", "cover.png"), "png");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
maxInlineBytes: 0,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([
"build/assets/cover.png",
"dist/final.pdf",
]);
});
it("uses the current task scope when artifactScope is omitted", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const current = await prepareXWorkmateArtifacts({
@ -198,63 +235,7 @@ 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("adopts root Word documents into only the current task scope", async () => {
it("does not adopt workspace root files even with a current-run timestamp", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const sinceUnixMs = Date.now() - 1_000;
await fs.writeFile(path.join(root, "article.docx"), "docx-content");
@ -270,18 +251,8 @@ describe("exportXWorkmateArtifacts", () => {
});
expect(result.artifactScope).toBe("tasks/draft-article/openclaw-run-1");
expect(result.artifacts).toHaveLength(1);
expect(result.artifacts[0]).toMatchObject({
relativePath: "article.docx",
artifactScope: "tasks/draft-article/openclaw-run-1",
scopeKind: "task",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
expect(result.artifacts[0]?.encoding).toBeUndefined();
expect(await fs.readFile(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"), "utf8")).toBe(
"docx-content",
);
await expect(fs.stat(path.join(root, "tasks", "draft-article", "turn-1", "article.docx"))).rejects.toThrow();
expect(result.artifacts).toEqual([]);
await expect(fs.stat(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"))).rejects.toThrow();
});
it("does not adopt old workspace root files into a later task scope", async () => {

View File

@ -16,8 +16,6 @@ const SKIPPED_DIRS = new Set([
".dart_tool",
".next",
".turbo",
"build",
"dist",
"node_modules",
]);
@ -166,23 +164,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs,
skipTaskScopeRoot: false,
warnSkippedSymlinks: true,
warnings,
})
: [];
const adoptedCandidates =
sinceUnixMs > 0
? await adoptWorkspaceRootCandidatesIntoScope({
workspaceRoot,
scopeRoot,
artifactScope,
sinceUnixMs,
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
warnings,
})
: [];
const candidates = [...scopedCandidates, ...adoptedCandidates];
const candidates = scopedCandidates;
if (!scopePrepared && candidates.length === 0) {
warnings.push("artifact scope is not prepared for this task run");
}
@ -403,58 +389,10 @@ export function formatArtifactManifestMarkdown(input: {
return lines.join("\n");
}
async function adoptWorkspaceRootCandidatesIntoScope(input: {
workspaceRoot: string;
scopeRoot: string;
artifactScope: string;
sinceUnixMs: number;
existingRelativePaths: Set<string>;
warnings: string[];
}): Promise<Candidate[]> {
const rootCandidates = await collectCandidates({
scanRoot: input.workspaceRoot,
relativeRoot: input.workspaceRoot,
sinceUnixMs: input.sinceUnixMs,
skipTaskScopeRoot: true,
warnSkippedSymlinks: false,
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;
sinceUnixMs: number;
skipTaskScopeRoot: boolean;
warnSkippedSymlinks: boolean;
warnings: string[];
}): Promise<Candidate[]> {
@ -483,9 +421,6 @@ async function collectCandidates(input: {
continue;
}
if (entry.isDirectory()) {
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
continue;
}
if (SKIPPED_DIRS.has(entry.name)) {
continue;
}
@ -552,6 +487,7 @@ function safeScopeSegment(value: string): string {
.trim()
.replace(/[\\/]+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 96) || "scope";
}
@ -604,15 +540,6 @@ async function directoryExists(absolutePath: string): Promise<boolean> {
}
}
async function fileExists(absolutePath: string): Promise<boolean> {
try {
const stat = await fs.stat(absolutePath);
return stat.isFile();
} catch {
return false;
}
}
function safeArtifactRefRunScope(value: unknown): string {
try {
return safeTaskArtifactScope(value);