@karmaniverous/jeeves-watcher 0.18.7 → 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-CS1wrtes.js → index-xP2XrV4W.js} +347 -305
- package/dist/cli/jeeves-watcher/index.js +148 -23
- package/dist/index.d.ts +42 -27
- package/dist/index.js +487 -322
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { join, dirname, resolve, relative, extname, basename, isAbsolute, sep } from 'node:path';
|
|
2
2
|
import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, substituteEnvVars, DEFAULT_PORTS, getBindAddress, postJson, fetchJson, getPackageVersion } from '@karmaniverous/jeeves';
|
|
3
|
-
import { extractWatchPathStrings, inferenceRuleSchema, jeevesWatcherConfigSchema, normalizeWatchPaths, vcsAuthorConfigSchema, LOGGING_DEFAULTS, API_DEFAULTS, EMBEDDING_DEFAULTS, CONFIG_WATCH_DEFAULTS, WATCH_DEFAULTS, ROOT_DEFAULTS, INIT_CONFIG_TEMPLATE, DEFAULT_PORT as DEFAULT_PORT$1, PLUGIN_PACKAGE, SERVICE_PACKAGE, COMPONENT_NAME } from '@karmaniverous/jeeves-watcher-core';
|
|
3
|
+
import { extractWatchPathStrings, inferenceRuleSchema, jeevesWatcherConfigSchema, normalizeWatchPaths, vcsRetentionConfigSchema, vcsAuthorConfigSchema, LOGGING_DEFAULTS, API_DEFAULTS, EMBEDDING_DEFAULTS, CONFIG_WATCH_DEFAULTS, WATCH_DEFAULTS, ROOT_DEFAULTS, INIT_CONFIG_TEMPLATE, DEFAULT_PORT as DEFAULT_PORT$1, PLUGIN_PACKAGE, SERVICE_PACKAGE, COMPONENT_NAME } from '@karmaniverous/jeeves-watcher-core';
|
|
4
4
|
export { apiConfigSchema, configWatchConfigSchema, embeddingConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema, loggingConfigSchema, vectorStoreConfigSchema, watchConfigSchema } from '@karmaniverous/jeeves-watcher-core';
|
|
5
5
|
import Fastify from 'fastify';
|
|
6
|
-
import { readdir, stat, readFile, writeFile, mkdir, appendFile,
|
|
6
|
+
import { readdir, stat, access, readFile, writeFile, mkdir, appendFile, rm } from 'node:fs/promises';
|
|
7
7
|
import { parallel, capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radash';
|
|
8
8
|
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
|
|
9
9
|
import ignore from 'ignore';
|
|
10
10
|
import picomatch from 'picomatch';
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
11
13
|
import Ajv from 'ajv';
|
|
12
14
|
import addFormats from 'ajv-formats';
|
|
13
15
|
import { z, ZodError } from 'zod';
|
|
@@ -21,8 +23,6 @@ import { toMarkdown } from 'mdast-util-to-markdown';
|
|
|
21
23
|
import rehypeParse from 'rehype-parse';
|
|
22
24
|
import { unified } from 'unified';
|
|
23
25
|
import yaml from 'js-yaml';
|
|
24
|
-
import { execFile } from 'node:child_process';
|
|
25
|
-
import { promisify } from 'node:util';
|
|
26
26
|
import crypto from 'crypto';
|
|
27
27
|
import { packageDirectorySync } from 'package-directory';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
@@ -421,6 +421,77 @@ async function retry(fn, options) {
|
|
|
421
421
|
throw lastError;
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
+
/**
|
|
425
|
+
* @module vcs/gitExec
|
|
426
|
+
* Shared git execution utilities.
|
|
427
|
+
*/
|
|
428
|
+
/** Promisified execFile for git commands. */
|
|
429
|
+
const execFileAsync = promisify(execFile);
|
|
430
|
+
/**
|
|
431
|
+
* Stage files via stdin to avoid ENAMETOOLONG on Windows.
|
|
432
|
+
*
|
|
433
|
+
* Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
|
|
434
|
+
* through stdin (NUL-delimited) instead of passed as command-line
|
|
435
|
+
* arguments. This sidesteps the Windows CreateProcessW 32 767-char
|
|
436
|
+
* limit that triggers ENAMETOOLONG when batches contain many files
|
|
437
|
+
* with long absolute paths.
|
|
438
|
+
*
|
|
439
|
+
* @param files - Absolute paths of files to stage.
|
|
440
|
+
* @param cwd - Repository root (working directory for git).
|
|
441
|
+
*/
|
|
442
|
+
function gitAddViaStdin(files, cwd) {
|
|
443
|
+
return new Promise((resolve, reject) => {
|
|
444
|
+
const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
|
|
445
|
+
if (error)
|
|
446
|
+
reject(error);
|
|
447
|
+
else
|
|
448
|
+
resolve();
|
|
449
|
+
});
|
|
450
|
+
if (!child.stdin) {
|
|
451
|
+
reject(new Error('Failed to open stdin for git add'));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Suppress EPIPE — expected if git exits before we finish writing
|
|
455
|
+
child.stdin.on('error', (err) => {
|
|
456
|
+
if (err.code !== 'EPIPE') {
|
|
457
|
+
reject(err);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
child.stdin.end(files.join('\0'));
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Normalize a path for case-insensitive comparison on Windows.
|
|
465
|
+
* On Windows, lowercases the entire path; on other platforms, returns as-is.
|
|
466
|
+
*
|
|
467
|
+
* @param p - The path string to normalize.
|
|
468
|
+
* @param platform - The platform to check against (default: `process.platform`).
|
|
469
|
+
* @returns The normalized path.
|
|
470
|
+
*/
|
|
471
|
+
function normalizePathCase(p, platform = process.platform) {
|
|
472
|
+
return platform === 'win32' ? p.toLowerCase() : p;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Find the longest-prefix-match root for a normalized path.
|
|
476
|
+
* Roots must be sorted longest-first for correct nested matching.
|
|
477
|
+
*
|
|
478
|
+
* @param roots - Root paths sorted longest-first.
|
|
479
|
+
* @param normalizedPath - Normalized absolute path (forward slashes).
|
|
480
|
+
* @param platform - The platform to check against (default: `process.platform`).
|
|
481
|
+
* @returns The matching root, or undefined.
|
|
482
|
+
*/
|
|
483
|
+
function findRootForPath(roots, normalizedPath, platform = process.platform) {
|
|
484
|
+
const comparePath = normalizePathCase(normalizedPath, platform);
|
|
485
|
+
for (const root of roots) {
|
|
486
|
+
const compareRoot = normalizePathCase(root, platform);
|
|
487
|
+
if (comparePath === compareRoot ||
|
|
488
|
+
comparePath.startsWith(compareRoot + '/')) {
|
|
489
|
+
return root;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
424
495
|
/**
|
|
425
496
|
* Best-effort base directory inference for a glob pattern.
|
|
426
497
|
*
|
|
@@ -638,7 +709,7 @@ function groupByRoot(filePaths, watchPaths) {
|
|
|
638
709
|
/** Default page size for prune scroll (smaller than search to reduce connection strain). */
|
|
639
710
|
const PRUNE_SCROLL_PAGE_SIZE = 500;
|
|
640
711
|
/** Max retry attempts for scroll page failures. */
|
|
641
|
-
const SCROLL_RETRY_ATTEMPTS =
|
|
712
|
+
const SCROLL_RETRY_ATTEMPTS = 5;
|
|
642
713
|
/** Base delay for scroll retry backoff (ms). */
|
|
643
714
|
const SCROLL_RETRY_BASE_DELAY_MS = 1000;
|
|
644
715
|
/**
|
|
@@ -664,26 +735,42 @@ async function scrollPageWithRetry(vectorStore, cursor, logger) {
|
|
|
664
735
|
}
|
|
665
736
|
throw lastError;
|
|
666
737
|
}
|
|
738
|
+
/** Concurrency limit for fs.access() disk-existence checks. */
|
|
739
|
+
const FS_ACCESS_CONCURRENCY = 100;
|
|
667
740
|
/**
|
|
668
|
-
* Compute the plan for a prune operation.
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
741
|
+
* Compute the plan for a prune operation using a filter-first approach.
|
|
742
|
+
*
|
|
743
|
+
* 1. Enumerate all watched files from the filesystem → `watchedFiles` set.
|
|
744
|
+
* 2. Scroll Qdrant points. For each point:
|
|
745
|
+
* - No `file_path` → orphan.
|
|
746
|
+
* - `file_path` not in `watchedFiles` → orphan (out of watch scope).
|
|
747
|
+
* - `file_path` in `watchedFiles` → check disk existence via `fs.access()`.
|
|
748
|
+
* If missing from disk → orphan (deleted file still in index).
|
|
749
|
+
* 3. Batch `fs.access()` checks with bounded concurrency.
|
|
672
750
|
*/
|
|
673
751
|
async function computePrunePlan(deps) {
|
|
674
752
|
const { config, vectorStore, gitignoreFilter, logger } = deps;
|
|
675
753
|
if (!vectorStore) {
|
|
676
754
|
throw new Error('vectorStore is required for prune scope');
|
|
677
755
|
}
|
|
756
|
+
// Step 1: Build set of watched files from filesystem
|
|
757
|
+
const isGitignored = createIsGitignored(gitignoreFilter);
|
|
758
|
+
const watchedFileList = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
|
|
759
|
+
const watchedFiles = new Set(watchedFileList.map((f) => normalizePathCase(normalizeSlashes(f))));
|
|
760
|
+
logger.info({ watchedFileCount: watchedFiles.size }, 'Prune: enumerated watched files from filesystem');
|
|
761
|
+
// Step 2: Scroll Qdrant and classify points
|
|
678
762
|
const seenFiles = new Set();
|
|
679
763
|
const orphanedFiles = new Set();
|
|
764
|
+
const diskCheckFiles = new Set();
|
|
680
765
|
const orphanedIds = [];
|
|
766
|
+
// Map from file_path → point IDs, for points needing disk-existence check
|
|
767
|
+
const diskCheckPointIds = new Map();
|
|
681
768
|
let totalPoints = 0;
|
|
682
769
|
let incomplete = false;
|
|
683
770
|
let cursor;
|
|
684
771
|
try {
|
|
685
|
-
|
|
686
|
-
while (
|
|
772
|
+
let hasMore = true;
|
|
773
|
+
while (hasMore) {
|
|
687
774
|
const page = await scrollPageWithRetry(vectorStore, cursor, logger);
|
|
688
775
|
for (const point of page.points) {
|
|
689
776
|
totalPoints++;
|
|
@@ -692,32 +779,62 @@ async function computePrunePlan(deps) {
|
|
|
692
779
|
orphanedIds.push(point.id);
|
|
693
780
|
continue;
|
|
694
781
|
}
|
|
695
|
-
|
|
696
|
-
|
|
782
|
+
const normalized = normalizePathCase(normalizeSlashes(filePath));
|
|
783
|
+
if (seenFiles.has(normalized)) {
|
|
784
|
+
if (orphanedFiles.has(normalized)) {
|
|
697
785
|
orphanedIds.push(point.id);
|
|
698
786
|
}
|
|
787
|
+
else if (diskCheckFiles.has(normalized)) {
|
|
788
|
+
// Already queued for disk check — track its ID
|
|
789
|
+
const ids = diskCheckPointIds.get(normalized);
|
|
790
|
+
if (ids)
|
|
791
|
+
ids.push(point.id);
|
|
792
|
+
}
|
|
699
793
|
continue;
|
|
700
794
|
}
|
|
701
|
-
seenFiles.add(
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
: false;
|
|
706
|
-
if (!watched || gitignored) {
|
|
707
|
-
orphanedFiles.add(filePath);
|
|
795
|
+
seenFiles.add(normalized);
|
|
796
|
+
if (!watchedFiles.has(normalized)) {
|
|
797
|
+
// Not in watch scope → definitely orphaned
|
|
798
|
+
orphanedFiles.add(normalized);
|
|
708
799
|
orphanedIds.push(point.id);
|
|
709
800
|
}
|
|
801
|
+
else {
|
|
802
|
+
// In watch scope → needs disk-existence check
|
|
803
|
+
diskCheckFiles.add(normalized);
|
|
804
|
+
diskCheckPointIds.set(normalized, [point.id]);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// Progress logging every 100K points
|
|
808
|
+
if (totalPoints > 0 && totalPoints % 100000 === 0) {
|
|
809
|
+
logger.info({ totalPoints, orphanedCount: orphanedIds.length }, 'Prune scroll progress');
|
|
810
|
+
}
|
|
811
|
+
if (!page.nextCursor) {
|
|
812
|
+
hasMore = false;
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
cursor = page.nextCursor;
|
|
710
816
|
}
|
|
711
|
-
if (!page.nextCursor)
|
|
712
|
-
break;
|
|
713
|
-
cursor = page.nextCursor;
|
|
714
817
|
}
|
|
715
818
|
}
|
|
716
819
|
catch (error) {
|
|
717
|
-
// All retries exhausted - return partial results
|
|
718
820
|
logger.error({ err: normalizeError(error), totalPoints, cursor }, 'Prune scroll failed after retries; returning partial plan');
|
|
719
821
|
incomplete = true;
|
|
720
822
|
}
|
|
823
|
+
// Step 3: Batch fs.access() checks for files in watch scope
|
|
824
|
+
const filesToCheck = [...diskCheckFiles];
|
|
825
|
+
await parallel(FS_ACCESS_CONCURRENCY, filesToCheck, async (filePath) => {
|
|
826
|
+
try {
|
|
827
|
+
await access(filePath);
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
// File doesn't exist on disk → orphaned
|
|
831
|
+
orphanedFiles.add(filePath);
|
|
832
|
+
const pointIds = diskCheckPointIds.get(filePath);
|
|
833
|
+
if (pointIds) {
|
|
834
|
+
orphanedIds.push(...pointIds);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
});
|
|
721
838
|
const orphanedFileList = [...orphanedFiles];
|
|
722
839
|
const byRoot = groupByRoot(orphanedFileList, extractWatchPathStrings(config.watch.paths));
|
|
723
840
|
return {
|
|
@@ -775,6 +892,12 @@ async function executeReindex(deps, scope, path, dryRun = false) {
|
|
|
775
892
|
deps.queue?.resume();
|
|
776
893
|
return { filesProcessed: 0, durationMs: 0, errors: 0, plan };
|
|
777
894
|
}
|
|
895
|
+
// Abort live prune when plan is incomplete — never silently delete partial data
|
|
896
|
+
if (plan.incomplete) {
|
|
897
|
+
deps.queue?.resume();
|
|
898
|
+
logger.error({ totalPoints: plan.total, pointsToDelete: plan.toDelete }, 'Aborting live prune — scroll was incomplete; partial deletes are unsafe');
|
|
899
|
+
return { filesProcessed: 0, durationMs: 0, errors: 1, plan };
|
|
900
|
+
}
|
|
778
901
|
// Execute prune
|
|
779
902
|
reindexTracker?.start(scope);
|
|
780
903
|
const startTime = Date.now();
|
|
@@ -3369,77 +3492,6 @@ function createSearchHandler(deps) {
|
|
|
3369
3492
|
}, deps.logger, 'Search');
|
|
3370
3493
|
}
|
|
3371
3494
|
|
|
3372
|
-
/**
|
|
3373
|
-
* @module vcs/gitExec
|
|
3374
|
-
* Shared git execution utilities.
|
|
3375
|
-
*/
|
|
3376
|
-
/** Promisified execFile for git commands. */
|
|
3377
|
-
const execFileAsync = promisify(execFile);
|
|
3378
|
-
/**
|
|
3379
|
-
* Stage files via stdin to avoid ENAMETOOLONG on Windows.
|
|
3380
|
-
*
|
|
3381
|
-
* Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
|
|
3382
|
-
* through stdin (NUL-delimited) instead of passed as command-line
|
|
3383
|
-
* arguments. This sidesteps the Windows CreateProcessW 32 767-char
|
|
3384
|
-
* limit that triggers ENAMETOOLONG when batches contain many files
|
|
3385
|
-
* with long absolute paths.
|
|
3386
|
-
*
|
|
3387
|
-
* @param files - Absolute paths of files to stage.
|
|
3388
|
-
* @param cwd - Repository root (working directory for git).
|
|
3389
|
-
*/
|
|
3390
|
-
function gitAddViaStdin(files, cwd) {
|
|
3391
|
-
return new Promise((resolve, reject) => {
|
|
3392
|
-
const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
|
|
3393
|
-
if (error)
|
|
3394
|
-
reject(error);
|
|
3395
|
-
else
|
|
3396
|
-
resolve();
|
|
3397
|
-
});
|
|
3398
|
-
if (!child.stdin) {
|
|
3399
|
-
reject(new Error('Failed to open stdin for git add'));
|
|
3400
|
-
return;
|
|
3401
|
-
}
|
|
3402
|
-
// Suppress EPIPE — expected if git exits before we finish writing
|
|
3403
|
-
child.stdin.on('error', (err) => {
|
|
3404
|
-
if (err.code !== 'EPIPE') {
|
|
3405
|
-
reject(err);
|
|
3406
|
-
}
|
|
3407
|
-
});
|
|
3408
|
-
child.stdin.end(files.join('\0'));
|
|
3409
|
-
});
|
|
3410
|
-
}
|
|
3411
|
-
/**
|
|
3412
|
-
* Normalize a path for case-insensitive comparison on Windows.
|
|
3413
|
-
* On Windows, lowercases the entire path; on other platforms, returns as-is.
|
|
3414
|
-
*
|
|
3415
|
-
* @param p - The path string to normalize.
|
|
3416
|
-
* @param platform - The platform to check against (default: `process.platform`).
|
|
3417
|
-
* @returns The normalized path.
|
|
3418
|
-
*/
|
|
3419
|
-
function normalizePathCase(p, platform = process.platform) {
|
|
3420
|
-
return platform === 'win32' ? p.toLowerCase() : p;
|
|
3421
|
-
}
|
|
3422
|
-
/**
|
|
3423
|
-
* Find the longest-prefix-match root for a normalized path.
|
|
3424
|
-
* Roots must be sorted longest-first for correct nested matching.
|
|
3425
|
-
*
|
|
3426
|
-
* @param roots - Root paths sorted longest-first.
|
|
3427
|
-
* @param normalizedPath - Normalized absolute path (forward slashes).
|
|
3428
|
-
* @param platform - The platform to check against (default: `process.platform`).
|
|
3429
|
-
* @returns The matching root, or undefined.
|
|
3430
|
-
*/
|
|
3431
|
-
function findRootForPath(roots, normalizedPath, platform = process.platform) {
|
|
3432
|
-
const comparePath = normalizePathCase(normalizedPath, platform);
|
|
3433
|
-
for (const root of roots) {
|
|
3434
|
-
const compareRoot = normalizePathCase(root, platform);
|
|
3435
|
-
if (comparePath === compareRoot ||
|
|
3436
|
-
comparePath.startsWith(compareRoot + '/')) {
|
|
3437
|
-
return root;
|
|
3438
|
-
}
|
|
3439
|
-
}
|
|
3440
|
-
return undefined;
|
|
3441
|
-
}
|
|
3442
|
-
|
|
3443
3495
|
/**
|
|
3444
3496
|
* @module vcs/resolveWatchRoot
|
|
3445
3497
|
* Resolves which VCS-enabled watch root(s) own a given absolute path or glob.
|
|
@@ -4193,6 +4245,91 @@ async function introspectAllHelpers(config, configDir) {
|
|
|
4193
4245
|
return result;
|
|
4194
4246
|
}
|
|
4195
4247
|
|
|
4248
|
+
/**
|
|
4249
|
+
* @module vcs/CommitMessageBuilder
|
|
4250
|
+
* Builds commit messages — both template-based and AI-generated.
|
|
4251
|
+
*/
|
|
4252
|
+
/**
|
|
4253
|
+
* Builds commit messages for VCS commits.
|
|
4254
|
+
* Supports template-based messages and AI-generated messages with fallback.
|
|
4255
|
+
*/
|
|
4256
|
+
class CommitMessageBuilder {
|
|
4257
|
+
rootPath;
|
|
4258
|
+
logger;
|
|
4259
|
+
generator;
|
|
4260
|
+
constructor(rootPath, logger, generator) {
|
|
4261
|
+
this.rootPath = rootPath;
|
|
4262
|
+
this.logger = logger;
|
|
4263
|
+
this.generator = generator;
|
|
4264
|
+
}
|
|
4265
|
+
/**
|
|
4266
|
+
* Build the template commit message (no AI).
|
|
4267
|
+
*/
|
|
4268
|
+
buildTemplateMessage(fileCount, isBaseline, pendingReversions) {
|
|
4269
|
+
if (pendingReversions.length === 0) {
|
|
4270
|
+
if (isBaseline) {
|
|
4271
|
+
return `baseline: batch (${String(fileCount)} files)`;
|
|
4272
|
+
}
|
|
4273
|
+
const timestamp = new Date().toISOString();
|
|
4274
|
+
return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
|
|
4275
|
+
}
|
|
4276
|
+
// Count total reverted files across all pending reversions
|
|
4277
|
+
const revertedPaths = new Set(pendingReversions.flatMap((r) => r.paths));
|
|
4278
|
+
const revertedCount = revertedPaths.size;
|
|
4279
|
+
const otherCount = fileCount - revertedCount;
|
|
4280
|
+
// Use the first reversion's glob and commit for the message
|
|
4281
|
+
const { glob, commit } = pendingReversions[0];
|
|
4282
|
+
const shortCommit = commit.slice(0, 7);
|
|
4283
|
+
let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
|
|
4284
|
+
if (otherCount > 0) {
|
|
4285
|
+
message += ` (+ ${String(otherCount)} other changes)`;
|
|
4286
|
+
}
|
|
4287
|
+
return message;
|
|
4288
|
+
}
|
|
4289
|
+
/**
|
|
4290
|
+
* Build the commit message, using AI when available with template fallback.
|
|
4291
|
+
*/
|
|
4292
|
+
async buildCommitMessage(files, isBaseline, pendingReversions) {
|
|
4293
|
+
const fileCount = files.length;
|
|
4294
|
+
const templateMessage = this.buildTemplateMessage(fileCount, isBaseline, pendingReversions);
|
|
4295
|
+
if (!this.generator) {
|
|
4296
|
+
return templateMessage;
|
|
4297
|
+
}
|
|
4298
|
+
try {
|
|
4299
|
+
const diffSummary = await this.getStagedDiff();
|
|
4300
|
+
const aiMessage = await this.generator.generate(files, diffSummary);
|
|
4301
|
+
if (!aiMessage) {
|
|
4302
|
+
this.logger.warn('AI commit message generation returned null; using template');
|
|
4303
|
+
return templateMessage;
|
|
4304
|
+
}
|
|
4305
|
+
// For reversions: prefix with revert info + AI description
|
|
4306
|
+
if (pendingReversions.length > 0) {
|
|
4307
|
+
const { glob, commit } = pendingReversions[0];
|
|
4308
|
+
const shortCommit = commit.slice(0, 7);
|
|
4309
|
+
return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
|
|
4310
|
+
}
|
|
4311
|
+
return aiMessage;
|
|
4312
|
+
}
|
|
4313
|
+
catch (error) {
|
|
4314
|
+
this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
|
|
4315
|
+
return templateMessage;
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
/**
|
|
4319
|
+
* Get the staged diff for AI context.
|
|
4320
|
+
*/
|
|
4321
|
+
async getStagedDiff() {
|
|
4322
|
+
try {
|
|
4323
|
+
const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
|
|
4324
|
+
const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
|
|
4325
|
+
return `${stat}\n${patch}`;
|
|
4326
|
+
}
|
|
4327
|
+
catch {
|
|
4328
|
+
return '';
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4196
4333
|
/**
|
|
4197
4334
|
* @module vcs/CommitMessageGenerator
|
|
4198
4335
|
* Generates AI-powered commit messages via the Anthropic Messages API.
|
|
@@ -4819,6 +4956,50 @@ function resolveIgnored(ignored) {
|
|
|
4819
4956
|
});
|
|
4820
4957
|
}
|
|
4821
4958
|
|
|
4959
|
+
/**
|
|
4960
|
+
* @module vcs/vcsPush
|
|
4961
|
+
* Push logic for VCS commits.
|
|
4962
|
+
*/
|
|
4963
|
+
/**
|
|
4964
|
+
* Push to the configured remote if remoteUrl is set.
|
|
4965
|
+
*
|
|
4966
|
+
* On failure: logs the error, appends to pushErrors, and returns undefined.
|
|
4967
|
+
* Never throws.
|
|
4968
|
+
*
|
|
4969
|
+
* @param rootPath - Absolute path of the git repository.
|
|
4970
|
+
* @param remoteUrl - Remote URL to push to, or undefined to skip.
|
|
4971
|
+
* @param accessToken - Optional token for HTTPS authentication.
|
|
4972
|
+
* @param pushErrors - Mutable array where push errors are recorded.
|
|
4973
|
+
* @param logger - Logger instance.
|
|
4974
|
+
* @returns ISO timestamp of a successful push, or undefined if skipped/failed.
|
|
4975
|
+
*/
|
|
4976
|
+
async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger) {
|
|
4977
|
+
if (!remoteUrl)
|
|
4978
|
+
return undefined;
|
|
4979
|
+
try {
|
|
4980
|
+
// Build the authenticated URL if a token is available
|
|
4981
|
+
const pushUrl = accessToken
|
|
4982
|
+
? remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`)
|
|
4983
|
+
: remoteUrl;
|
|
4984
|
+
await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
|
|
4985
|
+
cwd: rootPath,
|
|
4986
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4987
|
+
});
|
|
4988
|
+
const timestamp = new Date().toISOString();
|
|
4989
|
+
logger.info({ root: rootPath, remote: remoteUrl }, 'VCS push succeeded');
|
|
4990
|
+
return timestamp;
|
|
4991
|
+
}
|
|
4992
|
+
catch (error) {
|
|
4993
|
+
const pushError = {
|
|
4994
|
+
timestamp: new Date().toISOString(),
|
|
4995
|
+
message: normalizeError(error).message,
|
|
4996
|
+
};
|
|
4997
|
+
pushErrors.push(pushError);
|
|
4998
|
+
logger.error({ root: rootPath, err: normalizeError(error) }, 'VCS push failed');
|
|
4999
|
+
return undefined;
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
5002
|
+
|
|
4822
5003
|
/**
|
|
4823
5004
|
* @module vcs/VcsManager
|
|
4824
5005
|
* Per-root VCS manager for git-backed content versioning.
|
|
@@ -4839,23 +5020,24 @@ class VcsManager {
|
|
|
4839
5020
|
remoteUrl;
|
|
4840
5021
|
accessToken;
|
|
4841
5022
|
logger;
|
|
4842
|
-
|
|
5023
|
+
commitMessageBuilder;
|
|
4843
5024
|
pending = new Set();
|
|
4844
5025
|
pendingReversions = [];
|
|
4845
5026
|
_pushErrors = [];
|
|
4846
5027
|
squashManager;
|
|
4847
5028
|
debounceTimer;
|
|
4848
5029
|
commitInFlight = Promise.resolve();
|
|
5030
|
+
consecutiveCommitFailures = 0;
|
|
4849
5031
|
started = false;
|
|
4850
|
-
isBaseline =
|
|
5032
|
+
isBaseline = true;
|
|
4851
5033
|
_lastPushTime = null;
|
|
4852
5034
|
constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
|
|
4853
5035
|
this.rootPath = rootPath;
|
|
4854
5036
|
this.config = config;
|
|
4855
5037
|
this.logger = logger;
|
|
4856
|
-
this.commitMessageGenerator = commitMessageGenerator;
|
|
4857
5038
|
this.remoteUrl = remoteUrl;
|
|
4858
5039
|
this.accessToken = accessToken;
|
|
5040
|
+
this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
|
|
4859
5041
|
if (config.retention) {
|
|
4860
5042
|
this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
|
|
4861
5043
|
}
|
|
@@ -4867,48 +5049,23 @@ class VcsManager {
|
|
|
4867
5049
|
return this._pushErrors;
|
|
4868
5050
|
}
|
|
4869
5051
|
/**
|
|
4870
|
-
* Begin accepting file changes. Sets
|
|
4871
|
-
*
|
|
5052
|
+
* Begin accepting file changes. Sets the manager to started state and
|
|
5053
|
+
* starts the squash manager if configured.
|
|
5054
|
+
*
|
|
5055
|
+
* Baseline mode (commit prefix `"baseline:"`) remains active until
|
|
5056
|
+
* {@link endBaseline} is called by the coordinator after the initial scan.
|
|
4872
5057
|
*/
|
|
4873
|
-
|
|
5058
|
+
start() {
|
|
4874
5059
|
this.started = true;
|
|
4875
5060
|
this.squashManager?.start();
|
|
4876
5061
|
this.logger.info({ root: this.rootPath }, 'VcsManager started');
|
|
4877
|
-
await this.enqueueBaselineIfNeeded();
|
|
4878
5062
|
}
|
|
4879
5063
|
/**
|
|
4880
|
-
*
|
|
4881
|
-
*
|
|
5064
|
+
* Signal that the initial filesystem scan is complete.
|
|
5065
|
+
* Clears the baseline flag so subsequent commits use normal "watcher: batch" messages.
|
|
4882
5066
|
*/
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
|
4886
|
-
cwd: this.rootPath,
|
|
4887
|
-
});
|
|
4888
|
-
// HEAD exists — repo already has commits
|
|
4889
|
-
return;
|
|
4890
|
-
}
|
|
4891
|
-
catch {
|
|
4892
|
-
// No commits yet — proceed with baseline
|
|
4893
|
-
}
|
|
4894
|
-
try {
|
|
4895
|
-
const { stdout } = await execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: this.rootPath });
|
|
4896
|
-
const files = stdout
|
|
4897
|
-
.split('\0')
|
|
4898
|
-
.map((line) => line.trim())
|
|
4899
|
-
.filter((line) => line.length > 0);
|
|
4900
|
-
if (files.length === 0)
|
|
4901
|
-
return;
|
|
4902
|
-
this.logger.info({ root: this.rootPath, fileCount: files.length }, 'Enqueuing baseline commit for pre-existing files');
|
|
4903
|
-
this.isBaseline = true;
|
|
4904
|
-
const absolutePaths = files.map((f) => this.rootPath + '/' + f);
|
|
4905
|
-
for (const filePath of absolutePaths) {
|
|
4906
|
-
this.fileChanged(filePath);
|
|
4907
|
-
}
|
|
4908
|
-
}
|
|
4909
|
-
catch (error) {
|
|
4910
|
-
this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Failed to enumerate untracked files for baseline commit');
|
|
4911
|
-
}
|
|
5067
|
+
endBaseline() {
|
|
5068
|
+
this.isBaseline = false;
|
|
4912
5069
|
}
|
|
4913
5070
|
/**
|
|
4914
5071
|
* Add a file to the pending set and reset the debounce timer.
|
|
@@ -4919,6 +5076,11 @@ class VcsManager {
|
|
|
4919
5076
|
fileChanged(filePath) {
|
|
4920
5077
|
if (!this.started)
|
|
4921
5078
|
return;
|
|
5079
|
+
// Reset circuit breaker on new file change so the system can recover
|
|
5080
|
+
if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
|
|
5081
|
+
this.logger.info({ root: this.rootPath }, 'VCS circuit breaker reset — new file change received, retrying commits');
|
|
5082
|
+
this.consecutiveCommitFailures = 0;
|
|
5083
|
+
}
|
|
4922
5084
|
this.pending.add(filePath);
|
|
4923
5085
|
if (this.pending.size >= this.config.maxBatchSize) {
|
|
4924
5086
|
this.clearDebounce();
|
|
@@ -5006,97 +5168,61 @@ class VcsManager {
|
|
|
5006
5168
|
}, this.config.commitDebounceMs);
|
|
5007
5169
|
}
|
|
5008
5170
|
/**
|
|
5009
|
-
*
|
|
5171
|
+
* Check for and remove stale index.lock files before retrying.
|
|
5010
5172
|
*/
|
|
5011
|
-
async
|
|
5012
|
-
|
|
5013
|
-
const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
|
|
5014
|
-
const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
|
|
5015
|
-
return `${stat}\n${patch}`;
|
|
5016
|
-
}
|
|
5017
|
-
catch {
|
|
5018
|
-
return '';
|
|
5019
|
-
}
|
|
5020
|
-
}
|
|
5021
|
-
/**
|
|
5022
|
-
* Build the template commit message (no AI).
|
|
5023
|
-
*/
|
|
5024
|
-
buildTemplateMessage(fileCount) {
|
|
5025
|
-
if (this.pendingReversions.length === 0) {
|
|
5026
|
-
if (this.isBaseline) {
|
|
5027
|
-
return `baseline: batch (${String(fileCount)} files)`;
|
|
5028
|
-
}
|
|
5029
|
-
const timestamp = new Date().toISOString();
|
|
5030
|
-
return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
|
|
5031
|
-
}
|
|
5032
|
-
// Count total reverted files across all pending reversions
|
|
5033
|
-
const revertedPaths = new Set(this.pendingReversions.flatMap((r) => r.paths));
|
|
5034
|
-
const revertedCount = revertedPaths.size;
|
|
5035
|
-
const otherCount = fileCount - revertedCount;
|
|
5036
|
-
// Use the first reversion's glob and commit for the message
|
|
5037
|
-
const { glob, commit } = this.pendingReversions[0];
|
|
5038
|
-
const shortCommit = commit.slice(0, 7);
|
|
5039
|
-
let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
|
|
5040
|
-
if (otherCount > 0) {
|
|
5041
|
-
message += ` (+ ${String(otherCount)} other changes)`;
|
|
5042
|
-
}
|
|
5043
|
-
return message;
|
|
5044
|
-
}
|
|
5045
|
-
/**
|
|
5046
|
-
* Build the commit message, using AI when available with template fallback.
|
|
5047
|
-
*/
|
|
5048
|
-
async buildCommitMessage(files) {
|
|
5049
|
-
const fileCount = files.length;
|
|
5050
|
-
const templateMessage = this.buildTemplateMessage(fileCount);
|
|
5051
|
-
if (!this.commitMessageGenerator) {
|
|
5052
|
-
return templateMessage;
|
|
5053
|
-
}
|
|
5173
|
+
async removeStaleLock() {
|
|
5174
|
+
const lockPath = join(this.rootPath, '.git', 'index.lock');
|
|
5054
5175
|
try {
|
|
5055
|
-
const
|
|
5056
|
-
const
|
|
5057
|
-
if (
|
|
5058
|
-
|
|
5059
|
-
|
|
5176
|
+
const lockStat = await stat(lockPath);
|
|
5177
|
+
const ageMs = Date.now() - lockStat.mtimeMs;
|
|
5178
|
+
if (ageMs > this.config.staleLockThresholdMs) {
|
|
5179
|
+
await rm(lockPath, { force: true });
|
|
5180
|
+
this.logger.warn({ root: this.rootPath, ageMs }, 'Removed stale index.lock');
|
|
5060
5181
|
}
|
|
5061
|
-
// For reversions: prefix with revert info + AI description
|
|
5062
|
-
if (this.pendingReversions.length > 0) {
|
|
5063
|
-
const { glob, commit } = this.pendingReversions[0];
|
|
5064
|
-
const shortCommit = commit.slice(0, 7);
|
|
5065
|
-
return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
|
|
5066
|
-
}
|
|
5067
|
-
return aiMessage;
|
|
5068
5182
|
}
|
|
5069
5183
|
catch (error) {
|
|
5070
|
-
|
|
5071
|
-
|
|
5184
|
+
const code = error.code;
|
|
5185
|
+
if (code !== 'ENOENT') {
|
|
5186
|
+
this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
|
|
5187
|
+
}
|
|
5072
5188
|
}
|
|
5073
5189
|
}
|
|
5074
5190
|
/**
|
|
5075
|
-
* Commit a batch of files to the git repo with index.lock retry
|
|
5191
|
+
* Commit a batch of files to the git repo with index.lock retry,
|
|
5192
|
+
* stale lock detection, circuit breaker, and re-queue cap.
|
|
5076
5193
|
*/
|
|
5077
5194
|
async commitBatch(files) {
|
|
5078
5195
|
if (files.length === 0)
|
|
5079
5196
|
return;
|
|
5197
|
+
// Circuit breaker: skip if too many consecutive failures
|
|
5198
|
+
if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
|
|
5199
|
+
this.logger.error({
|
|
5200
|
+
root: this.rootPath,
|
|
5201
|
+
consecutiveFailures: this.consecutiveCommitFailures,
|
|
5202
|
+
discardedFiles: files.length,
|
|
5203
|
+
}, 'VCS circuit breaker tripped — pending files discarded until next successful commit');
|
|
5204
|
+
return;
|
|
5205
|
+
}
|
|
5080
5206
|
try {
|
|
5207
|
+
// Check for stale lock before first attempt
|
|
5208
|
+
await this.removeStaleLock();
|
|
5081
5209
|
await retry(async (attempt) => {
|
|
5082
5210
|
await gitAddViaStdin(files, this.rootPath);
|
|
5083
|
-
// Build message after staging so getStagedDiff can see the changes
|
|
5084
|
-
// Skip AI for baselines and retries — use template directly
|
|
5211
|
+
// Build message after staging so getStagedDiff can see the changes.
|
|
5212
|
+
// Skip AI for baselines and retries — use template directly.
|
|
5085
5213
|
const message = this.isBaseline || attempt > 1
|
|
5086
|
-
? this.buildTemplateMessage(files.length)
|
|
5087
|
-
: await this.buildCommitMessage(files);
|
|
5214
|
+
? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
|
|
5215
|
+
: await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
|
|
5088
5216
|
this.pendingReversions.length = 0;
|
|
5089
|
-
// Only clear baseline flag when all baseline files have been committed
|
|
5090
|
-
if (this.pending.size === 0) {
|
|
5091
|
-
this.isBaseline = false;
|
|
5092
|
-
}
|
|
5093
5217
|
await execFileAsync('git', ['commit', '-m', message], {
|
|
5094
5218
|
cwd: this.rootPath,
|
|
5095
5219
|
});
|
|
5096
5220
|
const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
|
|
5097
5221
|
const hash = hashOut.trim();
|
|
5098
5222
|
this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
|
|
5099
|
-
await this.
|
|
5223
|
+
const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
|
|
5224
|
+
if (pushTime)
|
|
5225
|
+
this._lastPushTime = pushTime;
|
|
5100
5226
|
}, {
|
|
5101
5227
|
attempts: 4,
|
|
5102
5228
|
baseDelayMs: 500,
|
|
@@ -5105,43 +5231,25 @@ class VcsManager {
|
|
|
5105
5231
|
this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
|
|
5106
5232
|
},
|
|
5107
5233
|
});
|
|
5234
|
+
// Success — reset circuit breaker
|
|
5235
|
+
this.consecutiveCommitFailures = 0;
|
|
5108
5236
|
}
|
|
5109
5237
|
catch (error) {
|
|
5110
|
-
|
|
5238
|
+
this.consecutiveCommitFailures++;
|
|
5239
|
+
// Re-queue cap: only re-queue up to maxBatchSize files
|
|
5240
|
+
const cap = this.config.maxBatchSize;
|
|
5241
|
+
const toRequeue = files.slice(0, cap);
|
|
5242
|
+
const overflow = files.length - toRequeue.length;
|
|
5243
|
+
for (const f of toRequeue) {
|
|
5111
5244
|
this.pending.add(f);
|
|
5112
5245
|
}
|
|
5113
|
-
|
|
5246
|
+
if (overflow > 0) {
|
|
5247
|
+
this.logger.warn({ root: this.rootPath, overflow, cap }, 'Re-queue cap exceeded — discarded overflow files');
|
|
5248
|
+
}
|
|
5249
|
+
this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
|
|
5114
5250
|
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
|
|
5115
5251
|
}
|
|
5116
5252
|
}
|
|
5117
|
-
/**
|
|
5118
|
-
* Push to the configured remote if remoteUrl is set.
|
|
5119
|
-
* On failure: logs the error and records it in pushErrors; never throws.
|
|
5120
|
-
*/
|
|
5121
|
-
async pushIfConfigured() {
|
|
5122
|
-
if (!this.remoteUrl)
|
|
5123
|
-
return;
|
|
5124
|
-
try {
|
|
5125
|
-
// Build the authenticated URL if a token is available
|
|
5126
|
-
const pushUrl = this.accessToken
|
|
5127
|
-
? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
|
|
5128
|
-
: this.remoteUrl;
|
|
5129
|
-
await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
|
|
5130
|
-
cwd: this.rootPath,
|
|
5131
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
5132
|
-
});
|
|
5133
|
-
this._lastPushTime = new Date().toISOString();
|
|
5134
|
-
this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'VCS push succeeded');
|
|
5135
|
-
}
|
|
5136
|
-
catch (error) {
|
|
5137
|
-
const pushError = {
|
|
5138
|
-
timestamp: new Date().toISOString(),
|
|
5139
|
-
message: normalizeError(error).message,
|
|
5140
|
-
};
|
|
5141
|
-
this._pushErrors.push(pushError);
|
|
5142
|
-
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS push failed');
|
|
5143
|
-
}
|
|
5144
|
-
}
|
|
5145
5253
|
}
|
|
5146
5254
|
|
|
5147
5255
|
/**
|
|
@@ -5170,10 +5278,14 @@ class VcsCoordinator {
|
|
|
5170
5278
|
// Deduplicate: skip if another glob already resolved to this root.
|
|
5171
5279
|
if (this.managers.has(rootKey))
|
|
5172
5280
|
continue;
|
|
5281
|
+
const mergedRetention = entry.vcs?.retention ??
|
|
5282
|
+
config.vcs.retention ??
|
|
5283
|
+
vcsRetentionConfigSchema.parse({});
|
|
5173
5284
|
const mergedConfig = {
|
|
5174
5285
|
...config.vcs,
|
|
5175
5286
|
...entry.vcs,
|
|
5176
5287
|
enabled: true,
|
|
5288
|
+
retention: mergedRetention,
|
|
5177
5289
|
};
|
|
5178
5290
|
// Create CommitMessageGenerator if AI commit messages are configured
|
|
5179
5291
|
const cmConfig = mergedConfig.commitMessage;
|
|
@@ -5197,12 +5309,10 @@ class VcsCoordinator {
|
|
|
5197
5309
|
/**
|
|
5198
5310
|
* Start all VcsManager instances.
|
|
5199
5311
|
*/
|
|
5200
|
-
|
|
5201
|
-
const startPromises = [];
|
|
5312
|
+
start() {
|
|
5202
5313
|
this.managers.forEach((manager) => {
|
|
5203
|
-
|
|
5314
|
+
manager.start();
|
|
5204
5315
|
});
|
|
5205
|
-
await Promise.all(startPromises);
|
|
5206
5316
|
this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
|
|
5207
5317
|
}
|
|
5208
5318
|
/**
|
|
@@ -5223,6 +5333,21 @@ class VcsCoordinator {
|
|
|
5223
5333
|
manager.fileChanged(normalizedPath);
|
|
5224
5334
|
}
|
|
5225
5335
|
}
|
|
5336
|
+
/**
|
|
5337
|
+
* Signal that the initial filesystem scan is complete.
|
|
5338
|
+
*
|
|
5339
|
+
* Flushes each manager's debounce buffer before ending baseline mode, ensuring
|
|
5340
|
+
* any files still pending in the VCS debounce are committed with a
|
|
5341
|
+
* `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
|
|
5342
|
+
* until all managers have completed their flush.
|
|
5343
|
+
*/
|
|
5344
|
+
async onInitialScanComplete() {
|
|
5345
|
+
for (const manager of this.managers.values()) {
|
|
5346
|
+
await manager.flush();
|
|
5347
|
+
manager.endBaseline();
|
|
5348
|
+
}
|
|
5349
|
+
this.logger.debug('VcsCoordinator: initial scan complete, baseline ended');
|
|
5350
|
+
}
|
|
5226
5351
|
/**
|
|
5227
5352
|
* Flush all managers and clean up.
|
|
5228
5353
|
*/
|
|
@@ -5265,6 +5390,68 @@ class VcsCoordinator {
|
|
|
5265
5390
|
}
|
|
5266
5391
|
}
|
|
5267
5392
|
|
|
5393
|
+
/**
|
|
5394
|
+
* @module app/watcherFactory
|
|
5395
|
+
* Factory helpers for creating and rebuilding the filesystem watcher.
|
|
5396
|
+
* Extracted from initialization.ts to follow SRP.
|
|
5397
|
+
*/
|
|
5398
|
+
/**
|
|
5399
|
+
* Create file system watcher with gitignore filtering.
|
|
5400
|
+
*/
|
|
5401
|
+
function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
|
|
5402
|
+
const respectGitignore = config.watch.respectGitignore ?? true;
|
|
5403
|
+
const gitignoreFilter = respectGitignore
|
|
5404
|
+
? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
|
|
5405
|
+
: undefined;
|
|
5406
|
+
const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
|
|
5407
|
+
maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
|
|
5408
|
+
maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
|
|
5409
|
+
onFatalError: runtimeOptions.onFatalError,
|
|
5410
|
+
gitignoreFilter,
|
|
5411
|
+
initialScanTracker,
|
|
5412
|
+
contentHashCache,
|
|
5413
|
+
onVcsFileChange,
|
|
5414
|
+
onInitialScanComplete,
|
|
5415
|
+
});
|
|
5416
|
+
return { watcher, gitignoreFilter };
|
|
5417
|
+
}
|
|
5418
|
+
/**
|
|
5419
|
+
* Tear down and rebuild the filesystem watcher with new config.
|
|
5420
|
+
* Falls back to the old watcher if the new one fails to start.
|
|
5421
|
+
*/
|
|
5422
|
+
async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
|
|
5423
|
+
try {
|
|
5424
|
+
await oldState.watcher.stop();
|
|
5425
|
+
const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete);
|
|
5426
|
+
newWatcher.start();
|
|
5427
|
+
logger.info('Filesystem watcher rebuilt successfully');
|
|
5428
|
+
return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
|
|
5429
|
+
}
|
|
5430
|
+
catch (error) {
|
|
5431
|
+
logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
|
|
5432
|
+
try {
|
|
5433
|
+
oldState.watcher.start();
|
|
5434
|
+
}
|
|
5435
|
+
catch (restartError) {
|
|
5436
|
+
logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
|
|
5437
|
+
}
|
|
5438
|
+
return oldState;
|
|
5439
|
+
}
|
|
5440
|
+
}
|
|
5441
|
+
/**
|
|
5442
|
+
* Check whether watch-relevant config fields changed between old and new config.
|
|
5443
|
+
*/
|
|
5444
|
+
function watchConfigChanged(oldConfig, newConfig) {
|
|
5445
|
+
return (JSON.stringify(oldConfig.watch.paths) !==
|
|
5446
|
+
JSON.stringify(newConfig.watch.paths) ||
|
|
5447
|
+
JSON.stringify(oldConfig.watch.ignored) !==
|
|
5448
|
+
JSON.stringify(newConfig.watch.ignored) ||
|
|
5449
|
+
JSON.stringify(oldConfig.watch.moveDetection) !==
|
|
5450
|
+
JSON.stringify(newConfig.watch.moveDetection) ||
|
|
5451
|
+
(oldConfig.watch.respectGitignore ?? true) !==
|
|
5452
|
+
(newConfig.watch.respectGitignore ?? true));
|
|
5453
|
+
}
|
|
5454
|
+
|
|
5268
5455
|
/**
|
|
5269
5456
|
* @module app/initialization
|
|
5270
5457
|
* Initialization helpers for JeevesWatcher. Extracted to follow SRP.
|
|
@@ -5329,25 +5516,6 @@ function createProcessorConfig(config, configDir, customMapLib) {
|
|
|
5329
5516
|
globalSchemas: config.schemas,
|
|
5330
5517
|
};
|
|
5331
5518
|
}
|
|
5332
|
-
/**
|
|
5333
|
-
* Create file system watcher with gitignore filtering.
|
|
5334
|
-
*/
|
|
5335
|
-
function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange) {
|
|
5336
|
-
const respectGitignore = config.watch.respectGitignore ?? true;
|
|
5337
|
-
const gitignoreFilter = respectGitignore
|
|
5338
|
-
? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
|
|
5339
|
-
: undefined;
|
|
5340
|
-
const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
|
|
5341
|
-
maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
|
|
5342
|
-
maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
|
|
5343
|
-
onFatalError: runtimeOptions.onFatalError,
|
|
5344
|
-
gitignoreFilter,
|
|
5345
|
-
initialScanTracker,
|
|
5346
|
-
contentHashCache,
|
|
5347
|
-
onVcsFileChange,
|
|
5348
|
-
});
|
|
5349
|
-
return { watcher, gitignoreFilter };
|
|
5350
|
-
}
|
|
5351
5519
|
/**
|
|
5352
5520
|
* Introspect all helpers for merged document.
|
|
5353
5521
|
*/
|
|
@@ -5363,42 +5531,6 @@ async function introspectHelpers(config, configDir) {
|
|
|
5363
5531
|
function getConfigDir(configPath) {
|
|
5364
5532
|
return configPath ? dirname(configPath) : '.';
|
|
5365
5533
|
}
|
|
5366
|
-
/**
|
|
5367
|
-
* Tear down and rebuild the filesystem watcher with new config.
|
|
5368
|
-
* Falls back to the old watcher if the new one fails to start.
|
|
5369
|
-
*/
|
|
5370
|
-
async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache) {
|
|
5371
|
-
try {
|
|
5372
|
-
await oldState.watcher.stop();
|
|
5373
|
-
const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache);
|
|
5374
|
-
newWatcher.start();
|
|
5375
|
-
logger.info('Filesystem watcher rebuilt successfully');
|
|
5376
|
-
return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
|
|
5377
|
-
}
|
|
5378
|
-
catch (error) {
|
|
5379
|
-
logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
|
|
5380
|
-
try {
|
|
5381
|
-
oldState.watcher.start();
|
|
5382
|
-
}
|
|
5383
|
-
catch (restartError) {
|
|
5384
|
-
logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
|
|
5385
|
-
}
|
|
5386
|
-
return oldState;
|
|
5387
|
-
}
|
|
5388
|
-
}
|
|
5389
|
-
/**
|
|
5390
|
-
* Check whether watch-relevant config fields changed between old and new config.
|
|
5391
|
-
*/
|
|
5392
|
-
function watchConfigChanged(oldConfig, newConfig) {
|
|
5393
|
-
return (JSON.stringify(oldConfig.watch.paths) !==
|
|
5394
|
-
JSON.stringify(newConfig.watch.paths) ||
|
|
5395
|
-
JSON.stringify(oldConfig.watch.ignored) !==
|
|
5396
|
-
JSON.stringify(newConfig.watch.ignored) ||
|
|
5397
|
-
JSON.stringify(oldConfig.watch.moveDetection) !==
|
|
5398
|
-
JSON.stringify(newConfig.watch.moveDetection) ||
|
|
5399
|
-
(oldConfig.watch.respectGitignore ?? true) !==
|
|
5400
|
-
(newConfig.watch.respectGitignore ?? true));
|
|
5401
|
-
}
|
|
5402
5534
|
/**
|
|
5403
5535
|
* Resolve package version from nearest package.json.
|
|
5404
5536
|
*/
|
|
@@ -6431,7 +6563,7 @@ async function reloadConfig(state, deps) {
|
|
|
6431
6563
|
// Rebuild filesystem watcher if watch config changed
|
|
6432
6564
|
if (watchConfigChanged(oldConfig, newConfig)) {
|
|
6433
6565
|
logger.info('Watch config changed, rebuilding filesystem watcher...');
|
|
6434
|
-
const newState = await rebuildWatcher(newConfig, deps.factories, deps.queue, processor, logger, deps.runtimeOptions, { watcher: state.watcher, gitignoreFilter: state.gitignoreFilter }, deps.initialScanTracker, deps.contentHashCache);
|
|
6566
|
+
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);
|
|
6435
6567
|
state.watcher = newState.watcher;
|
|
6436
6568
|
state.gitignoreFilter = newState.gitignoreFilter;
|
|
6437
6569
|
}
|
|
@@ -8470,6 +8602,37 @@ class MoveCorrelator {
|
|
|
8470
8602
|
}
|
|
8471
8603
|
}
|
|
8472
8604
|
|
|
8605
|
+
/**
|
|
8606
|
+
* @module watcher/ScanStats
|
|
8607
|
+
* Tracks initial filesystem scan statistics for diagnostics.
|
|
8608
|
+
*/
|
|
8609
|
+
/**
|
|
8610
|
+
* Mutable counter bag updated during the chokidar initial scan.
|
|
8611
|
+
* Extracted from the watcher start() method for clarity.
|
|
8612
|
+
*/
|
|
8613
|
+
class ScanStats {
|
|
8614
|
+
total = 0;
|
|
8615
|
+
matched = 0;
|
|
8616
|
+
globRejected = 0;
|
|
8617
|
+
gitignored = 0;
|
|
8618
|
+
byRoot;
|
|
8619
|
+
constructor(roots) {
|
|
8620
|
+
this.byRoot = Object.fromEntries(roots.map((r) => [r, 0]));
|
|
8621
|
+
}
|
|
8622
|
+
/**
|
|
8623
|
+
* Attribute a newly discovered file to its root for per-root diagnostics.
|
|
8624
|
+
*/
|
|
8625
|
+
classifyByRoot(path) {
|
|
8626
|
+
const normalized = path.replace(/\\/g, '/').toLowerCase();
|
|
8627
|
+
for (const root of Object.keys(this.byRoot)) {
|
|
8628
|
+
if (normalized.startsWith(`${root}/`) || normalized === root) {
|
|
8629
|
+
this.byRoot[root] = (this.byRoot[root] ?? 0) + 1;
|
|
8630
|
+
break;
|
|
8631
|
+
}
|
|
8632
|
+
}
|
|
8633
|
+
}
|
|
8634
|
+
}
|
|
8635
|
+
|
|
8473
8636
|
/**
|
|
8474
8637
|
* @module watcher
|
|
8475
8638
|
* Filesystem watcher wrapping chokidar. I/O: watches files/directories for add/change/unlink events, enqueues to processing queue.
|
|
@@ -8487,6 +8650,7 @@ class FileSystemWatcher {
|
|
|
8487
8650
|
initialScanTracker;
|
|
8488
8651
|
contentHashCache;
|
|
8489
8652
|
onVcsFileChange;
|
|
8653
|
+
onInitialScanComplete;
|
|
8490
8654
|
moveCorrelator;
|
|
8491
8655
|
globMatches;
|
|
8492
8656
|
watcher;
|
|
@@ -8508,6 +8672,7 @@ class FileSystemWatcher {
|
|
|
8508
8672
|
this.initialScanTracker = options.initialScanTracker;
|
|
8509
8673
|
this.contentHashCache = options.contentHashCache;
|
|
8510
8674
|
this.onVcsFileChange = options.onVcsFileChange;
|
|
8675
|
+
this.onInitialScanComplete = options.onInitialScanComplete;
|
|
8511
8676
|
this.globMatches = () => true;
|
|
8512
8677
|
const healthOptions = {
|
|
8513
8678
|
maxRetries: options.maxRetries,
|
|
@@ -8535,24 +8700,9 @@ class FileSystemWatcher {
|
|
|
8535
8700
|
? resolveIgnored(this.config.ignored)
|
|
8536
8701
|
: undefined;
|
|
8537
8702
|
// Track initial scan statistics per root for diagnostics.
|
|
8538
|
-
const scanStats =
|
|
8539
|
-
total: 0,
|
|
8540
|
-
matched: 0,
|
|
8541
|
-
globRejected: 0,
|
|
8542
|
-
gitignored: 0,
|
|
8543
|
-
byRoot: Object.fromEntries(roots.map((r) => [r, 0])),
|
|
8544
|
-
};
|
|
8703
|
+
const scanStats = new ScanStats(roots);
|
|
8545
8704
|
let initialScanComplete = false;
|
|
8546
8705
|
this.initialScanTracker?.start();
|
|
8547
|
-
const classifyByRoot = (path) => {
|
|
8548
|
-
const normalized = path.replace(/\\/g, '/').toLowerCase();
|
|
8549
|
-
for (const root of roots) {
|
|
8550
|
-
if (normalized.startsWith(root + '/') || normalized === root) {
|
|
8551
|
-
scanStats.byRoot[root] = (scanStats.byRoot[root] ?? 0) + 1;
|
|
8552
|
-
break;
|
|
8553
|
-
}
|
|
8554
|
-
}
|
|
8555
|
-
};
|
|
8556
8706
|
// Create move correlator if move detection is configured and cache is available.
|
|
8557
8707
|
const moveConfig = this.config.moveDetection;
|
|
8558
8708
|
if (moveConfig?.enabled && this.contentHashCache) {
|
|
@@ -8585,7 +8735,7 @@ class FileSystemWatcher {
|
|
|
8585
8735
|
this.watcher.on('add', (path) => {
|
|
8586
8736
|
if (!initialScanComplete) {
|
|
8587
8737
|
scanStats.total++;
|
|
8588
|
-
classifyByRoot(path);
|
|
8738
|
+
scanStats.classifyByRoot(path);
|
|
8589
8739
|
}
|
|
8590
8740
|
this.handleGitignoreChange(path);
|
|
8591
8741
|
if (!this.globMatches(path)) {
|
|
@@ -8606,8 +8756,7 @@ class FileSystemWatcher {
|
|
|
8606
8756
|
this.initialScanTracker?.incrementEnqueued();
|
|
8607
8757
|
}
|
|
8608
8758
|
this.logger.debug({ path }, 'File added');
|
|
8609
|
-
|
|
8610
|
-
this.onVcsFileChange?.(path, 'add');
|
|
8759
|
+
this.onVcsFileChange?.(path, 'add');
|
|
8611
8760
|
if (correlator && initialScanComplete) {
|
|
8612
8761
|
void correlator.handleAdd(path);
|
|
8613
8762
|
}
|
|
@@ -8646,6 +8795,7 @@ class FileSystemWatcher {
|
|
|
8646
8795
|
initialScanComplete = true;
|
|
8647
8796
|
this.initialScanTracker?.complete();
|
|
8648
8797
|
this.logger.info({ scanStats }, 'Initial scan complete');
|
|
8798
|
+
void this.onInitialScanComplete?.();
|
|
8649
8799
|
});
|
|
8650
8800
|
this.watcher.on('error', (error) => {
|
|
8651
8801
|
this.logger.error({ err: normalizeError(error) }, 'Watcher error');
|
|
@@ -8919,6 +9069,12 @@ class JeevesWatcher {
|
|
|
8919
9069
|
* Start the watcher, API server, and all components.
|
|
8920
9070
|
*/
|
|
8921
9071
|
async start() {
|
|
9072
|
+
await this.initInfrastructure();
|
|
9073
|
+
await this.initPipeline();
|
|
9074
|
+
await this.startServicesAndWatcher();
|
|
9075
|
+
}
|
|
9076
|
+
/** Set up logger, VCS, embedding provider, and vector store. */
|
|
9077
|
+
async initInfrastructure() {
|
|
8922
9078
|
const logger = this.factories.createLogger(this.config.logging);
|
|
8923
9079
|
this.logger = logger;
|
|
8924
9080
|
await initVcs(this.config, logger);
|
|
@@ -8928,6 +9084,10 @@ class JeevesWatcher {
|
|
|
8928
9084
|
if (this.config.search?.hybrid?.enabled) {
|
|
8929
9085
|
await vectorStore.ensureTextIndex('chunk_text');
|
|
8930
9086
|
}
|
|
9087
|
+
}
|
|
9088
|
+
/** Build the processing pipeline: rules, templates, processor, queue, VCS. */
|
|
9089
|
+
async initPipeline() {
|
|
9090
|
+
const logger = this.logger;
|
|
8931
9091
|
const compiledRules = this.factories.compileRules(this.config.inferenceRules ?? []);
|
|
8932
9092
|
const configDir = getConfigDir(this.configPath);
|
|
8933
9093
|
const { templateEngine, customMapLib } = await buildTemplateEngineAndCustomMapLib(this.config, configDir);
|
|
@@ -8937,36 +9097,39 @@ class JeevesWatcher {
|
|
|
8937
9097
|
this.issuesManager = new IssuesManager(stateDir, logger);
|
|
8938
9098
|
this.valuesManager = new ValuesManager(stateDir, logger);
|
|
8939
9099
|
this.enrichmentStore = new EnrichmentStore(stateDir, logger);
|
|
8940
|
-
const enrichmentStore = this.enrichmentStore;
|
|
8941
9100
|
this.contentHashCache = new ContentHashCache();
|
|
8942
|
-
|
|
8943
|
-
const processor = this.factories.createDocumentProcessor({
|
|
9101
|
+
this.processor = this.factories.createDocumentProcessor({
|
|
8944
9102
|
config: processorConfig,
|
|
8945
|
-
embeddingProvider,
|
|
8946
|
-
vectorStore,
|
|
9103
|
+
embeddingProvider: this.embeddingProvider,
|
|
9104
|
+
vectorStore: this.vectorStore,
|
|
8947
9105
|
compiledRules,
|
|
8948
9106
|
logger,
|
|
8949
9107
|
templateEngine,
|
|
8950
|
-
enrichmentStore,
|
|
9108
|
+
enrichmentStore: this.enrichmentStore,
|
|
8951
9109
|
issuesManager: this.issuesManager,
|
|
8952
9110
|
valuesManager: this.valuesManager,
|
|
8953
|
-
contentHashCache,
|
|
9111
|
+
contentHashCache: this.contentHashCache,
|
|
8954
9112
|
});
|
|
8955
|
-
this.processor = processor;
|
|
8956
9113
|
this.queue = this.factories.createEventQueue({
|
|
8957
9114
|
debounceMs: this.config.watch.debounceMs ?? 2000,
|
|
8958
9115
|
concurrency: this.config.embedding.concurrency ?? 5,
|
|
8959
9116
|
rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
|
|
8960
9117
|
});
|
|
8961
|
-
|
|
8962
|
-
|
|
8963
|
-
|
|
9118
|
+
this.vcsCoordinator = new VcsCoordinator(this.config, logger);
|
|
9119
|
+
}
|
|
9120
|
+
/** Wire up watcher and VCS coordinator, then start all services. */
|
|
9121
|
+
async startServicesAndWatcher() {
|
|
9122
|
+
const logger = this.logger;
|
|
9123
|
+
const vcsCoordinator = this.vcsCoordinator;
|
|
9124
|
+
const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, this.processor, logger, this.runtimeOptions, this.initialScanTracker, this.contentHashCache, (filePath, event) => {
|
|
8964
9125
|
vcsCoordinator.onFileChange(filePath, event);
|
|
9126
|
+
}, () => {
|
|
9127
|
+
void vcsCoordinator.onInitialScanComplete();
|
|
8965
9128
|
});
|
|
8966
9129
|
this.watcher = watcher;
|
|
8967
9130
|
this.gitignoreFilter = gitignoreFilter;
|
|
8968
9131
|
this.server = await this.startApiServer();
|
|
8969
|
-
|
|
9132
|
+
vcsCoordinator.start();
|
|
8970
9133
|
this.watcher.start();
|
|
8971
9134
|
this.startConfigWatch();
|
|
8972
9135
|
logger.info('jeeves-watcher started');
|
|
@@ -9081,6 +9244,8 @@ class JeevesWatcher {
|
|
|
9081
9244
|
contentHashCache: this.contentHashCache,
|
|
9082
9245
|
valuesManager: this.valuesManager,
|
|
9083
9246
|
issuesManager: this.issuesManager,
|
|
9247
|
+
onVcsFileChange: (filePath, event) => this.vcsCoordinator?.onFileChange(filePath, event),
|
|
9248
|
+
onInitialScanComplete: () => this.vcsCoordinator?.onInitialScanComplete(),
|
|
9084
9249
|
});
|
|
9085
9250
|
this.config = state.config;
|
|
9086
9251
|
this.watcher = state.watcher;
|