wicked-brain 0.14.1 → 0.14.3
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,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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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"]) {
|
|
@@ -13,6 +13,12 @@ import { join } from "node:path";
|
|
|
13
13
|
import { getBusDb, isBusAvailable, emitEvent } from "./bus.mjs";
|
|
14
14
|
import { promoteFact } from "./memory-promoter.mjs";
|
|
15
15
|
|
|
16
|
+
// Subscriber identity on the bus. Used both to register the subscription and to
|
|
17
|
+
// locate its cursor for the TTL self-heal — keep them in one place so the two
|
|
18
|
+
// can't drift.
|
|
19
|
+
const PLUGIN = "wicked-brain";
|
|
20
|
+
const FACT_FILTER = "wicked.fact.extracted";
|
|
21
|
+
|
|
16
22
|
/**
|
|
17
23
|
* Start the auto-memorize subscriber.
|
|
18
24
|
* Returns the subscription handle (with .stop()) or null if the bus is unavailable.
|
|
@@ -38,10 +44,22 @@ export async function startMemorySubscriber({ brainPath, brainId, db }) {
|
|
|
38
44
|
|
|
39
45
|
const memoryDir = join(brainPath, "memory");
|
|
40
46
|
|
|
47
|
+
// Self-heal a cursor stranded behind the bus TTL window (e.g. after a long
|
|
48
|
+
// server outage). The subscriber RESUMES its existing cursor, so cursor_init
|
|
49
|
+
// "latest" does not recover a stale one — poll() would throw WB-003 every
|
|
50
|
+
// cycle and auto-memorize would stall until manually reset. Advance it to the
|
|
51
|
+
// latest event before subscribing.
|
|
52
|
+
const healed = fastForwardStaleCursor(busDb, PLUGIN, FACT_FILTER);
|
|
53
|
+
if (healed) {
|
|
54
|
+
console.error(
|
|
55
|
+
`[memory-subscriber] cursor was behind the TTL window; repositioned ${healed.from} -> ${healed.to} to replay survivors`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
const sub = subscribe({
|
|
42
60
|
db: busDb,
|
|
43
|
-
plugin:
|
|
44
|
-
filter:
|
|
61
|
+
plugin: PLUGIN,
|
|
62
|
+
filter: FACT_FILTER,
|
|
45
63
|
cursor_init: "latest",
|
|
46
64
|
pollIntervalMs: 5000,
|
|
47
65
|
maxRetries: 3,
|
|
@@ -84,6 +102,63 @@ export async function startMemorySubscriber({ brainPath, brainId, db }) {
|
|
|
84
102
|
return sub;
|
|
85
103
|
}
|
|
86
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Fast-forward a subscriber cursor that has fallen behind the bus TTL window.
|
|
107
|
+
*
|
|
108
|
+
* After a long server outage the durable cursor can sit below the oldest
|
|
109
|
+
* surviving event; wicked-bus poll() then throws WB-003 ("cursor behind the TTL
|
|
110
|
+
* window") every cycle. The subscriber resumes its existing cursor (cursor_init
|
|
111
|
+
* only applies on first registration), so it never recovers on its own. This
|
|
112
|
+
* mirrors poll()'s WB-003 check and, when behind, repositions the cursor to just
|
|
113
|
+
* before the oldest surviving event so the subscriber still replays everything
|
|
114
|
+
* left in the bus (at-least-once) instead of discarding the survivors.
|
|
115
|
+
*
|
|
116
|
+
* No-op when the cursor is current, when there are no events, or when no cursor
|
|
117
|
+
* exists yet (a fresh subscriber initializes at "latest" anyway). Never throws —
|
|
118
|
+
* a self-heal failure must not block server startup.
|
|
119
|
+
*
|
|
120
|
+
* @param {import('better-sqlite3').Database} busDb
|
|
121
|
+
* @param {string} plugin
|
|
122
|
+
* @param {string} filter event_type_filter the subscriber registered with
|
|
123
|
+
* @returns {{from:number,to:number}|null} the adjustment made, or null for no-op
|
|
124
|
+
*/
|
|
125
|
+
export function fastForwardStaleCursor(busDb, plugin, filter) {
|
|
126
|
+
try {
|
|
127
|
+
const bounds = busDb
|
|
128
|
+
.prepare("SELECT MIN(event_id) AS min_id FROM events")
|
|
129
|
+
.get();
|
|
130
|
+
if (!bounds || bounds.min_id == null) return null; // no events to be behind of
|
|
131
|
+
|
|
132
|
+
const row = busDb
|
|
133
|
+
.prepare(
|
|
134
|
+
`SELECT c.cursor_id AS cursor_id, c.last_event_id AS last_event_id
|
|
135
|
+
FROM subscriptions s
|
|
136
|
+
INNER JOIN cursors c ON c.subscription_id = s.subscription_id
|
|
137
|
+
WHERE s.plugin = ? AND s.role = 'subscriber'
|
|
138
|
+
AND s.event_type_filter = ?
|
|
139
|
+
AND s.deregistered_at IS NULL AND c.deregistered_at IS NULL
|
|
140
|
+
ORDER BY s.registered_at DESC
|
|
141
|
+
LIMIT 1`,
|
|
142
|
+
)
|
|
143
|
+
.get(plugin, filter);
|
|
144
|
+
if (!row) return null; // no existing cursor — fresh subscribe inits at "latest"
|
|
145
|
+
|
|
146
|
+
// Mirror wicked-bus poll(): WB-003 fires when last_event_id < oldest - 1.
|
|
147
|
+
// Reposition to oldest-1 (not latest) so the subscriber replays every event
|
|
148
|
+
// that survived the sweep instead of discarding the backlog.
|
|
149
|
+
const target = bounds.min_id - 1;
|
|
150
|
+
if (row.last_event_id < target) {
|
|
151
|
+
busDb
|
|
152
|
+
.prepare("UPDATE cursors SET last_event_id = ? WHERE cursor_id = ?")
|
|
153
|
+
.run(target, row.cursor_id);
|
|
154
|
+
return { from: row.last_event_id, to: target };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
} catch {
|
|
158
|
+
return null; // never block startup on the self-heal
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
87
162
|
/**
|
|
88
163
|
* Render a memory descriptor as a markdown file with YAML-ish frontmatter.
|
|
89
164
|
* Minimal serializer — no YAML lib. Matches the format used by wicked-brain:memory.
|