@openclaw/fs-safe 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +9 -1
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/atomic.d.ts +1 -1
  6. package/dist/native-binding.d.ts +22 -0
  7. package/dist/replace-file-buffer.d.ts +4 -0
  8. package/dist/replace-file-buffer.js +36 -0
  9. package/dist/replace-file-copy-fallback.d.ts +2 -0
  10. package/dist/replace-file-copy-fallback.js +66 -38
  11. package/dist/replace-file-descriptor.d.ts +4 -0
  12. package/dist/replace-file-descriptor.js +9 -1
  13. package/dist/replace-file-destination.d.ts +17 -0
  14. package/dist/replace-file-destination.js +61 -0
  15. package/dist/replace-file-mutation.d.ts +26 -0
  16. package/dist/replace-file-mutation.js +47 -0
  17. package/dist/replace-file-temp-owner.d.ts +2 -2
  18. package/dist/replace-file-temp-owner.js +16 -4
  19. package/dist/replace-file-types.d.ts +55 -0
  20. package/dist/replace-file-types.js +1 -0
  21. package/dist/replace-file.d.ts +3 -55
  22. package/dist/replace-file.js +29 -10
  23. package/dist/retained-file-types.d.ts +61 -0
  24. package/dist/retained-file-types.js +1 -0
  25. package/dist/retained-file.d.ts +3 -0
  26. package/dist/retained-file.js +121 -0
  27. package/dist/root-directory-entry.d.ts +9 -0
  28. package/dist/root-directory-entry.js +28 -0
  29. package/dist/root-directory-list.d.ts +7 -1
  30. package/dist/root-directory-list.js +48 -23
  31. package/dist/root-handle-context.d.ts +4 -0
  32. package/dist/root-handle-context.js +12 -0
  33. package/dist/root-impl.d.ts +3 -3
  34. package/dist/root-impl.js +8 -2
  35. package/dist/root-walk.d.ts +19 -12
  36. package/dist/root-walk.js +49 -18
  37. package/dist/temp-target.js +3 -2
  38. package/dist/temp-workspace-admission.js +22 -21
  39. package/dist/temp-workspace-child-admission.d.ts +1 -1
  40. package/dist/temp-workspace-child-admission.js +14 -9
  41. package/dist/temp-workspace-ownership.d.ts +8 -0
  42. package/dist/temp-workspace-ownership.js +52 -0
  43. package/dist/test-hooks.d.ts +3 -0
  44. package/dist/watch-alias.d.ts +6 -0
  45. package/dist/watch-alias.js +80 -0
  46. package/dist/watch-hints.d.ts +8 -0
  47. package/dist/watch-hints.js +77 -0
  48. package/dist/watch-native.d.ts +32 -0
  49. package/dist/watch-native.js +56 -0
  50. package/dist/watch-scan.d.ts +24 -0
  51. package/dist/watch-scan.js +269 -0
  52. package/dist/watch-types.d.ts +58 -0
  53. package/dist/watch-types.js +1 -0
  54. package/dist/watch.d.ts +5 -0
  55. package/dist/watch.js +502 -0
  56. package/docs/advanced.md +1 -0
  57. package/docs/atomic.md +61 -0
  58. package/docs/contributing.md +5 -0
  59. package/docs/durability.md +7 -0
  60. package/docs/index.md +1 -0
  61. package/docs/native-helper.md +9 -0
  62. package/docs/retained-file.md +113 -0
  63. package/docs/root.md +6 -1
  64. package/docs/temp.md +24 -4
  65. package/docs/testing.md +60 -0
  66. package/docs/types.md +6 -0
  67. package/docs/walk.md +22 -1
  68. package/docs/watch.md +184 -0
  69. package/package.json +12 -8
@@ -0,0 +1,269 @@
1
+ import path from "node:path";
2
+ import { isWindowsReservedDeviceName } from "./device-path.js";
3
+ import { isNotFoundPathError } from "./path.js";
4
+ import { FsSafeError } from "./errors.js";
5
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
6
+ import { validatePinnedRelativePath } from "./pinned-operation.js";
7
+ import { assertRootIdentityCurrent, assertValidRootRelativePath, resolvePathInRoot } from "./root-context.js";
8
+ import { createRootDirectoryObservationGuard, assertRootDirectoryObservationGuard, openRootDirectoryListing, pathStatFromStats } from "./root-directory-list.js";
9
+ import { createSuppressedError } from "./suppressed-error.js";
10
+ import { lookupRootDirectoryEntry } from "./root-directory-entry.js";
11
+ export function watchScopes(input) {
12
+ if (!Array.isArray(input) || input.length > 128)
13
+ throw new RangeError("watch accepts at most 128 scopes");
14
+ return Object.freeze(input.map(scope => {
15
+ const { path: suppliedPath, kind: suppliedKind, depth: suppliedDepth } = scope;
16
+ if (typeof suppliedPath !== "string" || path.isAbsolute(suppliedPath))
17
+ throw new FsSafeError("invalid-path", "watch scopes must be relative");
18
+ validatePinnedRelativePath(suppliedPath);
19
+ assertValidRootRelativePath(suppliedPath);
20
+ if (process.platform === "win32" && suppliedPath.split(/[\\/]/).some(component => component !== "." &&
21
+ (component.endsWith(".") || component.endsWith(" ") || isWindowsReservedDeviceName(component)))) {
22
+ throw new FsSafeError("invalid-path", "watch scopes must use literal Windows names");
23
+ }
24
+ if (suppliedKind !== "entry" && suppliedKind !== "tree")
25
+ throw new TypeError("invalid watch scope kind");
26
+ const depth = suppliedDepth ?? 32;
27
+ if (!Number.isSafeInteger(depth) || depth < 0 || depth > 128)
28
+ throw new RangeError("watch depth must be between 0 and 128");
29
+ // Normalize only admitted input, then remove the separator normalize preserves.
30
+ const spelling = path.normalize(suppliedPath);
31
+ const normalized = spelling.endsWith(path.sep) ? spelling.slice(0, -1) : spelling;
32
+ return Object.freeze({ path: normalized === "." ? "" : normalized, kind: suppliedKind, depth });
33
+ }));
34
+ }
35
+ function kind(entry) {
36
+ return entry.isSymbolicLink ? "symlink" : entry.isDirectory ? "directory" : entry.isFile ? "file" : "other";
37
+ }
38
+ function fingerprint(entry, identity) {
39
+ const { dev, ino } = identity;
40
+ // Metadata is advisory; identity bits are never rounded through PathStat. Directory size/mtime
41
+ // reflect children, not changes to an entry-only scope.
42
+ return entry.isDirectory
43
+ ? [kind(entry), dev, ino, entry.mode].join(":")
44
+ : [kind(entry), dev, ino, entry.size, entry.mtimeMs, entry.mode].join(":");
45
+ }
46
+ /** A descendant's unavailable metadata never grants it authority or retires the Root. */
47
+ export function isWatchPathError(error) {
48
+ if (isNotFoundPathError(error))
49
+ return true;
50
+ if (error instanceof FsSafeError) {
51
+ if (error.details?.operation === "watch" && ["EACCES", "EPERM", "EBUSY"].includes(String(error.details.code)))
52
+ return true;
53
+ return ["not-found", "path-mismatch", "not-file", "symlink", "outside-workspace", "path-alias"].includes(error.code);
54
+ }
55
+ return ["ENOTDIR", "EACCES", "EPERM", "EBUSY", "ESTALE", "EIO", "ELOOP"].includes(error?.code ?? "");
56
+ }
57
+ export async function scanWatch(root, scopes, options, signal, register, onCleanupFailure) {
58
+ const result = { entries: new Map(), directories: new Map(), targets: new Map(), scanned: 0 };
59
+ const attempts = new Map();
60
+ const guards = new Map();
61
+ const walked = new Map();
62
+ const structural = new Set();
63
+ result.structural = structural;
64
+ const invalidate = (relative) => {
65
+ if (structural.size < options.maxPendingPaths)
66
+ structural.add(relative);
67
+ else
68
+ result.overflow = true;
69
+ // Discard partially observed names when their enclosing directory lost admission.
70
+ const below = (name) => name === relative || !relative || name.startsWith(relative + path.sep);
71
+ for (const map of [result.entries, result.targets, result.directories, guards, walked]) {
72
+ for (const name of map.keys())
73
+ if (below(name))
74
+ map.delete(name);
75
+ }
76
+ };
77
+ const recover = async (relative, error) => {
78
+ signal.throwIfAborted();
79
+ await assertRootIdentityCurrent(root);
80
+ const code = error instanceof FsSafeError ? error.details?.code ?? error.code : error?.code;
81
+ if (!isWatchPathError(error) || (!relative && (code === "EACCES" || code === "EPERM")))
82
+ throw error;
83
+ invalidate(relative);
84
+ };
85
+ const examined = () => {
86
+ if (++result.scanned > options.maxEntries)
87
+ throw new FsSafeError("too-large", "watch entry budget exceeded", { details: { operation: "scan" } });
88
+ };
89
+ const excluded = (name, entry) => {
90
+ let value;
91
+ try {
92
+ value = options.exclude?.({ path: name, kind: kind(entry) });
93
+ assertSynchronousCallbackResult(value, "watch exclude");
94
+ }
95
+ catch (cause) {
96
+ throw new FsSafeError("helper-failed", "watch exclusion callback failed", { cause, details: { operation: "callback" } });
97
+ }
98
+ signal.throwIfAborted();
99
+ return value;
100
+ };
101
+ const directory = async (relative) => {
102
+ signal.throwIfAborted();
103
+ const prior = guards.get(relative);
104
+ if (prior) {
105
+ try {
106
+ await assertRootDirectoryObservationGuard(root, prior);
107
+ return prior;
108
+ }
109
+ catch (error) {
110
+ await recover(relative, error);
111
+ }
112
+ }
113
+ if (!attempts.has(relative) && attempts.size >= options.maxDirectories) {
114
+ throw new FsSafeError("too-large", "watch directory budget exceeded", { details: { operation: "scan" } });
115
+ }
116
+ let registrationError;
117
+ while ((attempts.get(relative) ?? 0) < 3) {
118
+ attempts.set(relative, (attempts.get(relative) ?? 0) + 1);
119
+ try {
120
+ const resolved = await resolvePathInRoot(root, relative ? "./" + relative : ".", { rejectSymlinks: true });
121
+ const guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
122
+ const identity = { dev: guard.stat.dev, ino: guard.stat.ino };
123
+ result.directories.set(relative, identity);
124
+ await register(relative, identity, guard);
125
+ signal.throwIfAborted();
126
+ await assertRootDirectoryObservationGuard(root, guard);
127
+ guards.set(relative, guard);
128
+ return guard;
129
+ }
130
+ catch (error) {
131
+ registrationError = error;
132
+ await recover(relative, error);
133
+ }
134
+ }
135
+ if (!relative)
136
+ throw new FsSafeError("helper-failed", "watch Root registration could not be established", {
137
+ cause: registrationError, details: { operation: "watch", code: "registration-failed" },
138
+ });
139
+ throw new FsSafeError("path-mismatch", "watch directory changed during registration");
140
+ };
141
+ const tree = async (relative, depth) => {
142
+ if ((walked.get(relative) ?? 0) >= depth)
143
+ return;
144
+ walked.set(relative, depth);
145
+ let guard;
146
+ let listing;
147
+ for (let attempt = 0; attempt < 3; attempt++) {
148
+ try {
149
+ guard = await directory(relative);
150
+ listing = await openRootDirectoryListing(root, guard.realPath, {
151
+ order: "filesystem", snapshot: false, signal, exactIdentity: true, onCleanupFailure, admitEntry: () => { examined(); return true; },
152
+ });
153
+ // The listing has its own guard. Both identities must agree before reading names.
154
+ await assertRootDirectoryObservationGuard(root, guard);
155
+ break;
156
+ }
157
+ catch (error) {
158
+ await listing?.[Symbol.asyncDispose]();
159
+ listing = undefined;
160
+ await recover(relative, error);
161
+ }
162
+ }
163
+ if (!listing)
164
+ return;
165
+ let failed = false;
166
+ let operationError;
167
+ try {
168
+ while (true) {
169
+ const next = await listing.next();
170
+ signal.throwIfAborted();
171
+ if (!next)
172
+ break;
173
+ if (next.kind === "limit")
174
+ throw new FsSafeError("too-large", "watch entry budget exceeded");
175
+ if (!next.identity)
176
+ throw new FsSafeError("path-mismatch", "watch listing lacks exact identity");
177
+ const entry = next.entry;
178
+ const name = relative ? path.join(relative, entry.name) : entry.name;
179
+ if (excluded(name, entry))
180
+ continue;
181
+ result.entries.set(name, fingerprint(entry, next.identity));
182
+ if (entry.isDirectory && !entry.isSymbolicLink && depth > 1)
183
+ await tree(name, depth - 1);
184
+ }
185
+ await listing.assertCurrent();
186
+ }
187
+ catch (error) {
188
+ failed = true;
189
+ operationError = error;
190
+ await recover(relative, error);
191
+ }
192
+ finally {
193
+ try {
194
+ await listing[Symbol.asyncDispose]();
195
+ }
196
+ catch (closeError) {
197
+ if (failed)
198
+ throw createSuppressedError(closeError, operationError, "watch scan and directory disposal both failed");
199
+ throw closeError;
200
+ }
201
+ }
202
+ try {
203
+ await assertRootDirectoryObservationGuard(root, guard);
204
+ }
205
+ catch (error) {
206
+ await recover(relative, error);
207
+ }
208
+ };
209
+ await assertRootIdentityCurrent(root);
210
+ for (const scope of scopes) {
211
+ signal.throwIfAborted();
212
+ if (!scope.path) {
213
+ const guard = await directory("");
214
+ result.entries.set("", fingerprint({ name: "", ...pathStatFromStats(guard.stat) }, guard.stat));
215
+ if (scope.kind === "tree" && scope.depth > 0)
216
+ await tree("", scope.depth);
217
+ continue;
218
+ }
219
+ const segments = scope.path.split(path.sep);
220
+ let relative = "";
221
+ try {
222
+ for (let i = 0; i < segments.length; i++) {
223
+ const guard = await directory(relative);
224
+ examined();
225
+ // Filesystem lookup, not lowercase/prefix matching, owns case, Unicode and
226
+ // short-name aliases. It also preserves case-sensitive Windows directories.
227
+ const found = await lookupRootDirectoryEntry(root, guard, segments[i]);
228
+ signal.throwIfAborted();
229
+ if (!found)
230
+ break;
231
+ const name = relative ? path.join(relative, segments[i]) : segments[i];
232
+ if (excluded(name, found.entry))
233
+ break;
234
+ if (i === segments.length - 1) {
235
+ result.entries.set(name, fingerprint(found.entry, found.identity));
236
+ result.targets.set(name, found.identity);
237
+ if (scope.kind === "tree" && found.entry.isDirectory && !found.entry.isSymbolicLink && scope.depth > 0)
238
+ await tree(name, scope.depth);
239
+ break;
240
+ }
241
+ if (found.entry.isSymbolicLink) {
242
+ if (options.admitting)
243
+ throw new FsSafeError("symlink", "watch scope traverses a symbolic link; admit its target separately", { details: { operation: "scope" } });
244
+ invalidate(name);
245
+ break;
246
+ }
247
+ if (!found.entry.isDirectory)
248
+ break;
249
+ relative = name;
250
+ }
251
+ }
252
+ catch (error) {
253
+ if (error instanceof FsSafeError && error.details?.operation === "scope")
254
+ throw error;
255
+ await recover(relative, error);
256
+ }
257
+ }
258
+ for (const [relative, guard] of guards) {
259
+ try {
260
+ await assertRootDirectoryObservationGuard(root, guard);
261
+ }
262
+ catch (error) {
263
+ await recover(relative, error);
264
+ }
265
+ }
266
+ await assertRootIdentityCurrent(root);
267
+ signal.throwIfAborted();
268
+ return result;
269
+ }
@@ -0,0 +1,58 @@
1
+ /** Literal Root-relative names. Trees include their entry; depth defaults to 32 (max 128). */
2
+ export type WatchScope = Readonly<{
3
+ path: string;
4
+ kind: "entry" | "tree";
5
+ depth?: number;
6
+ }>;
7
+ export type WatchEntry = Readonly<{
8
+ path: string;
9
+ kind: "file" | "directory" | "symlink" | "other";
10
+ }>;
11
+ export type WatchChange = Readonly<{
12
+ path: string;
13
+ type: "content" | "structural";
14
+ }>;
15
+ export type WatchInvalidation = Readonly<{
16
+ reason: "event" | "reconcile" | "overflow";
17
+ /** Bounded advisory detail. undefined => invalidate every configured scope. */
18
+ changes?: readonly WatchChange[];
19
+ }>;
20
+ export type WatchFailure = Readonly<{
21
+ operation: "watch" | "scan" | "callback" | "close";
22
+ code?: string;
23
+ error: unknown;
24
+ }>;
25
+ export type WatchHealth = Readonly<{
26
+ state: "starting" | "ready" | "reconciling" | "unavailable" | "closed";
27
+ mode: "events" | "poll";
28
+ /** Directories currently observed, for pressure warnings. */
29
+ directories: number;
30
+ failure?: WatchFailure;
31
+ }>;
32
+ export type WatchOptions = {
33
+ scopes: readonly WatchScope[];
34
+ /** Required. auto selects events when available, otherwise poll. */
35
+ mode: "auto" | "events" | "poll";
36
+ /** Keep the Node event loop alive while open. Defaults to true. */
37
+ persistent?: boolean;
38
+ /** Guarded reconciliation interval: events 30000, poll 1000; minimum 20 ms. */
39
+ intervalMs?: number;
40
+ exclude?: (entry: WatchEntry) => boolean;
41
+ maxDirectories?: number;
42
+ maxEntries?: number;
43
+ maxPendingPaths?: number;
44
+ onInvalidate: (invalidation: WatchInvalidation) => void;
45
+ onHealth?: (health: WatchHealth) => void;
46
+ signal?: AbortSignal;
47
+ };
48
+ export type WatchSubscription = {
49
+ readonly ready: Promise<void>;
50
+ /** Immediately fences the old generation. Superseded calls reject AbortError. */
51
+ setScopes(scopes: readonly WatchScope[]): Promise<void>;
52
+ /** Completes a pass started after this call; concurrent pending requests coalesce. */
53
+ reconcile(): Promise<void>;
54
+ health(): WatchHealth;
55
+ /** Terminal, idempotent, joined; rejects only retirement failures. */
56
+ close(): Promise<void>;
57
+ [Symbol.asyncDispose](): Promise<void>;
58
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { Root } from "./root.js";
2
+ import type { WatchOptions, WatchSubscription } from "./watch-types.js";
3
+ export type * from "./watch-types.js";
4
+ /** Advisory observation only. Hints never grant filesystem authority. */
5
+ export declare function watch(root: Root, input: WatchOptions): WatchSubscription;