refactor: trim temp workspace surface

This commit is contained in:
Peter Steinberger 2026-05-06 01:26:06 +01:00
parent 542657b9d2
commit 02b9a9d2ae
No known key found for this signature in database
7 changed files with 23 additions and 49 deletions

View File

@ -159,26 +159,6 @@ type FileStorePruneOptions = {
Symlinks are skipped. The walk is best-effort — failures on individual entries don't abort the whole prune. Compares against `mtimeMs`.
## Standalone: `copyIntoRoot`
The same one-shot copy primitive used by `FileStore.copyIn`, available from the advanced surface for callers that don't want to instantiate a store:
```ts
import { copyIntoRoot } from "@openclaw/fs-safe/advanced";
await copyIntoRoot({
rootDir: "/var/cache/app",
relativePath: "ingest/upload.bin",
sourcePath: "/tmp/upload.bin",
mode: 0o600,
dirMode: 0o700,
maxBytes: 32 * 1024 * 1024,
tempPrefix: ".upload.bin", // optional
});
```
Returns the final absolute path. Throws `not-file` if the source is a symlink or non-regular file; throws `too-large` if it exceeds `maxBytes`.
## Difference from `Root`
| `FileStore` | `Root` |
@ -230,4 +210,4 @@ await root.move(`pending/${id}`, `done/${id}`);
- [`root()`](root.md) — the boundary `FileStore` is built on; reach for it when you need move/list/append.
- [JSON store](json-store.md) — the JSON-state-file equivalent of this surface.
- [Atomic writes](atomic.md) — `writeSiblingTempFile` is what every write goes through.
- [Temp workspaces](temp.md) — `TempWorkspace.copyIn` uses `copyIntoRoot`.
- [Temp workspaces](temp.md) — private scratch directories backed by `FileStore`.

View File

@ -24,9 +24,8 @@ The compact factory. Returns:
type TempWorkspace = {
dir: string;
store: FileStore;
file(fileName: string): string;
path(fileName: string): string;
writePrivate(fileName: string, data: string | Uint8Array): Promise<string>;
write(fileName: string, data: string | Uint8Array): Promise<string>;
writeText(fileName: string, data: string): Promise<string>;
writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
copyIn(fileName: string, sourcePath: string): Promise<string>;
@ -40,11 +39,11 @@ type TempWorkspace = {
import { tempWorkspace } from "@openclaw/fs-safe/temp";
await using workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" });
const inputPath = await workspace.writePrivate("input.txt", "data");
const inputPath = await workspace.write("input.txt", "data");
await runBuild(workspace.dir, inputPath);
```
`writePrivate` writes at `mode` (default `0o600`); `writeText` and `writeJson` are convenience wrappers for the common scratch-file shapes; `copyIn` ingests an absolute source path through the same atomic-rename machinery as `Root.copyIn`. `read` is a small accessor that reads back any file you wrote into the workspace.
`write` writes at `mode` (default `0o600`); `writeText` and `writeJson` are convenience wrappers for the common scratch-file shapes; `copyIn` ingests an absolute source path through the same atomic-rename machinery as `Root.copyIn`. `read` is a small accessor that reads back any file you wrote into the workspace.
`store` is a `fileStore({ rootDir: workspace.dir, private: true })` handle. Use
it when you want the richer store surface, including `writeStream`, `exists`,
@ -70,7 +69,7 @@ The recommended shape. Auto-cleanup on every exit path:
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
const result = await withTempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" }, async (workspace) => {
await workspace.writePrivate("input.txt", "data");
await workspace.write("input.txt", "data");
return await runBuild(workspace.dir);
});
```
@ -101,7 +100,7 @@ type TempWorkspaceOptions = {
rootDir: string; // parent directory for workspaces
prefix: string; // dir prefix (sanitized)
dirMode?: number; // dir mode; default 0o700
mode?: number; // writePrivate file mode; default 0o600
mode?: number; // file write mode; default 0o600
};
```

View File

@ -15,7 +15,6 @@ export {
export { sameFileIdentity, type FileIdentityStat } from "./file-identity.js";
export { sanitizeUntrustedFileName } from "./filename.js";
export { pathExists, pathExistsSync } from "./fs.js";
export { copyIntoRoot, fileStoreSync, type FileStoreSync } from "./file-store.js";
export {
resolveLocalPathFromRootsSync,
readLocalFileFromRoots,

View File

@ -134,7 +134,7 @@ function isNotFound(error: unknown): boolean {
(error as NodeJS.ErrnoException).code === "ENOTDIR";
}
export async function copyIntoRoot(params: {
async function copyIntoRoot(params: {
rootDir: string;
relativePath: string;
sourcePath: string;

View File

@ -20,9 +20,8 @@ export type TempWorkspaceOptions = {
export type TempWorkspace = {
dir: string;
store: FileStore;
file(fileName: string): string;
path(fileName: string): string;
writePrivate(fileName: string, data: string | Uint8Array): Promise<string>;
write(fileName: string, data: string | Uint8Array): Promise<string>;
writeText(fileName: string, data: string): Promise<string>;
writeJson(
fileName: string,
@ -38,9 +37,8 @@ export type TempWorkspace = {
export type TempWorkspaceSync = {
dir: string;
store: FileStoreSync;
file(fileName: string): string;
path(fileName: string): string;
writePrivate(fileName: string, data: string | Uint8Array): string;
write(fileName: string, data: string | Uint8Array): string;
writeText(fileName: string, data: string): string;
writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): string;
read(fileName: string): Buffer;
@ -119,9 +117,8 @@ async function createTempWorkspace(
return {
dir,
store,
file: (fileName) => resolveWorkspaceLeaf(dir, fileName),
path: (fileName) => resolveWorkspaceLeaf(dir, fileName),
writePrivate: async (fileName, data) =>
write: async (fileName, data) =>
await store.write(assertWorkspaceFileName(fileName), data, { mode }),
writeText: async (fileName, data) =>
await store.writeText(assertWorkspaceFileName(fileName), data, { mode }),
@ -200,9 +197,8 @@ export function tempWorkspaceSync(
return {
dir,
store,
file: (fileName) => resolveWorkspaceLeaf(dir, fileName),
path: (fileName) => resolveWorkspaceLeaf(dir, fileName),
writePrivate: (fileName, data) =>
write: (fileName, data) =>
store.write(assertWorkspaceFileName(fileName), data, { mode }),
writeText: (fileName, data) =>
store.writeText(assertWorkspaceFileName(fileName), data, { mode }),

View File

@ -9,7 +9,7 @@ import { extractArchive } from "../src/archive.js";
import { loadZipArchiveWithPreflight, readZipCentralDirectoryEntryCount } from "../src/archive-zip-preflight.js";
import { createAsyncLock } from "../src/async-lock.js";
import { writeTextAtomic } from "../src/atomic.js";
import { copyIntoRoot, fileStore, fileStoreSync } from "../src/file-store.js";
import { fileStore, fileStoreSync } from "../src/file-store.js";
import {
assertCanonicalPathWithinBase,
resolveSafeInstallDir,
@ -624,8 +624,8 @@ describe("temporary workspace and symlink parent helpers", () => {
await fs.writeFile(source, "copy", "utf8");
const workspace = await tempWorkspace({ rootDir: root, prefix: "bad prefix!" });
expect(() => workspace.file("../bad")).toThrow("Invalid temp workspace");
const privateFile = await workspace.writePrivate("private.bin", Buffer.from("private"));
expect(() => workspace.path("../bad")).toThrow("Invalid temp workspace");
const privateFile = await workspace.write("private.bin", Buffer.from("private"));
await workspace.store.writeText("store.txt", "stored");
const textFile = await workspace.writeText("text.txt", "text");
const jsonFile = await workspace.writeJson("data.json", { ok: true }, {
@ -648,8 +648,8 @@ describe("temporary workspace and symlink parent helpers", () => {
const syncWorkspace = tempWorkspaceSync({ rootDir: root, prefix: ".." });
try {
expect(() => syncWorkspace.file("bad/name")).toThrow("Invalid temp workspace");
expect(syncWorkspace.writePrivate("private.bin", Buffer.from("private"))).toContain(
expect(() => syncWorkspace.path("bad/name")).toThrow("Invalid temp workspace");
expect(syncWorkspace.write("private.bin", Buffer.from("private"))).toContain(
"private.bin",
);
expect(syncWorkspace.store.writeText("store.txt", "stored")).toContain("store.txt");
@ -756,7 +756,7 @@ describe("file stores and private stores", () => {
maxBytes: 4,
})).rejects.toMatchObject({ code: "too-large" });
await expect(store.copyIn("copied.txt", source)).resolves.toBe(path.join(root, "copied.txt"));
await expect(copyIntoRoot({ rootDir: root, relativePath: "bad.txt", sourcePath: sourceRoot }))
await expect(store.copyIn("bad.txt", sourceRoot))
.rejects
.toMatchObject({ code: "not-file" });
await expect(store.exists("copied.txt")).resolves.toBe(true);

View File

@ -52,7 +52,7 @@ describe("private temp workspaces", () => {
let workspaceDir = "";
const content = await withTempWorkspace({ rootDir: root, prefix: "work-" }, async (tmp) => {
workspaceDir = tmp.dir;
const filePath = await tmp.writePrivate("input.txt", "hello");
const filePath = await tmp.write("input.txt", "hello");
expect(await fs.readFile(filePath, "utf8")).toBe("hello");
return await tmp.read("input.txt");
});
@ -64,7 +64,7 @@ describe("private temp workspaces", () => {
it("rejects path-like file names", async () => {
const tmp = await tempWorkspace({ rootDir: root, prefix: "work-" });
try {
await expect(tmp.writePrivate("../escape.txt", "nope")).rejects.toThrow(/Invalid/);
await expect(tmp.write("../escape.txt", "nope")).rejects.toThrow(/Invalid/);
} finally {
await tmp.cleanup();
}
@ -74,7 +74,7 @@ describe("private temp workspaces", () => {
let workspaceDir = "";
const result = withTempWorkspaceSync({ rootDir: root, prefix: "sync-" }, (tmp) => {
workspaceDir = tmp.dir;
const filePath = tmp.writePrivate("input.txt", "hello");
const filePath = tmp.write("input.txt", "hello");
expect(tmp.read("input.txt").toString("utf8")).toBe("hello");
return filePath;
});
@ -83,7 +83,7 @@ describe("private temp workspaces", () => {
const tmp = tempWorkspaceSync({ rootDir: root, prefix: "sync-" });
try {
expect(tmp.writePrivate("again.txt", "ok")).toContain("again.txt");
expect(tmp.write("again.txt", "ok")).toContain("again.txt");
} finally {
tmp.cleanup();
}
@ -94,8 +94,8 @@ describe("private temp workspaces", () => {
{
await using tmp = await tempWorkspace({ rootDir: root, prefix: "compact-" });
workspaceDir = tmp.dir;
const filePath = await tmp.writePrivate("input.txt", "hello");
expect(filePath).toBe(tmp.file("input.txt"));
const filePath = await tmp.write("input.txt", "hello");
expect(filePath).toBe(tmp.path("input.txt"));
expect(tmp.path("input.txt")).toBe(filePath);
await tmp.store.json<{ ok: boolean }>("state.json").write({ ok: true });
await expect(tmp.store.readJson("state.json")).resolves.toEqual({ ok: true });