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:
parent
3bc137be6b
commit
2695c38612
@ -195,7 +195,8 @@ Gateway clients can use:
|
|||||||
- `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table.
|
- `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 `artifactScope` and `relativePath` for one task file.
|
||||||
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned 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
|
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
|
generate its own signed download URL and call `xworkmate.artifacts.read` as the
|
||||||
|
|||||||
18
dist/index.js
vendored
18
dist/index.js
vendored
@ -1,5 +1,5 @@
|
|||||||
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
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";
|
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js";
|
||||||
function scopedGatewayParams(params) {
|
function scopedGatewayParams(params) {
|
||||||
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
|
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) => {
|
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts) => {
|
||||||
try {
|
try {
|
||||||
const payload = await exportXWorkmateArtifacts({
|
const payload = await exportXWorkmateArtifacts({
|
||||||
|
|||||||
20
dist/src/exportArtifacts.d.ts
vendored
20
dist/src/exportArtifacts.d.ts
vendored
@ -33,6 +33,18 @@ export type XWorkmateArtifactPrepare = {
|
|||||||
relativeArtifactDirectory: string;
|
relativeArtifactDirectory: string;
|
||||||
warnings: 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 = {
|
type ExportInput = {
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
config?: unknown;
|
config?: unknown;
|
||||||
@ -44,13 +56,7 @@ type ReadInput = {
|
|||||||
pluginConfig?: Record<string, unknown>;
|
pluginConfig?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
export declare function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactPrepare>;
|
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 exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>;
|
||||||
export declare function readXWorkmateArtifact(input: ReadInput): 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 {};
|
export {};
|
||||||
|
|||||||
169
dist/src/exportArtifacts.js
vendored
169
dist/src/exportArtifacts.js
vendored
@ -49,6 +49,73 @@ export async function prepareXWorkmateArtifacts(input) {
|
|||||||
warnings: [],
|
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) {
|
export async function exportXWorkmateArtifacts(input) {
|
||||||
const params = input.params ?? {};
|
const params = input.params ?? {};
|
||||||
const pluginConfig = input.pluginConfig ?? {};
|
const pluginConfig = input.pluginConfig ?? {};
|
||||||
@ -260,7 +327,7 @@ export async function readXWorkmateArtifact(input) {
|
|||||||
manifestMarkdown: formatArtifactManifestMarkdown(result),
|
manifestMarkdown: formatArtifactManifestMarkdown(result),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function formatArtifactManifestMarkdown(input) {
|
function formatArtifactManifestMarkdown(input) {
|
||||||
const lines = [
|
const lines = [
|
||||||
"## XWorkmate artifacts",
|
"## 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) {
|
async function loadArtifactIgnoreRules(scopeRoot, warnings) {
|
||||||
const rules = [{ kind: "exact", path: ARTIFACT_IGNORE_FILE }];
|
const rules = [{ kind: "exact", path: ARTIFACT_IGNORE_FILE }];
|
||||||
const ignorePath = path.join(scopeRoot, ARTIFACT_IGNORE_FILE);
|
const ignorePath = path.join(scopeRoot, ARTIFACT_IGNORE_FILE);
|
||||||
@ -648,6 +784,36 @@ function contentTypeForPath(relativePath) {
|
|||||||
return "application/octet-stream";
|
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) {
|
function objectRecord(value) {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? value
|
? value
|
||||||
@ -753,7 +919,6 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
|
|||||||
function artifactRefSigningSecret(pluginConfig) {
|
function artifactRefSigningSecret(pluginConfig) {
|
||||||
return (optionalString(pluginConfig.artifactRefSigningSecret) ||
|
return (optionalString(pluginConfig.artifactRefSigningSecret) ||
|
||||||
optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) ||
|
optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) ||
|
||||||
optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) ||
|
|
||||||
GENERATED_ARTIFACT_REF_SECRET);
|
GENERATED_ARTIFACT_REF_SECRET);
|
||||||
}
|
}
|
||||||
function workspaceRootHash(workspaceRoot) {
|
function workspaceRootHash(workspaceRoot) {
|
||||||
|
|||||||
@ -49,6 +49,7 @@ describe("plugin registration", () => {
|
|||||||
expect(methods.map((entry) => entry.method)).toEqual([
|
expect(methods.map((entry) => entry.method)).toEqual([
|
||||||
"xworkmate.artifacts.prepare",
|
"xworkmate.artifacts.prepare",
|
||||||
"xworkmate.artifacts.export",
|
"xworkmate.artifacts.export",
|
||||||
|
"xworkmate.artifacts.collect-and-snapshot",
|
||||||
"xworkmate.artifacts.list",
|
"xworkmate.artifacts.list",
|
||||||
"xworkmate.artifacts.read",
|
"xworkmate.artifacts.read",
|
||||||
"xworkmate.agents.run",
|
"xworkmate.agents.run",
|
||||||
|
|||||||
16
index.ts
16
index.ts
@ -5,6 +5,7 @@ import type {
|
|||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
||||||
import {
|
import {
|
||||||
|
collectAndSnapshotXWorkmateArtifacts,
|
||||||
exportXWorkmateArtifacts,
|
exportXWorkmateArtifacts,
|
||||||
prepareXWorkmateArtifacts,
|
prepareXWorkmateArtifacts,
|
||||||
readXWorkmateArtifact,
|
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) => {
|
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts: GatewayRequestHandlerOptions) => {
|
||||||
try {
|
try {
|
||||||
const payload = await exportXWorkmateArtifacts({
|
const payload = await exportXWorkmateArtifacts({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
collectAndSnapshotXWorkmateArtifacts,
|
||||||
exportXWorkmateArtifacts,
|
exportXWorkmateArtifacts,
|
||||||
prepareXWorkmateArtifacts,
|
prepareXWorkmateArtifacts,
|
||||||
readXWorkmateArtifact,
|
readXWorkmateArtifact,
|
||||||
@ -102,6 +103,62 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
expect(result.artifacts).toEqual([]);
|
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 () => {
|
it("skips excluded directories and symlinks", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||||
const prepared = await prepareXWorkmateArtifacts({
|
const prepared = await prepareXWorkmateArtifacts({
|
||||||
|
|||||||
@ -59,6 +59,19 @@ export type XWorkmateArtifactPrepare = {
|
|||||||
warnings: 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 = {
|
type ExportInput = {
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
config?: 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> {
|
export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport> {
|
||||||
const params = input.params ?? {};
|
const params = input.params ?? {};
|
||||||
const pluginConfig = input.pluginConfig ?? {};
|
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 =
|
type ArtifactIgnoreRule =
|
||||||
| { kind: "directory"; path: string }
|
| { kind: "directory"; path: string }
|
||||||
| { kind: "exact"; 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> {
|
function objectRecord(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user