@karmaniverous/jeeves-watcher 0.18.8 → 0.18.10

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.
@@ -7,10 +7,12 @@ import { packageDirectorySync } from 'package-directory';
7
7
  import { readFileSync, existsSync, mkdirSync, renameSync, statSync, readdirSync } from 'node:fs';
8
8
  import { cosmiconfig } from 'cosmiconfig';
9
9
  import { ZodError } from 'zod';
10
- import { readdir, stat } from 'node:fs/promises';
10
+ import { readdir, stat, access } from 'node:fs/promises';
11
11
  import { parallel } from 'radash';
12
12
  import ignore from 'ignore';
13
13
  import picomatch from 'picomatch';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
14
16
 
15
17
  /**
16
18
  * @module api/handlers/configMerge
@@ -224,7 +226,7 @@ async function startFromConfig(configPath, descriptor) {
224
226
  const config = await loadConfig(resolvedPath);
225
227
  // Dynamic import breaks the circular dependency between this module
226
228
  // and app/index.ts (which defines JeevesWatcher and re-exports startFromConfig).
227
- const { JeevesWatcher } = await import('./index-BjghvUnc.js');
229
+ const { JeevesWatcher } = await import('./index-B_g6RfR8.js');
228
230
  const app = new JeevesWatcher(config, resolvedPath, descriptor);
229
231
  installShutdownHandlers(() => app.stop());
230
232
  await app.start();
@@ -613,6 +615,77 @@ async function retry(fn, options) {
613
615
  throw lastError;
614
616
  }
615
617
 
618
+ /**
619
+ * @module vcs/gitExec
620
+ * Shared git execution utilities.
621
+ */
622
+ /** Promisified execFile for git commands. */
623
+ const execFileAsync = promisify(execFile);
624
+ /**
625
+ * Stage files via stdin to avoid ENAMETOOLONG on Windows.
626
+ *
627
+ * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
628
+ * through stdin (NUL-delimited) instead of passed as command-line
629
+ * arguments. This sidesteps the Windows CreateProcessW 32 767-char
630
+ * limit that triggers ENAMETOOLONG when batches contain many files
631
+ * with long absolute paths.
632
+ *
633
+ * @param files - Absolute paths of files to stage.
634
+ * @param cwd - Repository root (working directory for git).
635
+ */
636
+ function gitAddViaStdin(files, cwd) {
637
+ return new Promise((resolve, reject) => {
638
+ const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
639
+ if (error)
640
+ reject(error);
641
+ else
642
+ resolve();
643
+ });
644
+ if (!child.stdin) {
645
+ reject(new Error('Failed to open stdin for git add'));
646
+ return;
647
+ }
648
+ // Suppress EPIPE — expected if git exits before we finish writing
649
+ child.stdin.on('error', (err) => {
650
+ if (err.code !== 'EPIPE') {
651
+ reject(err);
652
+ }
653
+ });
654
+ child.stdin.end(files.join('\0'));
655
+ });
656
+ }
657
+ /**
658
+ * Normalize a path for case-insensitive comparison on Windows.
659
+ * On Windows, lowercases the entire path; on other platforms, returns as-is.
660
+ *
661
+ * @param p - The path string to normalize.
662
+ * @param platform - The platform to check against (default: `process.platform`).
663
+ * @returns The normalized path.
664
+ */
665
+ function normalizePathCase(p, platform = process.platform) {
666
+ return platform === 'win32' ? p.toLowerCase() : p;
667
+ }
668
+ /**
669
+ * Find the longest-prefix-match root for a normalized path.
670
+ * Roots must be sorted longest-first for correct nested matching.
671
+ *
672
+ * @param roots - Root paths sorted longest-first.
673
+ * @param normalizedPath - Normalized absolute path (forward slashes).
674
+ * @param platform - The platform to check against (default: `process.platform`).
675
+ * @returns The matching root, or undefined.
676
+ */
677
+ function findRootForPath(roots, normalizedPath, platform = process.platform) {
678
+ const comparePath = normalizePathCase(normalizedPath, platform);
679
+ for (const root of roots) {
680
+ const compareRoot = normalizePathCase(root, platform);
681
+ if (comparePath === compareRoot ||
682
+ comparePath.startsWith(compareRoot + '/')) {
683
+ return root;
684
+ }
685
+ }
686
+ return undefined;
687
+ }
688
+
616
689
  /**
617
690
  * Best-effort base directory inference for a glob pattern.
618
691
  *
@@ -830,7 +903,7 @@ function groupByRoot(filePaths, watchPaths) {
830
903
  /** Default page size for prune scroll (smaller than search to reduce connection strain). */
831
904
  const PRUNE_SCROLL_PAGE_SIZE = 500;
832
905
  /** Max retry attempts for scroll page failures. */
833
- const SCROLL_RETRY_ATTEMPTS = 3;
906
+ const SCROLL_RETRY_ATTEMPTS = 5;
834
907
  /** Base delay for scroll retry backoff (ms). */
835
908
  const SCROLL_RETRY_BASE_DELAY_MS = 1000;
836
909
  /**
@@ -856,26 +929,42 @@ async function scrollPageWithRetry(vectorStore, cursor, logger) {
856
929
  }
857
930
  throw lastError;
858
931
  }
932
+ /** Concurrency limit for fs.access() disk-existence checks. */
933
+ const FS_ACCESS_CONCURRENCY = 100;
859
934
  /**
860
- * Compute the plan for a prune operation.
861
- * Scrolls all Qdrant points page-by-page with retry/resume,
862
- * checks each file_path against watch scope + gitignore,
863
- * returns the list of orphaned point IDs and the plan.
935
+ * Compute the plan for a prune operation using a filter-first approach.
936
+ *
937
+ * 1. Enumerate all watched files from the filesystem → `watchedFiles` set.
938
+ * 2. Scroll Qdrant points. For each point:
939
+ * - No `file_path` → orphan.
940
+ * - `file_path` not in `watchedFiles` → orphan (out of watch scope).
941
+ * - `file_path` in `watchedFiles` → check disk existence via `fs.access()`.
942
+ * If missing from disk → orphan (deleted file still in index).
943
+ * 3. Batch `fs.access()` checks with bounded concurrency.
864
944
  */
865
945
  async function computePrunePlan(deps) {
866
946
  const { config, vectorStore, gitignoreFilter, logger } = deps;
867
947
  if (!vectorStore) {
868
948
  throw new Error('vectorStore is required for prune scope');
869
949
  }
950
+ // Step 1: Build set of watched files from filesystem
951
+ const isGitignored = createIsGitignored(gitignoreFilter);
952
+ const watchedFileList = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
953
+ const watchedFiles = new Set(watchedFileList.map((f) => normalizePathCase(normalizeSlashes(f))));
954
+ logger.info({ watchedFileCount: watchedFiles.size }, 'Prune: enumerated watched files from filesystem');
955
+ // Step 2: Scroll Qdrant and classify points
870
956
  const seenFiles = new Set();
871
957
  const orphanedFiles = new Set();
958
+ const diskCheckFiles = new Set();
872
959
  const orphanedIds = [];
960
+ // Map from file_path → point IDs, for points needing disk-existence check
961
+ const diskCheckPointIds = new Map();
873
962
  let totalPoints = 0;
874
963
  let incomplete = false;
875
964
  let cursor;
876
965
  try {
877
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cursor is updated each iteration
878
- while (true) {
966
+ let hasMore = true;
967
+ while (hasMore) {
879
968
  const page = await scrollPageWithRetry(vectorStore, cursor, logger);
880
969
  for (const point of page.points) {
881
970
  totalPoints++;
@@ -884,32 +973,62 @@ async function computePrunePlan(deps) {
884
973
  orphanedIds.push(point.id);
885
974
  continue;
886
975
  }
887
- if (seenFiles.has(filePath)) {
888
- if (orphanedFiles.has(filePath)) {
976
+ const normalized = normalizePathCase(normalizeSlashes(filePath));
977
+ if (seenFiles.has(normalized)) {
978
+ if (orphanedFiles.has(normalized)) {
889
979
  orphanedIds.push(point.id);
890
980
  }
981
+ else if (diskCheckFiles.has(normalized)) {
982
+ // Already queued for disk check — track its ID
983
+ const ids = diskCheckPointIds.get(normalized);
984
+ if (ids)
985
+ ids.push(point.id);
986
+ }
891
987
  continue;
892
988
  }
893
- seenFiles.add(filePath);
894
- const watched = isPathWatched(filePath, extractWatchPathStrings(config.watch.paths), config.watch.ignored);
895
- const gitignored = gitignoreFilter
896
- ? gitignoreFilter.isIgnored(filePath)
897
- : false;
898
- if (!watched || gitignored) {
899
- orphanedFiles.add(filePath);
989
+ seenFiles.add(normalized);
990
+ if (!watchedFiles.has(normalized)) {
991
+ // Not in watch scope → definitely orphaned
992
+ orphanedFiles.add(normalized);
900
993
  orphanedIds.push(point.id);
901
994
  }
995
+ else {
996
+ // In watch scope → needs disk-existence check
997
+ diskCheckFiles.add(normalized);
998
+ diskCheckPointIds.set(normalized, [point.id]);
999
+ }
1000
+ }
1001
+ // Progress logging every 100K points
1002
+ if (totalPoints > 0 && totalPoints % 100000 === 0) {
1003
+ logger.info({ totalPoints, orphanedCount: orphanedIds.length }, 'Prune scroll progress');
1004
+ }
1005
+ if (!page.nextCursor) {
1006
+ hasMore = false;
1007
+ }
1008
+ else {
1009
+ cursor = page.nextCursor;
902
1010
  }
903
- if (!page.nextCursor)
904
- break;
905
- cursor = page.nextCursor;
906
1011
  }
907
1012
  }
908
1013
  catch (error) {
909
- // All retries exhausted - return partial results
910
1014
  logger.error({ err: normalizeError(error), totalPoints, cursor }, 'Prune scroll failed after retries; returning partial plan');
911
1015
  incomplete = true;
912
1016
  }
1017
+ // Step 3: Batch fs.access() checks for files in watch scope
1018
+ const filesToCheck = [...diskCheckFiles];
1019
+ await parallel(FS_ACCESS_CONCURRENCY, filesToCheck, async (filePath) => {
1020
+ try {
1021
+ await access(filePath);
1022
+ }
1023
+ catch {
1024
+ // File doesn't exist on disk → orphaned
1025
+ orphanedFiles.add(filePath);
1026
+ const pointIds = diskCheckPointIds.get(filePath);
1027
+ if (pointIds) {
1028
+ orphanedIds.push(...pointIds);
1029
+ }
1030
+ }
1031
+ });
913
1032
  const orphanedFileList = [...orphanedFiles];
914
1033
  const byRoot = groupByRoot(orphanedFileList, extractWatchPathStrings(config.watch.paths));
915
1034
  return {
@@ -967,6 +1086,12 @@ async function executeReindex(deps, scope, path, dryRun = false) {
967
1086
  deps.queue?.resume();
968
1087
  return { filesProcessed: 0, durationMs: 0, errors: 0, plan };
969
1088
  }
1089
+ // Abort live prune when plan is incomplete — never silently delete partial data
1090
+ if (plan.incomplete) {
1091
+ deps.queue?.resume();
1092
+ logger.error({ totalPoints: plan.total, pointsToDelete: plan.toDelete }, 'Aborting live prune — scroll was incomplete; partial deletes are unsafe');
1093
+ return { filesProcessed: 0, durationMs: 0, errors: 1, plan };
1094
+ }
970
1095
  // Execute prune
971
1096
  reindexTracker?.start(scope);
972
1097
  const startTime = Date.now();
@@ -1456,4 +1581,4 @@ const watcherDescriptor = {
1456
1581
  const program = createServiceCli(watcherDescriptor);
1457
1582
  program.parse();
1458
1583
 
1459
- export { CONFIG_WATCH_VALID_SCOPES as C, GitignoreFilter as G, VALID_REINDEX_SCOPES as V, normalizeSlashes as a, startFromConfig as b, executeReindex as e, getWatchRootBases as g, isPathWatched as i, loadConfig as l, mergeInferenceRules as m, normalizeError as n, retry as r, sleep as s };
1584
+ export { CONFIG_WATCH_VALID_SCOPES as C, GitignoreFilter as G, VALID_REINDEX_SCOPES as V, executeReindex as a, gitAddViaStdin as b, normalizePathCase as c, normalizeSlashes as d, execFileAsync as e, findRootForPath as f, getWatchRootBases as g, startFromConfig as h, isPathWatched as i, loadConfig as l, mergeInferenceRules as m, normalizeError as n, retry as r, sleep as s };
package/dist/index.d.ts CHANGED
@@ -1276,7 +1276,7 @@ interface PushError {
1276
1276
  * Per-root VCS manager for git-backed content versioning.
1277
1277
  *
1278
1278
  * Each VCS-enabled watch root gets its own VcsManager instance, which owns
1279
- * a debounced commit pipeline: file changes are batched, staged, committed
1279
+ * a throttled commit pipeline: file changes are batched, staged, committed
1280
1280
  * (with optional AI-generated messages), and optionally pushed to a remote.
1281
1281
  *
1282
1282
  * Concurrency: only one commit is in-flight at a time per root. Index.lock
@@ -1293,8 +1293,9 @@ declare class VcsManager {
1293
1293
  private readonly pendingReversions;
1294
1294
  private readonly _pushErrors;
1295
1295
  private readonly squashManager;
1296
- private debounceTimer;
1296
+ private throttleTimer;
1297
1297
  private commitInFlight;
1298
+ private consecutiveCommitFailures;
1298
1299
  private started;
1299
1300
  private isBaseline;
1300
1301
  private _lastPushTime;
@@ -1315,7 +1316,7 @@ declare class VcsManager {
1315
1316
  */
1316
1317
  endBaseline(): void;
1317
1318
  /**
1318
- * Add a file to the pending set and reset the debounce timer.
1319
+ * Add a file to the pending set and start the throttle timer.
1319
1320
  * If the pending set exceeds maxBatchSize, flush immediately.
1320
1321
  *
1321
1322
  * @param filePath - Absolute path of the changed file.
@@ -1347,15 +1348,21 @@ declare class VcsManager {
1347
1348
  */
1348
1349
  private takeBatch;
1349
1350
  /**
1350
- * Clear the debounce timer.
1351
+ * Clear the throttle timer.
1351
1352
  */
1352
- private clearDebounce;
1353
+ private clearThrottle;
1353
1354
  /**
1354
- * Reset the debounce timer to fire flush() after commitDebounceMs.
1355
+ * Start the throttle timer to fire flush() after commitThrottleMs.
1356
+ * If a timer is already running, do not reset it — this is throttle, not debounce.
1355
1357
  */
1356
- private resetDebounce;
1358
+ private resetThrottle;
1357
1359
  /**
1358
- * Commit a batch of files to the git repo with index.lock retry.
1360
+ * Check for and remove stale index.lock files before retrying.
1361
+ */
1362
+ private removeStaleLock;
1363
+ /**
1364
+ * Commit a batch of files to the git repo with index.lock retry,
1365
+ * stale lock detection, circuit breaker, and re-queue cap.
1359
1366
  */
1360
1367
  private commitBatch;
1361
1368
  }
@@ -1388,8 +1395,8 @@ declare class VcsCoordinator {
1388
1395
  /**
1389
1396
  * Signal that the initial filesystem scan is complete.
1390
1397
  *
1391
- * Flushes each manager's debounce buffer before ending baseline mode, ensuring
1392
- * any files still pending in the VCS debounce are committed with a
1398
+ * Flushes each manager's throttle buffer before ending baseline mode, ensuring
1399
+ * any files still pending in the VCS throttle are committed with a
1393
1400
  * `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
1394
1401
  * until all managers have completed their flush.
1395
1402
  */