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.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
18
dist/index.js
vendored
@ -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({
|
||||
|
||||
20
dist/src/exportArtifacts.d.ts
vendored
20
dist/src/exportArtifacts.d.ts
vendored
@ -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 {};
|
||||
|
||||
169
dist/src/exportArtifacts.js
vendored
169
dist/src/exportArtifacts.js
vendored
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
16
index.ts
16
index.ts
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user