@karmaniverous/jeeves-watcher 0.18.8 → 0.18.9
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/config.schema.json +228 -180
- package/dist/cli/jeeves-watcher/{index-BjghvUnc.js → index-xP2XrV4W.js} +66 -83
- package/dist/cli/jeeves-watcher/index.js +148 -23
- package/dist/index.d.ts +7 -1
- package/dist/index.js +207 -101
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, DEFAULT_PORTS, getBindAddress } from '@karmaniverous/jeeves';
|
|
3
|
-
import {
|
|
4
|
-
export {
|
|
3
|
+
import { d as normalizeSlashes, n as normalizeError, e as execFileAsync, r as retry, b as gitAddViaStdin, c as normalizePathCase, f as findRootForPath, G as GitignoreFilter, a as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, i as isPathWatched, g as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, l as loadConfig } from './index.js';
|
|
4
|
+
export { h as startFromConfig } from './index.js';
|
|
5
5
|
import { mkdirSync, existsSync, readFileSync, writeFileSync, rmSync, renameSync, readdirSync, statSync } from 'node:fs';
|
|
6
6
|
import { join, dirname, resolve, extname, basename, relative, sep, isAbsolute } from 'node:path';
|
|
7
7
|
import Database from 'better-sqlite3';
|
|
@@ -10,11 +10,9 @@ import Ajv from 'ajv';
|
|
|
10
10
|
import addFormats from 'ajv-formats';
|
|
11
11
|
import picomatch from 'picomatch';
|
|
12
12
|
import { deserialize, serialize } from 'node:v8';
|
|
13
|
-
import { normalizeWatchPaths, extractWatchPathStrings, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
|
|
14
|
-
import { execFile } from 'node:child_process';
|
|
15
|
-
import { promisify } from 'node:util';
|
|
13
|
+
import { normalizeWatchPaths, vcsRetentionConfigSchema, extractWatchPathStrings, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
|
|
16
14
|
import { homedir } from 'node:os';
|
|
17
|
-
import { access, rm, readFile, writeFile, appendFile, mkdir
|
|
15
|
+
import { access, rm, stat, readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
|
|
18
16
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
19
17
|
import { packageDirectorySync } from 'package-directory';
|
|
20
18
|
import { JsonMap } from '@karmaniverous/jsonmap';
|
|
@@ -40,6 +38,8 @@ import mammoth from 'mammoth';
|
|
|
40
38
|
import { MarkdownTextSplitter, RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
|
|
41
39
|
import { QdrantClient } from '@qdrant/js-client-rest';
|
|
42
40
|
import 'cosmiconfig';
|
|
41
|
+
import 'node:child_process';
|
|
42
|
+
import 'node:util';
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* @module api/InitialScanTracker
|
|
@@ -928,77 +928,6 @@ class CommitMessageGenerator {
|
|
|
928
928
|
}
|
|
929
929
|
}
|
|
930
930
|
|
|
931
|
-
/**
|
|
932
|
-
* @module vcs/gitExec
|
|
933
|
-
* Shared git execution utilities.
|
|
934
|
-
*/
|
|
935
|
-
/** Promisified execFile for git commands. */
|
|
936
|
-
const execFileAsync = promisify(execFile);
|
|
937
|
-
/**
|
|
938
|
-
* Stage files via stdin to avoid ENAMETOOLONG on Windows.
|
|
939
|
-
*
|
|
940
|
-
* Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
|
|
941
|
-
* through stdin (NUL-delimited) instead of passed as command-line
|
|
942
|
-
* arguments. This sidesteps the Windows CreateProcessW 32 767-char
|
|
943
|
-
* limit that triggers ENAMETOOLONG when batches contain many files
|
|
944
|
-
* with long absolute paths.
|
|
945
|
-
*
|
|
946
|
-
* @param files - Absolute paths of files to stage.
|
|
947
|
-
* @param cwd - Repository root (working directory for git).
|
|
948
|
-
*/
|
|
949
|
-
function gitAddViaStdin(files, cwd) {
|
|
950
|
-
return new Promise((resolve, reject) => {
|
|
951
|
-
const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
|
|
952
|
-
if (error)
|
|
953
|
-
reject(error);
|
|
954
|
-
else
|
|
955
|
-
resolve();
|
|
956
|
-
});
|
|
957
|
-
if (!child.stdin) {
|
|
958
|
-
reject(new Error('Failed to open stdin for git add'));
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
// Suppress EPIPE — expected if git exits before we finish writing
|
|
962
|
-
child.stdin.on('error', (err) => {
|
|
963
|
-
if (err.code !== 'EPIPE') {
|
|
964
|
-
reject(err);
|
|
965
|
-
}
|
|
966
|
-
});
|
|
967
|
-
child.stdin.end(files.join('\0'));
|
|
968
|
-
});
|
|
969
|
-
}
|
|
970
|
-
/**
|
|
971
|
-
* Normalize a path for case-insensitive comparison on Windows.
|
|
972
|
-
* On Windows, lowercases the entire path; on other platforms, returns as-is.
|
|
973
|
-
*
|
|
974
|
-
* @param p - The path string to normalize.
|
|
975
|
-
* @param platform - The platform to check against (default: `process.platform`).
|
|
976
|
-
* @returns The normalized path.
|
|
977
|
-
*/
|
|
978
|
-
function normalizePathCase(p, platform = process.platform) {
|
|
979
|
-
return platform === 'win32' ? p.toLowerCase() : p;
|
|
980
|
-
}
|
|
981
|
-
/**
|
|
982
|
-
* Find the longest-prefix-match root for a normalized path.
|
|
983
|
-
* Roots must be sorted longest-first for correct nested matching.
|
|
984
|
-
*
|
|
985
|
-
* @param roots - Root paths sorted longest-first.
|
|
986
|
-
* @param normalizedPath - Normalized absolute path (forward slashes).
|
|
987
|
-
* @param platform - The platform to check against (default: `process.platform`).
|
|
988
|
-
* @returns The matching root, or undefined.
|
|
989
|
-
*/
|
|
990
|
-
function findRootForPath(roots, normalizedPath, platform = process.platform) {
|
|
991
|
-
const comparePath = normalizePathCase(normalizedPath, platform);
|
|
992
|
-
for (const root of roots) {
|
|
993
|
-
const compareRoot = normalizePathCase(root, platform);
|
|
994
|
-
if (comparePath === compareRoot ||
|
|
995
|
-
comparePath.startsWith(compareRoot + '/')) {
|
|
996
|
-
return root;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
return undefined;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
931
|
/**
|
|
1003
932
|
* @module vcs/resolveCommitMessageApiKey
|
|
1004
933
|
* Resolves the API key for AI commit message generation.
|
|
@@ -1479,6 +1408,7 @@ class VcsManager {
|
|
|
1479
1408
|
squashManager;
|
|
1480
1409
|
debounceTimer;
|
|
1481
1410
|
commitInFlight = Promise.resolve();
|
|
1411
|
+
consecutiveCommitFailures = 0;
|
|
1482
1412
|
started = false;
|
|
1483
1413
|
isBaseline = true;
|
|
1484
1414
|
_lastPushTime = null;
|
|
@@ -1527,6 +1457,11 @@ class VcsManager {
|
|
|
1527
1457
|
fileChanged(filePath) {
|
|
1528
1458
|
if (!this.started)
|
|
1529
1459
|
return;
|
|
1460
|
+
// Reset circuit breaker on new file change so the system can recover
|
|
1461
|
+
if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
|
|
1462
|
+
this.logger.info({ root: this.rootPath }, 'VCS circuit breaker reset — new file change received, retrying commits');
|
|
1463
|
+
this.consecutiveCommitFailures = 0;
|
|
1464
|
+
}
|
|
1530
1465
|
this.pending.add(filePath);
|
|
1531
1466
|
if (this.pending.size >= this.config.maxBatchSize) {
|
|
1532
1467
|
this.clearDebounce();
|
|
@@ -1614,12 +1549,44 @@ class VcsManager {
|
|
|
1614
1549
|
}, this.config.commitDebounceMs);
|
|
1615
1550
|
}
|
|
1616
1551
|
/**
|
|
1617
|
-
*
|
|
1552
|
+
* Check for and remove stale index.lock files before retrying.
|
|
1553
|
+
*/
|
|
1554
|
+
async removeStaleLock() {
|
|
1555
|
+
const lockPath = join(this.rootPath, '.git', 'index.lock');
|
|
1556
|
+
try {
|
|
1557
|
+
const lockStat = await stat(lockPath);
|
|
1558
|
+
const ageMs = Date.now() - lockStat.mtimeMs;
|
|
1559
|
+
if (ageMs > this.config.staleLockThresholdMs) {
|
|
1560
|
+
await rm(lockPath, { force: true });
|
|
1561
|
+
this.logger.warn({ root: this.rootPath, ageMs }, 'Removed stale index.lock');
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
catch (error) {
|
|
1565
|
+
const code = error.code;
|
|
1566
|
+
if (code !== 'ENOENT') {
|
|
1567
|
+
this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Commit a batch of files to the git repo with index.lock retry,
|
|
1573
|
+
* stale lock detection, circuit breaker, and re-queue cap.
|
|
1618
1574
|
*/
|
|
1619
1575
|
async commitBatch(files) {
|
|
1620
1576
|
if (files.length === 0)
|
|
1621
1577
|
return;
|
|
1578
|
+
// Circuit breaker: skip if too many consecutive failures
|
|
1579
|
+
if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
|
|
1580
|
+
this.logger.error({
|
|
1581
|
+
root: this.rootPath,
|
|
1582
|
+
consecutiveFailures: this.consecutiveCommitFailures,
|
|
1583
|
+
discardedFiles: files.length,
|
|
1584
|
+
}, 'VCS circuit breaker tripped — pending files discarded until next successful commit');
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1622
1587
|
try {
|
|
1588
|
+
// Check for stale lock before first attempt
|
|
1589
|
+
await this.removeStaleLock();
|
|
1623
1590
|
await retry(async (attempt) => {
|
|
1624
1591
|
await gitAddViaStdin(files, this.rootPath);
|
|
1625
1592
|
// Build message after staging so getStagedDiff can see the changes.
|
|
@@ -1645,12 +1612,22 @@ class VcsManager {
|
|
|
1645
1612
|
this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
|
|
1646
1613
|
},
|
|
1647
1614
|
});
|
|
1615
|
+
// Success — reset circuit breaker
|
|
1616
|
+
this.consecutiveCommitFailures = 0;
|
|
1648
1617
|
}
|
|
1649
1618
|
catch (error) {
|
|
1650
|
-
|
|
1619
|
+
this.consecutiveCommitFailures++;
|
|
1620
|
+
// Re-queue cap: only re-queue up to maxBatchSize files
|
|
1621
|
+
const cap = this.config.maxBatchSize;
|
|
1622
|
+
const toRequeue = files.slice(0, cap);
|
|
1623
|
+
const overflow = files.length - toRequeue.length;
|
|
1624
|
+
for (const f of toRequeue) {
|
|
1651
1625
|
this.pending.add(f);
|
|
1652
1626
|
}
|
|
1653
|
-
|
|
1627
|
+
if (overflow > 0) {
|
|
1628
|
+
this.logger.warn({ root: this.rootPath, overflow, cap }, 'Re-queue cap exceeded — discarded overflow files');
|
|
1629
|
+
}
|
|
1630
|
+
this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
|
|
1654
1631
|
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
|
|
1655
1632
|
}
|
|
1656
1633
|
}
|
|
@@ -1682,10 +1659,14 @@ class VcsCoordinator {
|
|
|
1682
1659
|
// Deduplicate: skip if another glob already resolved to this root.
|
|
1683
1660
|
if (this.managers.has(rootKey))
|
|
1684
1661
|
continue;
|
|
1662
|
+
const mergedRetention = entry.vcs?.retention ??
|
|
1663
|
+
config.vcs.retention ??
|
|
1664
|
+
vcsRetentionConfigSchema.parse({});
|
|
1685
1665
|
const mergedConfig = {
|
|
1686
1666
|
...config.vcs,
|
|
1687
1667
|
...entry.vcs,
|
|
1688
1668
|
enabled: true,
|
|
1669
|
+
retention: mergedRetention,
|
|
1689
1670
|
};
|
|
1690
1671
|
// Create CommitMessageGenerator if AI commit messages are configured
|
|
1691
1672
|
const cmConfig = mergedConfig.commitMessage;
|
|
@@ -3179,10 +3160,10 @@ function createWatcher(config, factories, queue, processor, logger, runtimeOptio
|
|
|
3179
3160
|
* Tear down and rebuild the filesystem watcher with new config.
|
|
3180
3161
|
* Falls back to the old watcher if the new one fails to start.
|
|
3181
3162
|
*/
|
|
3182
|
-
async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache) {
|
|
3163
|
+
async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
|
|
3183
3164
|
try {
|
|
3184
3165
|
await oldState.watcher.stop();
|
|
3185
|
-
const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache);
|
|
3166
|
+
const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete);
|
|
3186
3167
|
newWatcher.start();
|
|
3187
3168
|
logger.info('Filesystem watcher rebuilt successfully');
|
|
3188
3169
|
return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
|
|
@@ -3377,7 +3358,7 @@ async function reloadConfig(state, deps) {
|
|
|
3377
3358
|
// Rebuild filesystem watcher if watch config changed
|
|
3378
3359
|
if (watchConfigChanged(oldConfig, newConfig)) {
|
|
3379
3360
|
logger.info('Watch config changed, rebuilding filesystem watcher...');
|
|
3380
|
-
const newState = await rebuildWatcher(newConfig, deps.factories, deps.queue, processor, logger, deps.runtimeOptions, { watcher: state.watcher, gitignoreFilter: state.gitignoreFilter }, deps.initialScanTracker, deps.contentHashCache);
|
|
3361
|
+
const newState = await rebuildWatcher(newConfig, deps.factories, deps.queue, processor, logger, deps.runtimeOptions, { watcher: state.watcher, gitignoreFilter: state.gitignoreFilter }, deps.initialScanTracker, deps.contentHashCache, deps.onVcsFileChange, deps.onInitialScanComplete);
|
|
3381
3362
|
state.watcher = newState.watcher;
|
|
3382
3363
|
state.gitignoreFilter = newState.gitignoreFilter;
|
|
3383
3364
|
}
|
|
@@ -7989,6 +7970,8 @@ class JeevesWatcher {
|
|
|
7989
7970
|
contentHashCache: this.contentHashCache,
|
|
7990
7971
|
valuesManager: this.valuesManager,
|
|
7991
7972
|
issuesManager: this.issuesManager,
|
|
7973
|
+
onVcsFileChange: (filePath, event) => this.vcsCoordinator?.onFileChange(filePath, event),
|
|
7974
|
+
onInitialScanComplete: () => this.vcsCoordinator?.onInitialScanComplete(),
|
|
7992
7975
|
});
|
|
7993
7976
|
this.config = state.config;
|
|
7994
7977
|
this.watcher = state.watcher;
|
|
@@ -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-xP2XrV4W.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
|
@@ -1295,6 +1295,7 @@ declare class VcsManager {
|
|
|
1295
1295
|
private readonly squashManager;
|
|
1296
1296
|
private debounceTimer;
|
|
1297
1297
|
private commitInFlight;
|
|
1298
|
+
private consecutiveCommitFailures;
|
|
1298
1299
|
private started;
|
|
1299
1300
|
private isBaseline;
|
|
1300
1301
|
private _lastPushTime;
|
|
@@ -1355,7 +1356,12 @@ declare class VcsManager {
|
|
|
1355
1356
|
*/
|
|
1356
1357
|
private resetDebounce;
|
|
1357
1358
|
/**
|
|
1358
|
-
*
|
|
1359
|
+
* Check for and remove stale index.lock files before retrying.
|
|
1360
|
+
*/
|
|
1361
|
+
private removeStaleLock;
|
|
1362
|
+
/**
|
|
1363
|
+
* Commit a batch of files to the git repo with index.lock retry,
|
|
1364
|
+
* stale lock detection, circuit breaker, and re-queue cap.
|
|
1359
1365
|
*/
|
|
1360
1366
|
private commitBatch;
|
|
1361
1367
|
}
|