@karmaniverous/jeeves-watcher 0.2.3 → 0.2.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/README.md CHANGED
@@ -94,6 +94,20 @@ The watcher will:
94
94
 
95
95
  ## Configuration
96
96
 
97
+ ### Environment Variable Substitution
98
+
99
+ Config strings support `${VAR_NAME}` syntax for environment variable injection:
100
+
101
+ ```json
102
+ {
103
+ "embedding": {
104
+ "apiKey": "${GOOGLE_API_KEY}"
105
+ }
106
+ }
107
+ ```
108
+
109
+ If `GOOGLE_API_KEY` is set in the environment, the value is substituted at config load time. **Unresolvable expressions are left untouched** — this allows `${...}` template syntax used in inference rule `set` values (e.g. `${frontmatter.title}`, `${file.path}`) to pass through for later resolution by the rules engine.
110
+
97
111
  ### Watch Paths
98
112
 
99
113
  ```json
@@ -141,6 +141,22 @@
141
141
  "$ref": "#/definitions/__schema52"
142
142
  }
143
143
  ]
144
+ },
145
+ "maxRetries": {
146
+ "description": "Maximum consecutive system-level failures before triggering fatal error. Default: Infinity.",
147
+ "allOf": [
148
+ {
149
+ "$ref": "#/definitions/__schema53"
150
+ }
151
+ ]
152
+ },
153
+ "maxBackoffMs": {
154
+ "description": "Maximum backoff delay in milliseconds for system errors. Default: 60000.",
155
+ "allOf": [
156
+ {
157
+ "$ref": "#/definitions/__schema54"
158
+ }
159
+ ]
144
160
  }
145
161
  },
146
162
  "required": [
@@ -572,6 +588,12 @@
572
588
  },
573
589
  "__schema52": {
574
590
  "type": "number"
591
+ },
592
+ "__schema53": {
593
+ "type": "number"
594
+ },
595
+ "__schema54": {
596
+ "type": "number"
575
597
  }
576
598
  }
577
599
  }
package/dist/cjs/index.js CHANGED
@@ -665,6 +665,16 @@ const jeevesWatcherConfigSchema = zod.z.object({
665
665
  .number()
666
666
  .optional()
667
667
  .describe('Timeout in milliseconds for graceful shutdown.'),
668
+ /** Maximum consecutive system-level failures before triggering fatal error. Default: Infinity. */
669
+ maxRetries: zod.z
670
+ .number()
671
+ .optional()
672
+ .describe('Maximum consecutive system-level failures before triggering fatal error. Default: Infinity.'),
673
+ /** Maximum backoff delay in milliseconds for system errors. Default: 60000. */
674
+ maxBackoffMs: zod.z
675
+ .number()
676
+ .optional()
677
+ .describe('Maximum backoff delay in milliseconds for system errors. Default: 60000.'),
668
678
  });
669
679
 
670
680
  /**
@@ -1069,11 +1079,11 @@ async function extractMarkdown(filePath) {
1069
1079
  }
1070
1080
  async function extractPlaintext(filePath) {
1071
1081
  const raw = await promises.readFile(filePath, 'utf8');
1072
- return { text: raw };
1082
+ return { text: raw.replace(/^\uFEFF/, '') };
1073
1083
  }
1074
1084
  async function extractJson(filePath) {
1075
1085
  const raw = await promises.readFile(filePath, 'utf8');
1076
- const parsed = JSON.parse(raw);
1086
+ const parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
1077
1087
  const json = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
1078
1088
  ? parsed
1079
1089
  : undefined;
@@ -1095,7 +1105,7 @@ async function extractDocx(filePath) {
1095
1105
  }
1096
1106
  async function extractHtml(filePath) {
1097
1107
  const raw = await promises.readFile(filePath, 'utf8');
1098
- const $ = cheerio__namespace.load(raw);
1108
+ const $ = cheerio__namespace.load(raw.replace(/^\uFEFF/, ''));
1099
1109
  $('script, style').remove();
1100
1110
  const text = $('body').text().trim() || $.text().trim();
1101
1111
  return { text };
@@ -1925,6 +1935,112 @@ class VectorStoreClient {
1925
1935
  }
1926
1936
  }
1927
1937
 
1938
+ /**
1939
+ * @module health
1940
+ * Tracks consecutive system-level failures and applies exponential backoff.
1941
+ * Triggers fatal error callback when maxRetries is exceeded.
1942
+ */
1943
+ /**
1944
+ * Tracks system health via consecutive failure count and exponential backoff.
1945
+ */
1946
+ class SystemHealth {
1947
+ consecutiveFailures = 0;
1948
+ maxRetries;
1949
+ maxBackoffMs;
1950
+ baseDelayMs;
1951
+ onFatalError;
1952
+ logger;
1953
+ constructor(options) {
1954
+ this.maxRetries = options.maxRetries ?? Number.POSITIVE_INFINITY;
1955
+ this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
1956
+ this.baseDelayMs = options.baseDelayMs ?? 1000;
1957
+ this.onFatalError = options.onFatalError;
1958
+ this.logger = options.logger;
1959
+ }
1960
+ /**
1961
+ * Record a successful system operation. Resets the failure counter.
1962
+ */
1963
+ recordSuccess() {
1964
+ if (this.consecutiveFailures > 0) {
1965
+ this.logger.info({ previousFailures: this.consecutiveFailures }, 'System health recovered');
1966
+ }
1967
+ this.consecutiveFailures = 0;
1968
+ }
1969
+ /**
1970
+ * Record a system-level failure. If maxRetries is exceeded, triggers fatal error.
1971
+ *
1972
+ * @param error - The error that occurred.
1973
+ * @returns Whether the watcher should continue (false = fatal).
1974
+ */
1975
+ recordFailure(error) {
1976
+ this.consecutiveFailures += 1;
1977
+ this.logger.error({
1978
+ consecutiveFailures: this.consecutiveFailures,
1979
+ maxRetries: this.maxRetries,
1980
+ err: normalizeError(error),
1981
+ }, 'System-level failure recorded');
1982
+ if (this.consecutiveFailures >= this.maxRetries) {
1983
+ this.logger.fatal({ consecutiveFailures: this.consecutiveFailures }, 'Maximum retries exceeded, triggering fatal error');
1984
+ if (this.onFatalError) {
1985
+ this.onFatalError(error);
1986
+ return false;
1987
+ }
1988
+ throw error instanceof Error
1989
+ ? error
1990
+ : new Error(`Fatal system error: ${String(error)}`);
1991
+ }
1992
+ return true;
1993
+ }
1994
+ /**
1995
+ * Compute the current backoff delay based on consecutive failures.
1996
+ *
1997
+ * @returns Delay in milliseconds.
1998
+ */
1999
+ get currentBackoffMs() {
2000
+ if (this.consecutiveFailures === 0)
2001
+ return 0;
2002
+ const exp = Math.max(0, this.consecutiveFailures - 1);
2003
+ return Math.min(this.maxBackoffMs, this.baseDelayMs * 2 ** exp);
2004
+ }
2005
+ /**
2006
+ * Sleep for the current backoff duration.
2007
+ *
2008
+ * @param signal - Optional abort signal.
2009
+ */
2010
+ async backoff(signal) {
2011
+ const delay = this.currentBackoffMs;
2012
+ if (delay <= 0)
2013
+ return;
2014
+ this.logger.warn({ delayMs: delay, consecutiveFailures: this.consecutiveFailures }, 'Backing off before next attempt');
2015
+ await new Promise((resolve, reject) => {
2016
+ const timer = setTimeout(() => {
2017
+ cleanup();
2018
+ resolve();
2019
+ }, delay);
2020
+ const onAbort = () => {
2021
+ cleanup();
2022
+ reject(new Error('Backoff aborted'));
2023
+ };
2024
+ const cleanup = () => {
2025
+ clearTimeout(timer);
2026
+ if (signal)
2027
+ signal.removeEventListener('abort', onAbort);
2028
+ };
2029
+ if (signal) {
2030
+ if (signal.aborted) {
2031
+ onAbort();
2032
+ return;
2033
+ }
2034
+ signal.addEventListener('abort', onAbort, { once: true });
2035
+ }
2036
+ });
2037
+ }
2038
+ /** Current consecutive failure count. */
2039
+ get failures() {
2040
+ return this.consecutiveFailures;
2041
+ }
2042
+ }
2043
+
1928
2044
  /**
1929
2045
  * @module watcher
1930
2046
  * Filesystem watcher wrapping chokidar. I/O: watches files/directories for add/change/unlink events, enqueues to processing queue.
@@ -1937,6 +2053,7 @@ class FileSystemWatcher {
1937
2053
  queue;
1938
2054
  processor;
1939
2055
  logger;
2056
+ health;
1940
2057
  watcher;
1941
2058
  /**
1942
2059
  * Create a new FileSystemWatcher.
@@ -1945,12 +2062,20 @@ class FileSystemWatcher {
1945
2062
  * @param queue - The event queue.
1946
2063
  * @param processor - The document processor.
1947
2064
  * @param logger - The logger instance.
2065
+ * @param options - Optional health/fatal error options.
1948
2066
  */
1949
- constructor(config, queue, processor, logger) {
2067
+ constructor(config, queue, processor, logger, options = {}) {
1950
2068
  this.config = config;
1951
2069
  this.queue = queue;
1952
2070
  this.processor = processor;
1953
2071
  this.logger = logger;
2072
+ const healthOptions = {
2073
+ maxRetries: options.maxRetries,
2074
+ maxBackoffMs: options.maxBackoffMs,
2075
+ onFatalError: options.onFatalError,
2076
+ logger,
2077
+ };
2078
+ this.health = new SystemHealth(healthOptions);
1954
2079
  }
1955
2080
  /**
1956
2081
  * Start watching the filesystem and processing events.
@@ -1967,18 +2092,19 @@ class FileSystemWatcher {
1967
2092
  });
1968
2093
  this.watcher.on('add', (path) => {
1969
2094
  this.logger.debug({ path }, 'File added');
1970
- this.queue.enqueue({ type: 'create', path, priority: 'normal' }, () => this.processor.processFile(path));
2095
+ this.queue.enqueue({ type: 'create', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.processFile(path)));
1971
2096
  });
1972
2097
  this.watcher.on('change', (path) => {
1973
2098
  this.logger.debug({ path }, 'File changed');
1974
- this.queue.enqueue({ type: 'modify', path, priority: 'normal' }, () => this.processor.processFile(path));
2099
+ this.queue.enqueue({ type: 'modify', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.processFile(path)));
1975
2100
  });
1976
2101
  this.watcher.on('unlink', (path) => {
1977
2102
  this.logger.debug({ path }, 'File removed');
1978
- this.queue.enqueue({ type: 'delete', path, priority: 'normal' }, () => this.processor.deleteFile(path));
2103
+ this.queue.enqueue({ type: 'delete', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.deleteFile(path)));
1979
2104
  });
1980
2105
  this.watcher.on('error', (error) => {
1981
2106
  this.logger.error({ err: normalizeError(error) }, 'Watcher error');
2107
+ this.health.recordFailure(error);
1982
2108
  });
1983
2109
  this.queue.process();
1984
2110
  this.logger.info({ paths: this.config.paths }, 'Filesystem watcher started');
@@ -1993,6 +2119,30 @@ class FileSystemWatcher {
1993
2119
  this.logger.info('Filesystem watcher stopped');
1994
2120
  }
1995
2121
  }
2122
+ /**
2123
+ * Get the system health tracker.
2124
+ */
2125
+ get systemHealth() {
2126
+ return this.health;
2127
+ }
2128
+ /**
2129
+ * Wrap a processing operation with health tracking.
2130
+ * On success, resets the failure counter.
2131
+ * On failure, records the failure and applies backoff.
2132
+ */
2133
+ async wrapProcessing(fn) {
2134
+ try {
2135
+ await this.health.backoff();
2136
+ await fn();
2137
+ this.health.recordSuccess();
2138
+ }
2139
+ catch (error) {
2140
+ const shouldContinue = this.health.recordFailure(error);
2141
+ if (!shouldContinue) {
2142
+ await this.stop();
2143
+ }
2144
+ }
2145
+ }
1996
2146
  }
1997
2147
 
1998
2148
  /**
@@ -2068,7 +2218,7 @@ const defaultFactories = {
2068
2218
  compileRules,
2069
2219
  createDocumentProcessor: (config, embeddingProvider, vectorStore, compiledRules, logger) => new DocumentProcessor(config, embeddingProvider, vectorStore, compiledRules, logger),
2070
2220
  createEventQueue: (options) => new EventQueue(options),
2071
- createFileSystemWatcher: (config, queue, processor, logger) => new FileSystemWatcher(config, queue, processor, logger),
2221
+ createFileSystemWatcher: (config, queue, processor, logger, options) => new FileSystemWatcher(config, queue, processor, logger, options),
2072
2222
  createApiServer,
2073
2223
  };
2074
2224
  /**
@@ -2078,6 +2228,7 @@ class JeevesWatcher {
2078
2228
  config;
2079
2229
  configPath;
2080
2230
  factories;
2231
+ runtimeOptions;
2081
2232
  logger;
2082
2233
  watcher;
2083
2234
  queue;
@@ -2090,11 +2241,13 @@ class JeevesWatcher {
2090
2241
  * @param config - The application configuration.
2091
2242
  * @param configPath - Optional config file path to watch for changes.
2092
2243
  * @param factories - Optional component factories (for dependency injection).
2244
+ * @param runtimeOptions - Optional runtime-only options (e.g., onFatalError).
2093
2245
  */
2094
- constructor(config, configPath, factories = {}) {
2246
+ constructor(config, configPath, factories = {}, runtimeOptions = {}) {
2095
2247
  this.config = config;
2096
2248
  this.configPath = configPath;
2097
2249
  this.factories = { ...defaultFactories, ...factories };
2250
+ this.runtimeOptions = runtimeOptions;
2098
2251
  }
2099
2252
  /**
2100
2253
  * Start the watcher, API server, and all components.
@@ -2127,7 +2280,11 @@ class JeevesWatcher {
2127
2280
  rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
2128
2281
  });
2129
2282
  this.queue = queue;
2130
- const watcher = this.factories.createFileSystemWatcher(this.config.watch, queue, processor, logger);
2283
+ const watcher = this.factories.createFileSystemWatcher(this.config.watch, queue, processor, logger, {
2284
+ maxRetries: this.config.maxRetries,
2285
+ maxBackoffMs: this.config.maxBackoffMs,
2286
+ onFatalError: this.runtimeOptions.onFatalError,
2287
+ });
2131
2288
  this.watcher = watcher;
2132
2289
  const server = this.factories.createApiServer({
2133
2290
  processor,
@@ -2236,6 +2393,7 @@ exports.DocumentProcessor = DocumentProcessor;
2236
2393
  exports.EventQueue = EventQueue;
2237
2394
  exports.FileSystemWatcher = FileSystemWatcher;
2238
2395
  exports.JeevesWatcher = JeevesWatcher;
2396
+ exports.SystemHealth = SystemHealth;
2239
2397
  exports.VectorStoreClient = VectorStoreClient;
2240
2398
  exports.apiConfigSchema = apiConfigSchema;
2241
2399
  exports.applyRules = applyRules;
@@ -668,6 +668,16 @@ const jeevesWatcherConfigSchema = z.object({
668
668
  .number()
669
669
  .optional()
670
670
  .describe('Timeout in milliseconds for graceful shutdown.'),
671
+ /** Maximum consecutive system-level failures before triggering fatal error. Default: Infinity. */
672
+ maxRetries: z
673
+ .number()
674
+ .optional()
675
+ .describe('Maximum consecutive system-level failures before triggering fatal error. Default: Infinity.'),
676
+ /** Maximum backoff delay in milliseconds for system errors. Default: 60000. */
677
+ maxBackoffMs: z
678
+ .number()
679
+ .optional()
680
+ .describe('Maximum backoff delay in milliseconds for system errors. Default: 60000.'),
671
681
  });
672
682
 
673
683
  /**
@@ -1072,11 +1082,11 @@ async function extractMarkdown(filePath) {
1072
1082
  }
1073
1083
  async function extractPlaintext(filePath) {
1074
1084
  const raw = await readFile(filePath, 'utf8');
1075
- return { text: raw };
1085
+ return { text: raw.replace(/^\uFEFF/, '') };
1076
1086
  }
1077
1087
  async function extractJson(filePath) {
1078
1088
  const raw = await readFile(filePath, 'utf8');
1079
- const parsed = JSON.parse(raw);
1089
+ const parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
1080
1090
  const json = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
1081
1091
  ? parsed
1082
1092
  : undefined;
@@ -1098,7 +1108,7 @@ async function extractDocx(filePath) {
1098
1108
  }
1099
1109
  async function extractHtml(filePath) {
1100
1110
  const raw = await readFile(filePath, 'utf8');
1101
- const $ = cheerio.load(raw);
1111
+ const $ = cheerio.load(raw.replace(/^\uFEFF/, ''));
1102
1112
  $('script, style').remove();
1103
1113
  const text = $('body').text().trim() || $.text().trim();
1104
1114
  return { text };
@@ -1928,6 +1938,112 @@ class VectorStoreClient {
1928
1938
  }
1929
1939
  }
1930
1940
 
1941
+ /**
1942
+ * @module health
1943
+ * Tracks consecutive system-level failures and applies exponential backoff.
1944
+ * Triggers fatal error callback when maxRetries is exceeded.
1945
+ */
1946
+ /**
1947
+ * Tracks system health via consecutive failure count and exponential backoff.
1948
+ */
1949
+ class SystemHealth {
1950
+ consecutiveFailures = 0;
1951
+ maxRetries;
1952
+ maxBackoffMs;
1953
+ baseDelayMs;
1954
+ onFatalError;
1955
+ logger;
1956
+ constructor(options) {
1957
+ this.maxRetries = options.maxRetries ?? Number.POSITIVE_INFINITY;
1958
+ this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
1959
+ this.baseDelayMs = options.baseDelayMs ?? 1000;
1960
+ this.onFatalError = options.onFatalError;
1961
+ this.logger = options.logger;
1962
+ }
1963
+ /**
1964
+ * Record a successful system operation. Resets the failure counter.
1965
+ */
1966
+ recordSuccess() {
1967
+ if (this.consecutiveFailures > 0) {
1968
+ this.logger.info({ previousFailures: this.consecutiveFailures }, 'System health recovered');
1969
+ }
1970
+ this.consecutiveFailures = 0;
1971
+ }
1972
+ /**
1973
+ * Record a system-level failure. If maxRetries is exceeded, triggers fatal error.
1974
+ *
1975
+ * @param error - The error that occurred.
1976
+ * @returns Whether the watcher should continue (false = fatal).
1977
+ */
1978
+ recordFailure(error) {
1979
+ this.consecutiveFailures += 1;
1980
+ this.logger.error({
1981
+ consecutiveFailures: this.consecutiveFailures,
1982
+ maxRetries: this.maxRetries,
1983
+ err: normalizeError(error),
1984
+ }, 'System-level failure recorded');
1985
+ if (this.consecutiveFailures >= this.maxRetries) {
1986
+ this.logger.fatal({ consecutiveFailures: this.consecutiveFailures }, 'Maximum retries exceeded, triggering fatal error');
1987
+ if (this.onFatalError) {
1988
+ this.onFatalError(error);
1989
+ return false;
1990
+ }
1991
+ throw error instanceof Error
1992
+ ? error
1993
+ : new Error(`Fatal system error: ${String(error)}`);
1994
+ }
1995
+ return true;
1996
+ }
1997
+ /**
1998
+ * Compute the current backoff delay based on consecutive failures.
1999
+ *
2000
+ * @returns Delay in milliseconds.
2001
+ */
2002
+ get currentBackoffMs() {
2003
+ if (this.consecutiveFailures === 0)
2004
+ return 0;
2005
+ const exp = Math.max(0, this.consecutiveFailures - 1);
2006
+ return Math.min(this.maxBackoffMs, this.baseDelayMs * 2 ** exp);
2007
+ }
2008
+ /**
2009
+ * Sleep for the current backoff duration.
2010
+ *
2011
+ * @param signal - Optional abort signal.
2012
+ */
2013
+ async backoff(signal) {
2014
+ const delay = this.currentBackoffMs;
2015
+ if (delay <= 0)
2016
+ return;
2017
+ this.logger.warn({ delayMs: delay, consecutiveFailures: this.consecutiveFailures }, 'Backing off before next attempt');
2018
+ await new Promise((resolve, reject) => {
2019
+ const timer = setTimeout(() => {
2020
+ cleanup();
2021
+ resolve();
2022
+ }, delay);
2023
+ const onAbort = () => {
2024
+ cleanup();
2025
+ reject(new Error('Backoff aborted'));
2026
+ };
2027
+ const cleanup = () => {
2028
+ clearTimeout(timer);
2029
+ if (signal)
2030
+ signal.removeEventListener('abort', onAbort);
2031
+ };
2032
+ if (signal) {
2033
+ if (signal.aborted) {
2034
+ onAbort();
2035
+ return;
2036
+ }
2037
+ signal.addEventListener('abort', onAbort, { once: true });
2038
+ }
2039
+ });
2040
+ }
2041
+ /** Current consecutive failure count. */
2042
+ get failures() {
2043
+ return this.consecutiveFailures;
2044
+ }
2045
+ }
2046
+
1931
2047
  /**
1932
2048
  * @module watcher
1933
2049
  * Filesystem watcher wrapping chokidar. I/O: watches files/directories for add/change/unlink events, enqueues to processing queue.
@@ -1940,6 +2056,7 @@ class FileSystemWatcher {
1940
2056
  queue;
1941
2057
  processor;
1942
2058
  logger;
2059
+ health;
1943
2060
  watcher;
1944
2061
  /**
1945
2062
  * Create a new FileSystemWatcher.
@@ -1948,12 +2065,20 @@ class FileSystemWatcher {
1948
2065
  * @param queue - The event queue.
1949
2066
  * @param processor - The document processor.
1950
2067
  * @param logger - The logger instance.
2068
+ * @param options - Optional health/fatal error options.
1951
2069
  */
1952
- constructor(config, queue, processor, logger) {
2070
+ constructor(config, queue, processor, logger, options = {}) {
1953
2071
  this.config = config;
1954
2072
  this.queue = queue;
1955
2073
  this.processor = processor;
1956
2074
  this.logger = logger;
2075
+ const healthOptions = {
2076
+ maxRetries: options.maxRetries,
2077
+ maxBackoffMs: options.maxBackoffMs,
2078
+ onFatalError: options.onFatalError,
2079
+ logger,
2080
+ };
2081
+ this.health = new SystemHealth(healthOptions);
1957
2082
  }
1958
2083
  /**
1959
2084
  * Start watching the filesystem and processing events.
@@ -1970,18 +2095,19 @@ class FileSystemWatcher {
1970
2095
  });
1971
2096
  this.watcher.on('add', (path) => {
1972
2097
  this.logger.debug({ path }, 'File added');
1973
- this.queue.enqueue({ type: 'create', path, priority: 'normal' }, () => this.processor.processFile(path));
2098
+ this.queue.enqueue({ type: 'create', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.processFile(path)));
1974
2099
  });
1975
2100
  this.watcher.on('change', (path) => {
1976
2101
  this.logger.debug({ path }, 'File changed');
1977
- this.queue.enqueue({ type: 'modify', path, priority: 'normal' }, () => this.processor.processFile(path));
2102
+ this.queue.enqueue({ type: 'modify', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.processFile(path)));
1978
2103
  });
1979
2104
  this.watcher.on('unlink', (path) => {
1980
2105
  this.logger.debug({ path }, 'File removed');
1981
- this.queue.enqueue({ type: 'delete', path, priority: 'normal' }, () => this.processor.deleteFile(path));
2106
+ this.queue.enqueue({ type: 'delete', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.deleteFile(path)));
1982
2107
  });
1983
2108
  this.watcher.on('error', (error) => {
1984
2109
  this.logger.error({ err: normalizeError(error) }, 'Watcher error');
2110
+ this.health.recordFailure(error);
1985
2111
  });
1986
2112
  this.queue.process();
1987
2113
  this.logger.info({ paths: this.config.paths }, 'Filesystem watcher started');
@@ -1996,6 +2122,30 @@ class FileSystemWatcher {
1996
2122
  this.logger.info('Filesystem watcher stopped');
1997
2123
  }
1998
2124
  }
2125
+ /**
2126
+ * Get the system health tracker.
2127
+ */
2128
+ get systemHealth() {
2129
+ return this.health;
2130
+ }
2131
+ /**
2132
+ * Wrap a processing operation with health tracking.
2133
+ * On success, resets the failure counter.
2134
+ * On failure, records the failure and applies backoff.
2135
+ */
2136
+ async wrapProcessing(fn) {
2137
+ try {
2138
+ await this.health.backoff();
2139
+ await fn();
2140
+ this.health.recordSuccess();
2141
+ }
2142
+ catch (error) {
2143
+ const shouldContinue = this.health.recordFailure(error);
2144
+ if (!shouldContinue) {
2145
+ await this.stop();
2146
+ }
2147
+ }
2148
+ }
1999
2149
  }
2000
2150
 
2001
2151
  /**
@@ -2071,7 +2221,7 @@ const defaultFactories = {
2071
2221
  compileRules,
2072
2222
  createDocumentProcessor: (config, embeddingProvider, vectorStore, compiledRules, logger) => new DocumentProcessor(config, embeddingProvider, vectorStore, compiledRules, logger),
2073
2223
  createEventQueue: (options) => new EventQueue(options),
2074
- createFileSystemWatcher: (config, queue, processor, logger) => new FileSystemWatcher(config, queue, processor, logger),
2224
+ createFileSystemWatcher: (config, queue, processor, logger, options) => new FileSystemWatcher(config, queue, processor, logger, options),
2075
2225
  createApiServer,
2076
2226
  };
2077
2227
  /**
@@ -2081,6 +2231,7 @@ class JeevesWatcher {
2081
2231
  config;
2082
2232
  configPath;
2083
2233
  factories;
2234
+ runtimeOptions;
2084
2235
  logger;
2085
2236
  watcher;
2086
2237
  queue;
@@ -2093,11 +2244,13 @@ class JeevesWatcher {
2093
2244
  * @param config - The application configuration.
2094
2245
  * @param configPath - Optional config file path to watch for changes.
2095
2246
  * @param factories - Optional component factories (for dependency injection).
2247
+ * @param runtimeOptions - Optional runtime-only options (e.g., onFatalError).
2096
2248
  */
2097
- constructor(config, configPath, factories = {}) {
2249
+ constructor(config, configPath, factories = {}, runtimeOptions = {}) {
2098
2250
  this.config = config;
2099
2251
  this.configPath = configPath;
2100
2252
  this.factories = { ...defaultFactories, ...factories };
2253
+ this.runtimeOptions = runtimeOptions;
2101
2254
  }
2102
2255
  /**
2103
2256
  * Start the watcher, API server, and all components.
@@ -2130,7 +2283,11 @@ class JeevesWatcher {
2130
2283
  rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
2131
2284
  });
2132
2285
  this.queue = queue;
2133
- const watcher = this.factories.createFileSystemWatcher(this.config.watch, queue, processor, logger);
2286
+ const watcher = this.factories.createFileSystemWatcher(this.config.watch, queue, processor, logger, {
2287
+ maxRetries: this.config.maxRetries,
2288
+ maxBackoffMs: this.config.maxBackoffMs,
2289
+ onFatalError: this.runtimeOptions.onFatalError,
2290
+ });
2134
2291
  this.watcher = watcher;
2135
2292
  const server = this.factories.createApiServer({
2136
2293
  processor,