wicked-brain 0.14.0 → 0.14.2
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/package.json
CHANGED
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
unlinkSync,
|
|
12
12
|
existsSync,
|
|
13
13
|
mkdirSync,
|
|
14
|
+
openSync,
|
|
15
|
+
closeSync,
|
|
16
|
+
statSync,
|
|
14
17
|
} from "node:fs";
|
|
15
18
|
import { join, resolve, basename } from "node:path";
|
|
16
19
|
import { homedir } from "node:os";
|
|
@@ -186,6 +189,24 @@ function pidAlive(pid) {
|
|
|
186
189
|
|
|
187
190
|
// ---------- server lifecycle ----------
|
|
188
191
|
|
|
192
|
+
// Open {brain}/_meta/server.log for the detached daemon's stdout+stderr. Without
|
|
193
|
+
// this the server is spawned with stdio:"ignore", so a boot crash (e.g. an
|
|
194
|
+
// EMFILE watch error) vanishes and the only symptom is the "did not become
|
|
195
|
+
// ready" timeout below. Caps growth: starts fresh if the existing log exceeds
|
|
196
|
+
// 5 MB so a long-lived daemon stays diagnosable without growing unbounded.
|
|
197
|
+
function openServerLog(brainPath) {
|
|
198
|
+
const logPath = join(brainPath, "_meta", "server.log");
|
|
199
|
+
let flag = "a";
|
|
200
|
+
try {
|
|
201
|
+
if (statSync(logPath).size > 5 * 1024 * 1024) flag = "w";
|
|
202
|
+
} catch {}
|
|
203
|
+
try {
|
|
204
|
+
return { fd: openSync(logPath, flag), logPath };
|
|
205
|
+
} catch {
|
|
206
|
+
return { fd: null, logPath };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
189
210
|
async function ensureServer(brainPath, opts) {
|
|
190
211
|
const { explicitPort, sourceOverride, noSpawn, log, spawnTimeoutMs = 10_000 } = opts;
|
|
191
212
|
const meta = readMetaConfig(brainPath);
|
|
@@ -227,15 +248,28 @@ async function ensureServer(brainPath, opts) {
|
|
|
227
248
|
const argv = [SERVER_BIN, "--brain", brainPath, "--port", String(port)];
|
|
228
249
|
if (sourcePath) argv.push("--source", sourcePath);
|
|
229
250
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
251
|
+
const { fd: logFd, logPath } = openServerLog(brainPath);
|
|
252
|
+
try {
|
|
253
|
+
const child = spawn(process.execPath, argv, {
|
|
254
|
+
detached: true,
|
|
255
|
+
stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
|
|
256
|
+
windowsHide: true,
|
|
257
|
+
});
|
|
258
|
+
child.unref();
|
|
259
|
+
} finally {
|
|
260
|
+
// Close our copy of the fd in all cases — the child keeps its own. Without
|
|
261
|
+
// the finally a synchronous spawn() throw would leak the descriptor.
|
|
262
|
+
if (logFd !== null) {
|
|
263
|
+
try { closeSync(logFd); } catch {}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (logFd !== null) log(`server logs -> ${logPath}`);
|
|
236
267
|
|
|
237
268
|
if (await waitForHealth(port, spawnTimeoutMs)) return port;
|
|
238
|
-
throw new Error(
|
|
269
|
+
throw new Error(
|
|
270
|
+
`server did not become ready within ${spawnTimeoutMs}ms on port ${port}` +
|
|
271
|
+
(logFd !== null ? ` — see ${logPath} for the cause` : ""),
|
|
272
|
+
);
|
|
239
273
|
} finally {
|
|
240
274
|
releaseLock(lockPath);
|
|
241
275
|
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Recursive fs.watch over brain content with a polling fallback.
|
|
3
|
+
*
|
|
4
|
+
* Watches chunks/, wiki/, memory/ and registered project dirs; on any watch
|
|
5
|
+
* error (EMFILE/ENOSPC, or recursive watch unsupported) it degrades to polling
|
|
6
|
+
* instead of crashing the server.
|
|
7
|
+
*
|
|
8
|
+
* @module lib/file-watcher
|
|
9
|
+
*/
|
|
10
|
+
import { watch, readFileSync, existsSync, readdirSync, statSync, realpathSync } from "node:fs";
|
|
2
11
|
import { join, relative } from "node:path";
|
|
3
12
|
import { createHash } from "node:crypto";
|
|
4
13
|
|
|
@@ -6,6 +15,31 @@ function normalizePath(p) {
|
|
|
6
15
|
return p.replace(/\\/g, "/");
|
|
7
16
|
}
|
|
8
17
|
|
|
18
|
+
// Canonicalize to the real (long) path before watching. On Windows, calling
|
|
19
|
+
// fs.watch on an 8.3 short-name path (e.g. C:\Users\RUNNER~1\AppData\Local\Temp)
|
|
20
|
+
// makes libuv abort the process with "Assertion failed: !_wcsnicmp(...)" in
|
|
21
|
+
// fs-event.c — change events report the long filename, which no longer shares
|
|
22
|
+
// the watched (short) directory prefix. That abort is a native crash, so it
|
|
23
|
+
// bypasses the try/catch → polling fallback below. filename in the callbacks
|
|
24
|
+
// stays relative to the watched dir, so the logical relPath construction is
|
|
25
|
+
// unaffected.
|
|
26
|
+
//
|
|
27
|
+
// realpathSync.native (OS GetFinalPathNameByHandle) is required to expand 8.3
|
|
28
|
+
// short names to their long form; the JS realpathSync only resolves symlinks
|
|
29
|
+
// and leaves short-name components like RUNNER~1 intact. Fall back to the JS
|
|
30
|
+
// implementation, then to the original path.
|
|
31
|
+
function canonicalDir(p) {
|
|
32
|
+
try {
|
|
33
|
+
return realpathSync.native(p);
|
|
34
|
+
} catch {
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(p);
|
|
37
|
+
} catch {
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
9
43
|
const IGNORE_DIRS = new Set([
|
|
10
44
|
"node_modules", ".git", "__pycache__", ".venv", "venv",
|
|
11
45
|
"target", "dist", "build", ".next", ".nuxt", "coverage",
|
|
@@ -23,12 +57,26 @@ export class FileWatcher {
|
|
|
23
57
|
#pollInterval = null;
|
|
24
58
|
#onChangeCallbacks = [];
|
|
25
59
|
#projects = [];
|
|
60
|
+
#watch;
|
|
26
61
|
|
|
27
|
-
constructor(brainPath, db, brainId, projects = []) {
|
|
62
|
+
constructor(brainPath, db, brainId, projects = [], watchFn = watch) {
|
|
28
63
|
this.#brainPath = brainPath;
|
|
29
64
|
this.#db = db;
|
|
30
65
|
this.#brainId = brainId;
|
|
31
66
|
this.#projects = projects;
|
|
67
|
+
// Injectable so tests can drive the FSWatcher 'error' path deterministically
|
|
68
|
+
// without exhausting real file descriptors; defaults to node:fs watch.
|
|
69
|
+
this.#watch = watchFn;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Active fs watcher count (0 in polling mode). Exposed for tests/diagnostics. */
|
|
73
|
+
get watcherCount() {
|
|
74
|
+
return this.#watchers.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True once the watcher has fallen back to polling. For tests/diagnostics. */
|
|
78
|
+
get polling() {
|
|
79
|
+
return this.#pollInterval !== null;
|
|
32
80
|
}
|
|
33
81
|
|
|
34
82
|
onFileChange(callback) {
|
|
@@ -69,18 +117,14 @@ export class FileWatcher {
|
|
|
69
117
|
for (const project of this.#projects) {
|
|
70
118
|
if (!existsSync(project.path)) continue;
|
|
71
119
|
this.#scanAndHashProject(project);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.#watchers.push(watcher);
|
|
81
|
-
} catch {
|
|
82
|
-
// recursive watch not supported — polling fallback already handles this
|
|
83
|
-
}
|
|
120
|
+
const watcher = this.#safeWatch(project.path, (eventType, filename) => {
|
|
121
|
+
if (!filename) return;
|
|
122
|
+
const parts = filename.split(/[/\\]/);
|
|
123
|
+
if (parts.some(p => IGNORE_DIRS.has(p))) return;
|
|
124
|
+
const relPath = normalizePath(`projects/${project.name}/${filename}`);
|
|
125
|
+
this.#debounce(relPath, () => this.#handleProjectChange(project, filename));
|
|
126
|
+
});
|
|
127
|
+
if (watcher) this.#watchers.push(watcher);
|
|
84
128
|
}
|
|
85
129
|
|
|
86
130
|
// If no watchers were set up (Linux), use polling fallback
|
|
@@ -95,18 +139,67 @@ export class FileWatcher {
|
|
|
95
139
|
#tryWatch(dir) {
|
|
96
140
|
const absDir = join(this.#brainPath, dir);
|
|
97
141
|
if (!existsSync(absDir)) return false;
|
|
142
|
+
const watcher = this.#safeWatch(absDir, (eventType, filename) => {
|
|
143
|
+
if (!filename || !filename.endsWith(".md")) return;
|
|
144
|
+
const relPath = normalizePath(`${dir}/${filename}`);
|
|
145
|
+
this.#debounce(relPath, () => this.#handleChange(relPath));
|
|
146
|
+
});
|
|
147
|
+
if (!watcher) return false;
|
|
148
|
+
this.#watchers.push(watcher);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create a recursive fs.watch with an 'error' handler attached. An unhandled
|
|
154
|
+
* 'error' event on an FSWatcher is rethrown by Node and crashes the entire
|
|
155
|
+
* server — that turned a common, recoverable condition (EMFILE when the
|
|
156
|
+
* open-file limit is low on macOS, ENOSPC/inotify exhaustion on Linux) into a
|
|
157
|
+
* fatal boot crash that left the brain permanently stale. On any watch error
|
|
158
|
+
* we degrade to polling, which holds no persistent watch descriptors.
|
|
159
|
+
*
|
|
160
|
+
* Returns the watcher, or null if watching could not be started (caller then
|
|
161
|
+
* relies on the polling fallback). The path is canonicalized first — see
|
|
162
|
+
* canonicalDir; required on Windows to avoid a libuv abort on 8.3 paths.
|
|
163
|
+
*/
|
|
164
|
+
#safeWatch(absPath, listener) {
|
|
165
|
+
if (this.#pollInterval) return null; // already degraded — don't reopen fs watches
|
|
166
|
+
let watcher;
|
|
98
167
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
168
|
+
watcher = this.#watch(canonicalDir(absPath), { recursive: true }, listener);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
// Resource exhaustion (EMFILE/ENFILE/ENOSPC) can throw synchronously, not
|
|
171
|
+
// only as an async 'error' event. Don't leave already-watched dirs in a
|
|
172
|
+
// half-watched state — degrade the whole watcher to polling. Other sync
|
|
173
|
+
// failures (e.g. recursive watch unsupported on older Linux) fall through
|
|
174
|
+
// to the polling fallback in start() when no watcher attaches.
|
|
175
|
+
if (err && (err.code === "EMFILE" || err.code === "ENFILE" || err.code === "ENOSPC")) {
|
|
176
|
+
this.#degradeToPolling();
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (watcher && typeof watcher.on === "function") {
|
|
181
|
+
watcher.on("error", (err) => this.#onWatchError(err));
|
|
109
182
|
}
|
|
183
|
+
return watcher;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A watch backend errored. Log and fall back to polling rather than crash. */
|
|
187
|
+
#onWatchError(err) {
|
|
188
|
+
const code = (err && err.code) || "UNKNOWN";
|
|
189
|
+
console.error(
|
|
190
|
+
`[watcher] fs.watch error (${code}) — degrading to polling: ${(err && err.message) || err}`,
|
|
191
|
+
);
|
|
192
|
+
this.#degradeToPolling();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Tear down fs watchers and switch to polling. Idempotent. */
|
|
196
|
+
#degradeToPolling() {
|
|
197
|
+
if (this.#pollInterval) return; // already polling
|
|
198
|
+
for (const w of this.#watchers) {
|
|
199
|
+
try { w.close(); } catch {}
|
|
200
|
+
}
|
|
201
|
+
this.#watchers = [];
|
|
202
|
+
this.#startPolling();
|
|
110
203
|
}
|
|
111
204
|
|
|
112
205
|
stop() {
|
|
@@ -285,6 +378,7 @@ export class FileWatcher {
|
|
|
285
378
|
}
|
|
286
379
|
|
|
287
380
|
#startPolling() {
|
|
381
|
+
if (this.#pollInterval) return; // already polling — keep a single interval
|
|
288
382
|
console.log("File watcher using polling mode (recursive watch not available)");
|
|
289
383
|
this.#pollInterval = setInterval(() => {
|
|
290
384
|
for (const dir of ["chunks", "wiki", "memory"]) {
|
|
@@ -795,10 +795,10 @@ export class SqliteSearch {
|
|
|
795
795
|
d.id,
|
|
796
796
|
d.path,
|
|
797
797
|
d.brain_id,
|
|
798
|
-
snippet(
|
|
798
|
+
snippet(documents_fts, 2, '<b>', '</b>', '…', 32) AS snippet
|
|
799
799
|
FROM ${attached}.documents_fts f
|
|
800
800
|
JOIN ${attached}.documents d ON d.id = f.id
|
|
801
|
-
WHERE
|
|
801
|
+
WHERE documents_fts MATCH ?
|
|
802
802
|
ORDER BY rank
|
|
803
803
|
LIMIT ?
|
|
804
804
|
`)
|