wicked-brain 0.14.1 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "type": "module",
5
5
  "description": "Digital brain as skills for AI coding CLIs — no vector DB, no embeddings, no infrastructure",
6
6
  "keywords": [
@@ -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 child = spawn(process.execPath, argv, {
231
- detached: true,
232
- stdio: "ignore",
233
- windowsHide: true,
234
- });
235
- child.unref();
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(`server did not become ready within ${spawnTimeoutMs}ms on port ${port}`);
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,3 +1,12 @@
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
+ */
1
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";
@@ -48,12 +57,26 @@ export class FileWatcher {
48
57
  #pollInterval = null;
49
58
  #onChangeCallbacks = [];
50
59
  #projects = [];
60
+ #watch;
51
61
 
52
- constructor(brainPath, db, brainId, projects = []) {
62
+ constructor(brainPath, db, brainId, projects = [], watchFn = watch) {
53
63
  this.#brainPath = brainPath;
54
64
  this.#db = db;
55
65
  this.#brainId = brainId;
56
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;
57
80
  }
58
81
 
59
82
  onFileChange(callback) {
@@ -94,18 +117,14 @@ export class FileWatcher {
94
117
  for (const project of this.#projects) {
95
118
  if (!existsSync(project.path)) continue;
96
119
  this.#scanAndHashProject(project);
97
- try {
98
- const watcher = watch(canonicalDir(project.path), { recursive: true }, (eventType, filename) => {
99
- if (!filename) return;
100
- const parts = filename.split(/[/\\]/);
101
- if (parts.some(p => IGNORE_DIRS.has(p))) return;
102
- const relPath = normalizePath(`projects/${project.name}/${filename}`);
103
- this.#debounce(relPath, () => this.#handleProjectChange(project, filename));
104
- });
105
- this.#watchers.push(watcher);
106
- } catch {
107
- // recursive watch not supported — polling fallback already handles this
108
- }
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);
109
128
  }
110
129
 
111
130
  // If no watchers were set up (Linux), use polling fallback
@@ -120,18 +139,67 @@ export class FileWatcher {
120
139
  #tryWatch(dir) {
121
140
  const absDir = join(this.#brainPath, dir);
122
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;
123
167
  try {
124
- const watcher = watch(canonicalDir(absDir), { recursive: true }, (eventType, filename) => {
125
- if (!filename || !filename.endsWith(".md")) return;
126
- const relPath = normalizePath(`${dir}/${filename}`);
127
- this.#debounce(relPath, () => this.#handleChange(relPath));
128
- });
129
- this.#watchers.push(watcher);
130
- return true;
131
- } catch {
132
- // recursive watch not supported (Linux) — polling fallback handles it
133
- return false;
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));
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 {}
134
200
  }
201
+ this.#watchers = [];
202
+ this.#startPolling();
135
203
  }
136
204
 
137
205
  stop() {
@@ -310,6 +378,7 @@ export class FileWatcher {
310
378
  }
311
379
 
312
380
  #startPolling() {
381
+ if (this.#pollInterval) return; // already polling — keep a single interval
313
382
  console.log("File watcher using polling mode (recursive watch not available)");
314
383
  this.#pollInterval = setInterval(() => {
315
384
  for (const dir of ["chunks", "wiki", "memory"]) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "type": "module",
5
5
  "description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
6
6
  "keywords": [