@nanhara/hara 0.122.2 → 0.122.4
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.
- package/CHANGELOG.md +108 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +31 -17
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/sessions.js +3 -3
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/org/projects.js +3 -3
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/sync-sleep.js +46 -0
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/task.js +3 -3
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/fs-walk.js
CHANGED
|
@@ -1,23 +1,193 @@
|
|
|
1
1
|
// Shared filesystem walker — powers @file completion and the grep/glob tools.
|
|
2
2
|
// Walks a directory tree, skipping noise dirs, capped so huge trees stay responsive.
|
|
3
3
|
import { readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { opendir } from "node:fs/promises";
|
|
4
5
|
import { join, relative, sep } from "node:path";
|
|
5
|
-
import { execSync } from "node:child_process";
|
|
6
|
+
import { execFile, execSync } from "node:child_process";
|
|
6
7
|
import { fuzzyRank } from "./fuzzy.js";
|
|
7
8
|
import { isSensitiveFilePath } from "./security/sensitive-files.js";
|
|
8
9
|
import { toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
10
|
+
import { recursiveRootContainsHome } from "./context/workspace-scope.js";
|
|
9
11
|
export const IGNORE_DIRS = new Set([
|
|
10
12
|
".git", "node_modules", "dist", "build", "out", ".next", ".nuxt", ".cache",
|
|
11
13
|
"coverage", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
|
|
12
14
|
"target", ".idea", ".vscode", ".hara", ".turbo", ".parcel-cache", "vendor",
|
|
13
15
|
]);
|
|
14
16
|
const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
const DEFAULT_MAX_FILES = 8_000;
|
|
18
|
+
const DEFAULT_MAX_DIRECTORIES = 20_000;
|
|
19
|
+
const DEFAULT_MAX_ENTRIES = 100_000;
|
|
20
|
+
const DEFAULT_ASYNC_TIMEOUT_MS = 2_000;
|
|
21
|
+
// Compatibility-only synchronous callers (readline completion and legacy public APIs) cannot be made
|
|
22
|
+
// interruptible. Keep them on a much tighter wall budget; agent/tool paths use the async APIs below.
|
|
23
|
+
const DEFAULT_SYNC_TIMEOUT_MS = 250;
|
|
24
|
+
const DEFAULT_YIELD_EVERY = 128;
|
|
25
|
+
function finiteLimit(value, fallback) {
|
|
26
|
+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback;
|
|
27
|
+
}
|
|
28
|
+
function normalizeOptions(options, defaultTimeoutMs) {
|
|
29
|
+
return {
|
|
30
|
+
maxFiles: finiteLimit(options?.maxFiles, DEFAULT_MAX_FILES),
|
|
31
|
+
maxDirectories: finiteLimit(options?.maxDirectories, DEFAULT_MAX_DIRECTORIES),
|
|
32
|
+
maxEntries: finiteLimit(options?.maxEntries, DEFAULT_MAX_ENTRIES),
|
|
33
|
+
timeoutMs: finiteLimit(options?.timeoutMs, defaultTimeoutMs),
|
|
34
|
+
signal: options?.signal,
|
|
35
|
+
yieldEvery: Math.max(1, finiteLimit(options?.yieldEvery, DEFAULT_YIELD_EVERY)),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function abortError(signal) {
|
|
39
|
+
const reason = signal?.reason;
|
|
40
|
+
if (reason instanceof Error)
|
|
41
|
+
return reason;
|
|
42
|
+
const error = new Error(typeof reason === "string" && reason ? reason : "file scan aborted");
|
|
43
|
+
error.name = "AbortError";
|
|
44
|
+
return error;
|
|
45
|
+
}
|
|
46
|
+
function throwIfAborted(signal) {
|
|
47
|
+
if (signal?.aborted)
|
|
48
|
+
throw abortError(signal);
|
|
49
|
+
}
|
|
50
|
+
function result(files, directoriesVisited, entriesVisited, reason) {
|
|
51
|
+
return { files, truncated: reason !== undefined, reason, directoriesVisited, entriesVisited };
|
|
52
|
+
}
|
|
53
|
+
function yieldToEventLoop() {
|
|
54
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Interruptible filesystem walk for agent/tool paths. It has independent file, directory, Dirent and
|
|
58
|
+
* total wall-clock limits. Dirents are streamed with `opendir`, and explicit yields ensure an agent
|
|
59
|
+
* deadline's timer can run even when a large tree is entirely cached in memory.
|
|
60
|
+
*/
|
|
61
|
+
export async function walkFilesAsync(root, options = {}) {
|
|
62
|
+
const cfg = normalizeOptions(options, DEFAULT_ASYNC_TIMEOUT_MS);
|
|
63
|
+
const startedAt = Date.now();
|
|
64
|
+
throwIfAborted(cfg.signal);
|
|
65
|
+
if (recursiveRootContainsHome(root))
|
|
66
|
+
return result([], 0, 0);
|
|
67
|
+
if (cfg.maxFiles === 0)
|
|
68
|
+
return result([], 0, 0, "file_limit");
|
|
69
|
+
if (cfg.maxDirectories === 0)
|
|
70
|
+
return result([], 0, 0, "directory_limit");
|
|
71
|
+
if (cfg.maxEntries === 0)
|
|
72
|
+
return result([], 0, 0, "entry_limit");
|
|
17
73
|
const files = [];
|
|
18
74
|
const stack = [root];
|
|
19
|
-
|
|
75
|
+
let directoriesVisited = 0;
|
|
76
|
+
let entriesVisited = 0;
|
|
77
|
+
let sinceYield = 0;
|
|
78
|
+
const timedOut = () => Date.now() - startedAt >= cfg.timeoutMs;
|
|
79
|
+
while (stack.length) {
|
|
80
|
+
throwIfAborted(cfg.signal);
|
|
81
|
+
if (timedOut())
|
|
82
|
+
return result(files, directoriesVisited, entriesVisited, "time_limit");
|
|
83
|
+
if (directoriesVisited >= cfg.maxDirectories) {
|
|
84
|
+
return result(files, directoriesVisited, entriesVisited, "directory_limit");
|
|
85
|
+
}
|
|
20
86
|
const dir = stack.pop();
|
|
87
|
+
directoriesVisited++;
|
|
88
|
+
if (recursiveRootContainsHome(dir))
|
|
89
|
+
continue;
|
|
90
|
+
let directory;
|
|
91
|
+
try {
|
|
92
|
+
// Stream Dirents so maxEntries is an allocation boundary too; readdir() would first materialize a
|
|
93
|
+
// million-entry directory and only then let us enforce the counter.
|
|
94
|
+
directory = await opendir(dir);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throwIfAborted(cfg.signal);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
throwIfAborted(cfg.signal);
|
|
102
|
+
if (timedOut())
|
|
103
|
+
return result(files, directoriesVisited, entriesVisited, "time_limit");
|
|
104
|
+
for await (const entry of directory) {
|
|
105
|
+
throwIfAborted(cfg.signal);
|
|
106
|
+
if (timedOut())
|
|
107
|
+
return result(files, directoriesVisited, entriesVisited, "time_limit");
|
|
108
|
+
if (entriesVisited >= cfg.maxEntries) {
|
|
109
|
+
return result(files, directoriesVisited, entriesVisited, "entry_limit");
|
|
110
|
+
}
|
|
111
|
+
entriesVisited++;
|
|
112
|
+
sinceYield++;
|
|
113
|
+
// Yield before filters/continues so a forest of ignored or sensitive entries remains cancellable.
|
|
114
|
+
if (sinceYield >= cfg.yieldEvery) {
|
|
115
|
+
sinceYield = 0;
|
|
116
|
+
await yieldToEventLoop();
|
|
117
|
+
throwIfAborted(cfg.signal);
|
|
118
|
+
if (timedOut())
|
|
119
|
+
return result(files, directoriesVisited, entriesVisited, "time_limit");
|
|
120
|
+
}
|
|
121
|
+
if (entry.name.startsWith(".") && entry.name !== ".env" && entry.isDirectory() && IGNORE_DIRS.has(entry.name)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (entry.isDirectory()) {
|
|
125
|
+
if (IGNORE_DIRS.has(entry.name))
|
|
126
|
+
continue;
|
|
127
|
+
stack.push(join(dir, entry.name));
|
|
128
|
+
}
|
|
129
|
+
else if (entry.isFile()) {
|
|
130
|
+
const absolute = join(dir, entry.name);
|
|
131
|
+
if (isSensitiveFilePath(absolute))
|
|
132
|
+
continue;
|
|
133
|
+
files.push(toPosix(relative(root, absolute)));
|
|
134
|
+
if (files.length >= cfg.maxFiles) {
|
|
135
|
+
return result(files, directoriesVisited, entriesVisited, "file_limit");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throwIfAborted(cfg.signal);
|
|
142
|
+
// Directory vanished or became unreadable while streaming it; keep the best-effort inventory.
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
// Node normally closes a fully consumed async Dir iterator itself. Early budget/cancellation exits
|
|
146
|
+
// also pass here; ignore ERR_DIR_CLOSED when the iterator already performed the close.
|
|
147
|
+
try {
|
|
148
|
+
await directory.close();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* already closed */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Empty-directory forests do not increment the Dirent counter while they are being popped. Yield on
|
|
155
|
+
// directory progress too, otherwise that exact shape can starve AbortSignal timers.
|
|
156
|
+
sinceYield++;
|
|
157
|
+
if (sinceYield >= cfg.yieldEvery) {
|
|
158
|
+
sinceYield = 0;
|
|
159
|
+
await yieldToEventLoop();
|
|
160
|
+
throwIfAborted(cfg.signal);
|
|
161
|
+
if (timedOut())
|
|
162
|
+
return result(files, directoriesVisited, entriesVisited, "time_limit");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return result(files, directoriesVisited, entriesVisited);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Synchronous compatibility API. New agent/tool code must use `walkFilesAsync` so cancellation can run.
|
|
169
|
+
* This legacy path is nevertheless bounded by directory count, Dirent count and a short wall budget.
|
|
170
|
+
*/
|
|
171
|
+
export function walkFiles(root, cap = DEFAULT_MAX_FILES, options = {}) {
|
|
172
|
+
// Defense in depth: callers that need an explicitly selected child directory pass that child as root.
|
|
173
|
+
// No generic inventory helper may silently turn the user's entire home into a project corpus.
|
|
174
|
+
if (recursiveRootContainsHome(root))
|
|
175
|
+
return [];
|
|
176
|
+
const cfg = normalizeOptions({ ...options, maxFiles: cap }, DEFAULT_SYNC_TIMEOUT_MS);
|
|
177
|
+
const startedAt = Date.now();
|
|
178
|
+
const files = [];
|
|
179
|
+
const stack = [root];
|
|
180
|
+
let directoriesVisited = 0;
|
|
181
|
+
let entriesVisited = 0;
|
|
182
|
+
while (stack.length && files.length < cfg.maxFiles) {
|
|
183
|
+
if (Date.now() - startedAt >= cfg.timeoutMs)
|
|
184
|
+
break;
|
|
185
|
+
if (directoriesVisited >= cfg.maxDirectories || entriesVisited >= cfg.maxEntries)
|
|
186
|
+
break;
|
|
187
|
+
const dir = stack.pop();
|
|
188
|
+
directoriesVisited++;
|
|
189
|
+
if (recursiveRootContainsHome(dir))
|
|
190
|
+
continue;
|
|
21
191
|
let entries;
|
|
22
192
|
try {
|
|
23
193
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -26,8 +196,11 @@ export function walkFiles(root, cap = 8000) {
|
|
|
26
196
|
continue;
|
|
27
197
|
}
|
|
28
198
|
for (const e of entries) {
|
|
29
|
-
if (files.length >=
|
|
199
|
+
if (files.length >= cfg.maxFiles || entriesVisited >= cfg.maxEntries)
|
|
30
200
|
break;
|
|
201
|
+
if (Date.now() - startedAt >= cfg.timeoutMs)
|
|
202
|
+
break;
|
|
203
|
+
entriesVisited++;
|
|
31
204
|
if (e.name.startsWith(".") && e.name !== ".env" && e.isDirectory()) {
|
|
32
205
|
if (IGNORE_DIRS.has(e.name))
|
|
33
206
|
continue;
|
|
@@ -52,6 +225,9 @@ export function walkFiles(root, cap = 8000) {
|
|
|
52
225
|
* Otherwise: a filesystem walk. POSIX-relative paths.
|
|
53
226
|
*/
|
|
54
227
|
export function listProjectFiles(root, cap = 8000) {
|
|
228
|
+
if (recursiveRootContainsHome(root))
|
|
229
|
+
return [];
|
|
230
|
+
const startedAt = Date.now();
|
|
55
231
|
try {
|
|
56
232
|
const out = execSync("git ls-files --cached --others --exclude-standard", {
|
|
57
233
|
cwd: root,
|
|
@@ -61,7 +237,7 @@ export function listProjectFiles(root, cap = 8000) {
|
|
|
61
237
|
stdio: ["ignore", "pipe", "ignore"],
|
|
62
238
|
// Bound it: a hung git (credential-helper GUI, a slow/network filesystem, a giant repo) must not
|
|
63
239
|
// freeze the "which files exist" probe — on timeout it throws → we fall through to the fs walk.
|
|
64
|
-
timeout:
|
|
240
|
+
timeout: DEFAULT_SYNC_TIMEOUT_MS,
|
|
65
241
|
})
|
|
66
242
|
.split("\n")
|
|
67
243
|
.map((s) => s.trim())
|
|
@@ -72,7 +248,94 @@ export function listProjectFiles(root, cap = 8000) {
|
|
|
72
248
|
catch {
|
|
73
249
|
/* not a git repo — fall through to fs walk */
|
|
74
250
|
}
|
|
75
|
-
return walkFiles(root, cap);
|
|
251
|
+
return walkFiles(root, cap, { timeoutMs: Math.max(0, DEFAULT_SYNC_TIMEOUT_MS - (Date.now() - startedAt)) });
|
|
252
|
+
}
|
|
253
|
+
function gitProjectFiles(root, timeoutMs, signal) {
|
|
254
|
+
return new Promise((resolveOutput, rejectOutput) => {
|
|
255
|
+
try {
|
|
256
|
+
execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
|
|
257
|
+
cwd: root,
|
|
258
|
+
env: toolSubprocessEnv(),
|
|
259
|
+
encoding: "utf8",
|
|
260
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
261
|
+
timeout: Math.max(1, timeoutMs),
|
|
262
|
+
signal,
|
|
263
|
+
windowsHide: true,
|
|
264
|
+
}, (error, stdout) => {
|
|
265
|
+
if (error)
|
|
266
|
+
rejectOutput(error);
|
|
267
|
+
else
|
|
268
|
+
resolveOutput(stdout);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
rejectOutput(error);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Interruptible project inventory. The git probe and filesystem fallback share one total wall budget;
|
|
278
|
+
* an unavailable or slow git process can never grant the fallback a fresh timeout.
|
|
279
|
+
*/
|
|
280
|
+
export async function listProjectFilesAsync(root, options = {}) {
|
|
281
|
+
const cfg = normalizeOptions(options, DEFAULT_ASYNC_TIMEOUT_MS);
|
|
282
|
+
const startedAt = Date.now();
|
|
283
|
+
throwIfAborted(cfg.signal);
|
|
284
|
+
if (recursiveRootContainsHome(root))
|
|
285
|
+
return result([], 0, 0);
|
|
286
|
+
if (cfg.maxFiles === 0)
|
|
287
|
+
return result([], 0, 0, "file_limit");
|
|
288
|
+
if (cfg.maxEntries === 0)
|
|
289
|
+
return result([], 0, 0, "entry_limit");
|
|
290
|
+
const remaining = () => cfg.timeoutMs - (Date.now() - startedAt);
|
|
291
|
+
try {
|
|
292
|
+
if (remaining() <= 0)
|
|
293
|
+
return result([], 0, 0, "time_limit");
|
|
294
|
+
const stdout = await gitProjectFiles(root, remaining(), cfg.signal);
|
|
295
|
+
throwIfAborted(cfg.signal);
|
|
296
|
+
if (remaining() <= 0)
|
|
297
|
+
return result([], 0, 0, "time_limit");
|
|
298
|
+
const files = [];
|
|
299
|
+
let entriesVisited = 0;
|
|
300
|
+
for (const rawPath of stdout.split("\n")) {
|
|
301
|
+
throwIfAborted(cfg.signal);
|
|
302
|
+
if (remaining() <= 0)
|
|
303
|
+
return result(files, 0, entriesVisited, "time_limit");
|
|
304
|
+
const projectPath = rawPath.trim();
|
|
305
|
+
if (!projectPath)
|
|
306
|
+
continue;
|
|
307
|
+
if (entriesVisited >= cfg.maxEntries)
|
|
308
|
+
return result(files, 0, entriesVisited, "entry_limit");
|
|
309
|
+
entriesVisited++;
|
|
310
|
+
if (!isSensitiveFilePath(join(root, projectPath)))
|
|
311
|
+
files.push(toPosix(projectPath));
|
|
312
|
+
if (files.length >= cfg.maxFiles)
|
|
313
|
+
return result(files, 0, entriesVisited, "file_limit");
|
|
314
|
+
if (entriesVisited % cfg.yieldEvery === 0) {
|
|
315
|
+
await yieldToEventLoop();
|
|
316
|
+
throwIfAborted(cfg.signal);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// A successful empty result is authoritative (empty repo, everything ignored, or only protected
|
|
320
|
+
// paths). Falling back would violate .gitignore/protected-file semantics.
|
|
321
|
+
return result(files, 0, entriesVisited);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
throwIfAborted(cfg.signal);
|
|
325
|
+
// Not a git repo, git unavailable, or its bounded probe expired: fall through with only the budget
|
|
326
|
+
// that remains. A timeout is intentionally not reset for the filesystem traversal.
|
|
327
|
+
}
|
|
328
|
+
const timeoutMs = remaining();
|
|
329
|
+
if (timeoutMs <= 0)
|
|
330
|
+
return result([], 0, 0, "time_limit");
|
|
331
|
+
return walkFilesAsync(root, {
|
|
332
|
+
maxFiles: cfg.maxFiles,
|
|
333
|
+
maxDirectories: cfg.maxDirectories,
|
|
334
|
+
maxEntries: cfg.maxEntries,
|
|
335
|
+
timeoutMs,
|
|
336
|
+
signal: cfg.signal,
|
|
337
|
+
yieldEvery: cfg.yieldEvery,
|
|
338
|
+
});
|
|
76
339
|
}
|
|
77
340
|
/** Directory prefixes implied by a set of file paths, e.g. "a/b/c.ts" → "a/", "a/b/". */
|
|
78
341
|
export function dirPrefixes(files) {
|
|
@@ -110,3 +373,12 @@ export function nearestPaths(cwd, p, n = 3) {
|
|
|
110
373
|
.slice(0, n)
|
|
111
374
|
.map((r) => r.item);
|
|
112
375
|
}
|
|
376
|
+
/** Interruptible did-you-mean helper for agent tools. */
|
|
377
|
+
export async function nearestPathsAsync(cwd, p, n = 3, options = {}) {
|
|
378
|
+
if (!p)
|
|
379
|
+
return [];
|
|
380
|
+
const inventory = await listProjectFilesAsync(cwd, options);
|
|
381
|
+
return fuzzyRank(p, inventory.files, (f) => f)
|
|
382
|
+
.slice(0, n)
|
|
383
|
+
.map((ranked) => ranked.item);
|
|
384
|
+
}
|
package/dist/fs-write.js
CHANGED
|
@@ -312,6 +312,11 @@ async function syncDirectory(path) {
|
|
|
312
312
|
}
|
|
313
313
|
/** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
|
|
314
314
|
export async function atomicWriteText(path, content, options = {}) {
|
|
315
|
+
const throwIfCancelled = () => {
|
|
316
|
+
if (options.signal?.aborted)
|
|
317
|
+
throw new Error(`write cancelled before commit: ${path}`);
|
|
318
|
+
};
|
|
319
|
+
throwIfCancelled();
|
|
315
320
|
if (options.boundary) {
|
|
316
321
|
if (resolve(path) !== resolve(options.boundary.target))
|
|
317
322
|
throw new FileChangedError(path);
|
|
@@ -393,6 +398,7 @@ export async function atomicWriteText(path, content, options = {}) {
|
|
|
393
398
|
try {
|
|
394
399
|
// Keep the final parent-identity check and namespace mutation in one JS turn. Node has no portable
|
|
395
400
|
// openat/linkat API; synchronous link is the narrowest available commit boundary.
|
|
401
|
+
throwIfCancelled();
|
|
396
402
|
verifyCommitParent();
|
|
397
403
|
linkSync(temp, target);
|
|
398
404
|
}
|
|
@@ -409,6 +415,7 @@ export async function atomicWriteText(path, content, options = {}) {
|
|
|
409
415
|
// path leaves a verify→commit race; a concurrent replacement can otherwise be silently destroyed.
|
|
410
416
|
const claimed = join(dir, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
|
|
411
417
|
try {
|
|
418
|
+
throwIfCancelled();
|
|
412
419
|
verifyCommitParent();
|
|
413
420
|
renameSync(target, claimed);
|
|
414
421
|
}
|
|
@@ -459,6 +466,7 @@ export async function atomicWriteText(path, content, options = {}) {
|
|
|
459
466
|
if (!claimedIdentity)
|
|
460
467
|
throw new Error(`Failed to identify claimed file for ${path}`);
|
|
461
468
|
try {
|
|
469
|
+
throwIfCancelled();
|
|
462
470
|
verifyCommitParent();
|
|
463
471
|
linkSync(temp, target); // atomic create-if-absent: never overwrites an entry created after claim.
|
|
464
472
|
}
|
|
@@ -486,6 +494,7 @@ export async function atomicWriteText(path, content, options = {}) {
|
|
|
486
494
|
}
|
|
487
495
|
}
|
|
488
496
|
else {
|
|
497
|
+
throwIfCancelled();
|
|
489
498
|
verifyCommitParent();
|
|
490
499
|
renameSync(temp, target);
|
|
491
500
|
staged = false;
|