@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.
- package/README.md +2 -2
- package/config.schema.json +231 -183
- package/dist/cli/jeeves-watcher/{index-BjghvUnc.js → index-B_g6RfR8.js} +115 -130
- package/dist/cli/jeeves-watcher/index.js +148 -23
- package/dist/index.d.ts +17 -10
- package/dist/index.js +256 -148
- package/package.json +2 -2
|
@@ -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-
|
|
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 =
|
|
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
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
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
|
-
|
|
878
|
-
while (
|
|
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
|
-
|
|
888
|
-
|
|
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(
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1351
|
+
* Clear the throttle timer.
|
|
1351
1352
|
*/
|
|
1352
|
-
private
|
|
1353
|
+
private clearThrottle;
|
|
1353
1354
|
/**
|
|
1354
|
-
*
|
|
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
|
|
1358
|
+
private resetThrottle;
|
|
1357
1359
|
/**
|
|
1358
|
-
*
|
|
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
|
|
1392
|
-
* any files still pending in the VCS
|
|
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
|
*/
|