feat(artifacts): add xworkmate.artifacts.collect-and-snapshot gateway method

Adds a new gateway method that copies recent outputs from the OpenClaw media
and tmp directories into the current task scope's artifacts directory,
returning a snapshot manifest. XWorkmate Bridge can then call the existing
xworkmate.artifacts.export to hand the snapshot to the XWorkmate APP.
This commit is contained in:
Haitao Pan 2026-06-05 11:50:53 +08:00
parent 3bc137be6b
commit 2695c38612
8 changed files with 466 additions and 11 deletions

View File

@ -195,7 +195,8 @@ Gateway clients can use:
- `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table.
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file.
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task file.
- `xworkmate.artifacts.export` with `artifactScope` after `agent.wait` for the XWorkmate APP sync path.
- `xworkmate.artifacts.collect-and-snapshot` after `agent.wait` to copy `~/.openclaw/media/` and `/tmp/openclaw/` outputs into the current task scope.
- `xworkmate.artifacts.export` with `artifactScope` after collect-and-snapshot for the XWorkmate APP sync path.
Large files are metadata-only in the export payload, but XWorkmate Bridge can
generate its own signed download URL and call `xworkmate.artifacts.read` as the

18
dist/index.js vendored
View File

@ -1,5 +1,5 @@
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js";
import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js";
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js";
function scopedGatewayParams(params) {
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
@ -69,6 +69,22 @@ function register(api) {
});
}
});
api.registerGatewayMethod("xworkmate.artifacts.collect-and-snapshot", async (opts) => {
try {
const payload = await collectAndSnapshotXWorkmateArtifacts({
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
opts.respond(true, payload, undefined);
}
catch (error) {
opts.respond(false, undefined, {
code: "INVALID_REQUEST",
message: error instanceof Error ? error.message : String(error),
});
}
});
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts) => {
try {
const payload = await exportXWorkmateArtifacts({

View File

@ -33,6 +33,18 @@ export type XWorkmateArtifactPrepare = {
relativeArtifactDirectory: string;
warnings: string[];
};
export type XWorkmateArtifactSnapshot = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
remoteWorkspaceRefKind: "remotePath";
artifactScope: string;
scopeKind: "task";
artifactDirectory: string;
snapshotDirectory: string;
copiedFiles: string[];
warnings: string[];
};
type ExportInput = {
params: Record<string, unknown>;
config?: unknown;
@ -44,13 +56,7 @@ type ReadInput = {
pluginConfig?: Record<string, unknown>;
};
export declare function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactPrepare>;
export declare function collectAndSnapshotXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactSnapshot>;
export declare function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>;
export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>;
export declare function formatArtifactManifestMarkdown(input: {
remoteWorkingDirectory: string;
artifactScope?: string;
scopeKind?: XWorkmateArtifactScopeKind;
artifacts: XWorkmateArtifact[];
warnings: string[];
}): string;
export {};

View File

@ -49,6 +49,73 @@ export async function prepareXWorkmateArtifacts(input) {
warnings: [],
};
}
export async function collectAndSnapshotXWorkmateArtifacts(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.snapshotMaxFiles, DEFAULT_MAX_FILES);
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
throw new Error("artifactScope does not match sessionKey/runId");
}
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
params,
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const artifactScope = requestedArtifactScope || expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
const snapshotRoot = path.join(scopeRoot, "artifacts");
if (!isWithinRoot(scopeRoot, snapshotRoot)) {
throw new Error("snapshotDirectory must stay inside artifactScope");
}
await fs.mkdir(snapshotRoot, { recursive: true });
const warnings = [];
const copiedFiles = [];
for (const source of openClawSnapshotSources(params, pluginConfig)) {
if (copiedFiles.length >= maxFiles) {
warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`);
break;
}
const candidates = await collectSnapshotSourceCandidates({
source,
sinceUnixMs,
warnings,
});
for (const candidate of candidates) {
if (copiedFiles.length >= maxFiles) {
warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`);
break;
}
const destinationRelativePath = safeSnapshotDestinationRelativePath(source.label, candidate.relativePath);
const destination = path.join(snapshotRoot, destinationRelativePath.split("/").join(path.sep));
if (!isWithinRoot(snapshotRoot, destination)) {
warnings.push(`skipped unsafe snapshot path ${destinationRelativePath}`);
continue;
}
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.copyFile(candidate.absolutePath, destination);
copiedFiles.push(`artifacts/${destinationRelativePath}`);
}
}
return {
runId,
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath",
artifactScope,
scopeKind: "task",
artifactDirectory: scopeRoot,
snapshotDirectory: snapshotRoot,
copiedFiles,
warnings,
};
}
export async function exportXWorkmateArtifacts(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
@ -260,7 +327,7 @@ export async function readXWorkmateArtifact(input) {
manifestMarkdown: formatArtifactManifestMarkdown(result),
};
}
export function formatArtifactManifestMarkdown(input) {
function formatArtifactManifestMarkdown(input) {
const lines = [
"## XWorkmate artifacts",
"",
@ -347,6 +414,75 @@ async function collectCandidates(input) {
}
}
}
async function collectSnapshotSourceCandidates(input) {
let sourceRoot = "";
try {
sourceRoot = await fs.realpath(input.source.root);
}
catch (error) {
if (error?.code !== "ENOENT") {
input.warnings.push(`cannot read ${input.source.label}: ${String(error)}`);
}
return [];
}
const candidates = [];
await walk(sourceRoot);
candidates.sort((left, right) => {
if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs;
}
return left.relativePath.localeCompare(right.relativePath);
});
return candidates;
async function walk(currentDir) {
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
}
catch (error) {
input.warnings.push(`cannot read ${input.source.label}/${safeDisplayPath(sourceRoot, 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);
if (entry.isSymbolicLink()) {
input.warnings.push(`skipped symlink ${input.source.label}/${safeDisplayPath(sourceRoot, absolutePath)}`);
continue;
}
if (entry.isDirectory()) {
await walk(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
const stat = await fs.stat(absolutePath);
const changedAtMs = Math.max(stat.mtimeMs, stat.ctimeMs);
if (changedAtMs < input.sinceUnixMs) {
continue;
}
const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(sourceRoot, realPath)) {
input.warnings.push(`skipped path outside ${input.source.label}: ${entry.name}`);
continue;
}
const relativePath = safeRelativePath(sourceRoot, realPath);
if (!relativePath) {
continue;
}
candidates.push({
absolutePath: realPath,
relativePath,
sizeBytes: stat.size,
mtimeMs: changedAtMs,
});
}
}
}
async function loadArtifactIgnoreRules(scopeRoot, warnings) {
const rules = [{ kind: "exact", path: ARTIFACT_IGNORE_FILE }];
const ignorePath = path.join(scopeRoot, ARTIFACT_IGNORE_FILE);
@ -648,6 +784,36 @@ function contentTypeForPath(relativePath) {
return "application/octet-stream";
}
}
function openClawSnapshotSources(params, pluginConfig) {
const configured = Array.isArray(params.snapshotSourceRoots)
? params.snapshotSourceRoots
: Array.isArray(pluginConfig.snapshotSourceRoots)
? pluginConfig.snapshotSourceRoots
: undefined;
if (configured) {
return configured
.map((entry, index) => {
const record = objectRecord(entry);
const root = optionalString(record.root);
const label = safeScopeSegment(optionalString(record.label) || `source-${index + 1}`);
return root ? { label, root: expandUserPath(root) } : undefined;
})
.filter((entry) => Boolean(entry));
}
return [
{
label: "media",
root: expandUserPath(optionalString(pluginConfig.openClawMediaDir) || path.join("~", ".openclaw", "media")),
},
{
label: "tmp-openclaw",
root: expandUserPath(optionalString(pluginConfig.openClawTmpDir) || path.join(os.tmpdir(), "openclaw")),
},
];
}
function safeSnapshotDestinationRelativePath(sourceLabel, sourceRelativePath) {
return [safeScopeSegment(sourceLabel), safeInputRelativePath(sourceRelativePath, "snapshot source path")].join("/");
}
function objectRecord(value) {
return value && typeof value === "object" && !Array.isArray(value)
? value
@ -753,7 +919,6 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
function artifactRefSigningSecret(pluginConfig) {
return (optionalString(pluginConfig.artifactRefSigningSecret) ||
optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) ||
optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) ||
GENERATED_ARTIFACT_REF_SECRET);
}
function workspaceRootHash(workspaceRoot) {

View File

@ -49,6 +49,7 @@ describe("plugin registration", () => {
expect(methods.map((entry) => entry.method)).toEqual([
"xworkmate.artifacts.prepare",
"xworkmate.artifacts.export",
"xworkmate.artifacts.collect-and-snapshot",
"xworkmate.artifacts.list",
"xworkmate.artifacts.read",
"xworkmate.agents.run",

View File

@ -5,6 +5,7 @@ import type {
} from "openclaw/plugin-sdk/core";
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import {
collectAndSnapshotXWorkmateArtifacts,
exportXWorkmateArtifacts,
prepareXWorkmateArtifacts,
readXWorkmateArtifact,
@ -113,6 +114,21 @@ function register(api: OpenClawPluginApi) {
});
}
});
api.registerGatewayMethod("xworkmate.artifacts.collect-and-snapshot", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await collectAndSnapshotXWorkmateArtifacts({
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
opts.respond(true, payload, undefined);
} catch (error) {
opts.respond(false, undefined, {
code: "INVALID_REQUEST",
message: error instanceof Error ? error.message : String(error),
});
}
});
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await exportXWorkmateArtifacts({

View File

@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
collectAndSnapshotXWorkmateArtifacts,
exportXWorkmateArtifacts,
prepareXWorkmateArtifacts,
readXWorkmateArtifact,
@ -102,6 +103,62 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.artifacts).toEqual([]);
});
it("snapshots OpenClaw media and tmp outputs into the current task artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const mediaRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-media-"));
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-global-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(mediaRoot, "browser"), { recursive: true });
await fs.mkdir(path.join(tmpRoot, "renders"), { recursive: true });
const oldFile = path.join(mediaRoot, "browser", "old.png");
await fs.writeFile(oldFile, "old");
const snapshotSinceUnixMs = Date.now() + 20;
await new Promise((resolve) => setTimeout(resolve, 30));
await fs.writeFile(path.join(mediaRoot, "browser", "current.png"), "png");
await fs.writeFile(path.join(tmpRoot, "renders", "final.mp4"), "mp4");
const snapshot = await collectAndSnapshotXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "run-1",
artifactScope: prepared.artifactScope,
sinceUnixMs: snapshotSinceUnixMs,
},
pluginConfig: {
workspaceDir: root,
snapshotSourceRoots: [
{ label: "media", root: mediaRoot },
{ label: "tmp-openclaw", root: tmpRoot },
],
},
});
expect(snapshot.artifactScope).toBe(prepared.artifactScope);
expect(snapshot.copiedFiles.sort()).toEqual([
"artifacts/media/browser/current.png",
"artifacts/tmp-openclaw/renders/final.mp4",
]);
await expect(fs.stat(path.join(prepared.artifactDirectory, "artifacts", "media", "browser", "old.png"))).rejects.toThrow();
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "run-1",
artifactScope: prepared.artifactScope,
includeContent: false,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts.map((artifact) => artifact.relativePath).sort()).toEqual([
"artifacts/media/browser/current.png",
"artifacts/tmp-openclaw/renders/final.mp4",
]);
});
it("skips excluded directories and symlinks", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({

View File

@ -59,6 +59,19 @@ export type XWorkmateArtifactPrepare = {
warnings: string[];
};
export type XWorkmateArtifactSnapshot = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
remoteWorkspaceRefKind: "remotePath";
artifactScope: string;
scopeKind: "task";
artifactDirectory: string;
snapshotDirectory: string;
copiedFiles: string[];
warnings: string[];
};
type ExportInput = {
params: Record<string, unknown>;
config?: unknown;
@ -125,6 +138,76 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
};
}
export async function collectAndSnapshotXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactSnapshot> {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.snapshotMaxFiles, DEFAULT_MAX_FILES);
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
throw new Error("artifactScope does not match sessionKey/runId");
}
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
params,
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const artifactScope = requestedArtifactScope || expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
const snapshotRoot = path.join(scopeRoot, "artifacts");
if (!isWithinRoot(scopeRoot, snapshotRoot)) {
throw new Error("snapshotDirectory must stay inside artifactScope");
}
await fs.mkdir(snapshotRoot, { recursive: true });
const warnings: string[] = [];
const copiedFiles: string[] = [];
for (const source of openClawSnapshotSources(params, pluginConfig)) {
if (copiedFiles.length >= maxFiles) {
warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`);
break;
}
const candidates = await collectSnapshotSourceCandidates({
source,
sinceUnixMs,
warnings,
});
for (const candidate of candidates) {
if (copiedFiles.length >= maxFiles) {
warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`);
break;
}
const destinationRelativePath = safeSnapshotDestinationRelativePath(source.label, candidate.relativePath);
const destination = path.join(snapshotRoot, destinationRelativePath.split("/").join(path.sep));
if (!isWithinRoot(snapshotRoot, destination)) {
warnings.push(`skipped unsafe snapshot path ${destinationRelativePath}`);
continue;
}
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.copyFile(candidate.absolutePath, destination);
copiedFiles.push(`artifacts/${destinationRelativePath}`);
}
}
return {
runId,
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath",
artifactScope,
scopeKind: "task",
artifactDirectory: scopeRoot,
snapshotDirectory: snapshotRoot,
copiedFiles,
warnings,
};
}
export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport> {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
@ -460,6 +543,84 @@ async function collectCandidates(input: {
}
}
type SnapshotSource = {
label: string;
root: string;
};
async function collectSnapshotSourceCandidates(input: {
source: SnapshotSource;
sinceUnixMs: number;
warnings: string[];
}): Promise<Candidate[]> {
let sourceRoot = "";
try {
sourceRoot = await fs.realpath(input.source.root);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
input.warnings.push(`cannot read ${input.source.label}: ${String(error)}`);
}
return [];
}
const candidates: Candidate[] = [];
await walk(sourceRoot);
candidates.sort((left, right) => {
if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs;
}
return left.relativePath.localeCompare(right.relativePath);
});
return candidates;
async function walk(currentDir: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch (error) {
input.warnings.push(`cannot read ${input.source.label}/${safeDisplayPath(sourceRoot, 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);
if (entry.isSymbolicLink()) {
input.warnings.push(`skipped symlink ${input.source.label}/${safeDisplayPath(sourceRoot, absolutePath)}`);
continue;
}
if (entry.isDirectory()) {
await walk(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
const stat = await fs.stat(absolutePath);
const changedAtMs = Math.max(stat.mtimeMs, stat.ctimeMs);
if (changedAtMs < input.sinceUnixMs) {
continue;
}
const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(sourceRoot, realPath)) {
input.warnings.push(`skipped path outside ${input.source.label}: ${entry.name}`);
continue;
}
const relativePath = safeRelativePath(sourceRoot, realPath);
if (!relativePath) {
continue;
}
candidates.push({
absolutePath: realPath,
relativePath,
sizeBytes: stat.size,
mtimeMs: changedAtMs,
});
}
}
}
type ArtifactIgnoreRule =
| { kind: "directory"; path: string }
| { kind: "exact"; path: string }
@ -799,6 +960,38 @@ function contentTypeForPath(relativePath: string): string {
}
}
function openClawSnapshotSources(params: Record<string, unknown>, pluginConfig: Record<string, unknown>): SnapshotSource[] {
const configured = Array.isArray(params.snapshotSourceRoots)
? params.snapshotSourceRoots
: Array.isArray(pluginConfig.snapshotSourceRoots)
? pluginConfig.snapshotSourceRoots
: undefined;
if (configured) {
return configured
.map((entry, index) => {
const record = objectRecord(entry);
const root = optionalString(record.root);
const label = safeScopeSegment(optionalString(record.label) || `source-${index + 1}`);
return root ? { label, root: expandUserPath(root) } : undefined;
})
.filter((entry): entry is SnapshotSource => Boolean(entry));
}
return [
{
label: "media",
root: expandUserPath(optionalString(pluginConfig.openClawMediaDir) || path.join("~", ".openclaw", "media")),
},
{
label: "tmp-openclaw",
root: expandUserPath(optionalString(pluginConfig.openClawTmpDir) || path.join(os.tmpdir(), "openclaw")),
},
];
}
function safeSnapshotDestinationRelativePath(sourceLabel: string, sourceRelativePath: string): string {
return [safeScopeSegment(sourceLabel), safeInputRelativePath(sourceRelativePath, "snapshot source path")].join("/");
}
function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)