Adopt OpenClaw root artifacts into task scope
This commit is contained in:
parent
29e09701b8
commit
b68fc811c9
12
README.md
12
README.md
@ -139,10 +139,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 that scope has no files, export/list returns
|
||||
an empty artifact list with the manifest text `No artifacts found for this task
|
||||
run.` The plugin does not scan the workspace root and does not borrow artifacts
|
||||
from earlier task scopes.
|
||||
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>`. Without `sinceUnixMs`, export/list only reads the
|
||||
current task scope. The plugin never scans `tasks/` as a fallback and does not
|
||||
borrow artifacts from earlier task scopes.
|
||||
|
||||
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
|
||||
@ -191,6 +194,7 @@ only remote file access path.
|
||||
|
||||
- Only files inside the resolved OpenClaw workspace are exported.
|
||||
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are excluded from task artifact exports.
|
||||
- Workspace-root files are adopted only when `sinceUnixMs` is provided; adopted files are copied into the current `tasks/<safe-session-key>/<safe-run-id>` scope before listing or reading.
|
||||
- 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>`.
|
||||
|
||||
65
dist/src/exportArtifacts.js
vendored
65
dist/src/exportArtifacts.js
vendored
@ -77,7 +77,10 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
const scopeKind = "task";
|
||||
const scopePrepared = await directoryExists(scopeRoot);
|
||||
const candidates = scopePrepared
|
||||
if (!scopePrepared && sinceUnixMs > 0) {
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
}
|
||||
const scopedCandidates = (await directoryExists(scopeRoot))
|
||||
? await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
@ -86,7 +89,18 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
if (!scopePrepared) {
|
||||
const adoptedCandidates = sinceUnixMs > 0
|
||||
? await adoptWorkspaceRootCandidatesIntoScope({
|
||||
workspaceRoot,
|
||||
scopeRoot,
|
||||
artifactScope,
|
||||
sinceUnixMs,
|
||||
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
const candidates = [...scopedCandidates, ...adoptedCandidates];
|
||||
if (!scopePrepared && candidates.length === 0) {
|
||||
warnings.push("artifact scope is not prepared for this task run");
|
||||
}
|
||||
candidates.sort((left, right) => {
|
||||
@ -282,6 +296,44 @@ 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,
|
||||
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);
|
||||
@ -412,6 +464,15 @@ 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);
|
||||
|
||||
@ -176,7 +176,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["current.txt"]);
|
||||
});
|
||||
|
||||
it("does not scan the workspace root when the current task scope is empty", async () => {
|
||||
it("does not scan the workspace root without a current-run timestamp", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
@ -198,6 +198,84 @@ 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("does not adopt old workspace root files into a later task 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 },
|
||||
});
|
||||
await fs.writeFile(path.join(root, "old-root.md"), "old");
|
||||
const stat = await fs.stat(path.join(root, "old-root.md"));
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
sinceUnixMs: stat.mtimeMs + 10_000,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.artifacts).toEqual([]);
|
||||
await expect(fs.stat(path.join(root, "tasks", "thread-main", "turn-1", "old-root.md"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects scoped exports that do not match the requested session/run", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const first = await prepareXWorkmateArtifacts({
|
||||
@ -221,7 +299,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
).rejects.toThrow("artifactScope does not match sessionKey/runId");
|
||||
});
|
||||
|
||||
it("does not fall back to workspace files when the scoped directory is empty", async () => {
|
||||
it("does not adopt old workspace files when the scoped directory is empty", 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" },
|
||||
|
||||
@ -158,7 +158,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
const scopeKind: XWorkmateArtifactScopeKind = "task";
|
||||
const scopePrepared = await directoryExists(scopeRoot);
|
||||
const candidates = scopePrepared
|
||||
if (!scopePrepared && sinceUnixMs > 0) {
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
}
|
||||
const scopedCandidates = (await directoryExists(scopeRoot))
|
||||
? await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
@ -167,7 +170,19 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
if (!scopePrepared) {
|
||||
const adoptedCandidates =
|
||||
sinceUnixMs > 0
|
||||
? await adoptWorkspaceRootCandidatesIntoScope({
|
||||
workspaceRoot,
|
||||
scopeRoot,
|
||||
artifactScope,
|
||||
sinceUnixMs,
|
||||
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
const candidates = [...scopedCandidates, ...adoptedCandidates];
|
||||
if (!scopePrepared && candidates.length === 0) {
|
||||
warnings.push("artifact scope is not prepared for this task run");
|
||||
}
|
||||
|
||||
@ -387,6 +402,52 @@ 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,
|
||||
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;
|
||||
@ -538,6 +599,15 @@ 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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user