@karmaniverous/jeeves-watcher 0.18.9 → 0.18.11

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
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, 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';
3
+ import { extractWatchPathStrings, inferenceRuleSchema, jeevesWatcherConfigSchema, normalizeWatchPaths, vcsRetentionConfigSchema, vcsAuthorConfigSchema, getEndpoint, 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
6
  import { readdir, stat, access, readFile, writeFile, mkdir, appendFile, rm } from 'node:fs/promises';
@@ -427,6 +427,39 @@ async function retry(fn, options) {
427
427
  */
428
428
  /** Promisified execFile for git commands. */
429
429
  const execFileAsync = promisify(execFile);
430
+ /** Standard timeout (ms) for most git operations. */
431
+ const GIT_TIMEOUT_STANDARD = 30_000;
432
+ /** Extended timeout (ms) for cherry-pick operations. */
433
+ const GIT_TIMEOUT_CHERRY_PICK = 120_000;
434
+ /** Timeout (ms) for push operations. */
435
+ const GIT_TIMEOUT_PUSH = 60_000;
436
+ /**
437
+ * Extract string fields from a child-process error.
438
+ * Node's execFile wraps failures in an Error with `stderr`, `stdout`, and
439
+ * `code` properties that aren't part of the Error type.
440
+ */
441
+ function getExecErrorFields(error) {
442
+ if (!(error instanceof Error)) {
443
+ return { message: String(error), stderr: '', stdout: '' };
444
+ }
445
+ const rec = error;
446
+ const stderr = typeof rec['stderr'] === 'string' ? rec['stderr'] : '';
447
+ const stdout = typeof rec['stdout'] === 'string' ? rec['stdout'] : '';
448
+ return { message: error.message || '', stderr, stdout };
449
+ }
450
+ /**
451
+ * Build an authenticated push URL by injecting a token into an HTTPS remote.
452
+ * Non-HTTPS URLs are returned as-is.
453
+ *
454
+ * @param remoteUrl - The remote repository URL.
455
+ * @param accessToken - Optional access token to inject.
456
+ * @returns The URL with the token injected if applicable.
457
+ */
458
+ function buildAuthenticatedPushUrl(remoteUrl, accessToken) {
459
+ if (!accessToken)
460
+ return remoteUrl;
461
+ return remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`);
462
+ }
430
463
  /**
431
464
  * Stage files via stdin to avoid ENAMETOOLONG on Windows.
432
465
  *
@@ -438,22 +471,34 @@ const execFileAsync = promisify(execFile);
438
471
  *
439
472
  * @param files - Absolute paths of files to stage.
440
473
  * @param cwd - Repository root (working directory for git).
474
+ * @param timeoutMs - Kill the child process after this many milliseconds. Default: 30000.
441
475
  */
442
- function gitAddViaStdin(files, cwd) {
476
+ function gitAddViaStdin(files, cwd, timeoutMs = 30_000) {
443
477
  return new Promise((resolve, reject) => {
478
+ let timedOut = false;
444
479
  const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
480
+ clearTimeout(timer);
481
+ if (timedOut)
482
+ return;
445
483
  if (error)
446
484
  reject(error);
447
485
  else
448
486
  resolve();
449
487
  });
488
+ const timer = setTimeout(() => {
489
+ timedOut = true;
490
+ child.kill();
491
+ reject(new Error(`git add timed out after ${String(timeoutMs)}ms`));
492
+ }, timeoutMs);
450
493
  if (!child.stdin) {
494
+ clearTimeout(timer);
451
495
  reject(new Error('Failed to open stdin for git add'));
452
496
  return;
453
497
  }
454
498
  // Suppress EPIPE — expected if git exits before we finish writing
455
499
  child.stdin.on('error', (err) => {
456
500
  if (err.code !== 'EPIPE') {
501
+ clearTimeout(timer);
457
502
  reject(err);
458
503
  }
459
504
  });
@@ -4320,8 +4365,8 @@ class CommitMessageBuilder {
4320
4365
  */
4321
4366
  async getStagedDiff() {
4322
4367
  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 });
4368
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4369
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4325
4370
  return `${stat}\n${patch}`;
4326
4371
  }
4327
4372
  catch {
@@ -4502,29 +4547,26 @@ function partMatches(part, value, min, max) {
4502
4547
  // Number with step doesn't make sense for single values, treat as exact
4503
4548
  return value === num;
4504
4549
  }
4505
- /**
4506
- * Manages scheduled squash retention for a single VCS root.
4507
- *
4508
- * Runs on a cron schedule (checked every 60s). When triggered, squashes
4509
- * commits older than the retention boundary (the tighter of maxAgeDays
4510
- * and maxVersions) into a single "historical baseline" orphan commit,
4511
- * then cherry-picks retained commits on top. Force-pushes to remote
4512
- * if configured (D13).
4513
- */
4514
4550
  class SquashManager {
4515
4551
  rootPath;
4516
4552
  retention;
4553
+ branch;
4554
+ pauseCommits;
4555
+ resumeCommits;
4517
4556
  remoteUrl;
4518
4557
  accessToken;
4519
4558
  logger;
4520
4559
  intervalTimer;
4521
4560
  running = false;
4522
- constructor(rootPath, retention, logger, remoteUrl, accessToken) {
4561
+ constructor(rootPath, retention, logger, options = {}) {
4523
4562
  this.rootPath = rootPath;
4524
4563
  this.retention = retention;
4525
4564
  this.logger = logger;
4526
- this.remoteUrl = remoteUrl;
4527
- this.accessToken = accessToken;
4565
+ this.branch = options.branch ?? 'master';
4566
+ this.pauseCommits = options.pauseCommits;
4567
+ this.resumeCommits = options.resumeCommits;
4568
+ this.remoteUrl = options.remoteUrl;
4569
+ this.accessToken = options.accessToken;
4528
4570
  }
4529
4571
  /**
4530
4572
  * Start the cron-based squash scheduler.
@@ -4554,6 +4596,9 @@ class SquashManager {
4554
4596
  }
4555
4597
  /**
4556
4598
  * Run the squash operation. Exposed publicly for testing.
4599
+ *
4600
+ * Coordinates with VcsManager by calling pause/resume callbacks
4601
+ * to drain and suspend the commit pipeline during the squash.
4557
4602
  */
4558
4603
  async runSquash() {
4559
4604
  // Check for index.lock before starting multi-step operation
@@ -4567,6 +4612,16 @@ class SquashManager {
4567
4612
  catch {
4568
4613
  // No lock — proceed
4569
4614
  }
4615
+ // Bug 3: Pause the commit pipeline before squash
4616
+ try {
4617
+ await this.pauseCommits?.();
4618
+ }
4619
+ catch (error) {
4620
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Failed to pause commit pipeline before squash');
4621
+ // Resume in case pause partially executed (flush succeeded but flag not set)
4622
+ this.resumeCommits?.();
4623
+ return { squashed: false, error: normalizeError(error).message };
4624
+ }
4570
4625
  try {
4571
4626
  const commits = await this.getCommitLog();
4572
4627
  if (commits.length <= 1) {
@@ -4591,13 +4646,17 @@ class SquashManager {
4591
4646
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
4592
4647
  return { squashed: false, error: message };
4593
4648
  }
4649
+ finally {
4650
+ // Bug 3: Always resume the commit pipeline
4651
+ this.resumeCommits?.();
4652
+ }
4594
4653
  }
4595
4654
  /**
4596
4655
  * Get all commits in chronological order (oldest first).
4597
4656
  */
4598
4657
  async getCommitLog() {
4599
4658
  try {
4600
- const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath });
4659
+ const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4601
4660
  if (!stdout.trim())
4602
4661
  return [];
4603
4662
  return stdout
@@ -4644,32 +4703,35 @@ class SquashManager {
4644
4703
  }
4645
4704
  /**
4646
4705
  * Perform the squash: create orphan with baseline + cherry-pick retained commits.
4706
+ * Uses the configured branch name instead of dynamic detection (Bug 1).
4647
4707
  */
4648
4708
  async performSquash(commits, boundaryIndex) {
4649
4709
  // The commit just before boundary is the last one to squash
4650
4710
  const baselineHash = commits[boundaryIndex - 1].hash;
4651
- // Get the current branch name
4652
- const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: this.rootPath });
4653
- const currentBranch = branchOut.trim();
4711
+ // Bug 1: Use configured branch name, not dynamic detection
4712
+ const targetBranch = this.branch;
4654
4713
  const orphanBranch = `__squash_orphan_${String(Date.now())}`;
4655
4714
  try {
4656
4715
  // 1. Create orphan branch with the tree state at the baseline commit
4657
4716
  await execFileAsync('git', ['checkout', '--orphan', orphanBranch], {
4658
4717
  cwd: this.rootPath,
4718
+ timeout: GIT_TIMEOUT_STANDARD,
4659
4719
  });
4660
4720
  // 2. Reset index to the baseline tree (the last squashed commit's tree)
4661
4721
  await execFileAsync('git', ['reset', '--hard', baselineHash], {
4662
4722
  cwd: this.rootPath,
4723
+ timeout: GIT_TIMEOUT_STANDARD,
4663
4724
  });
4664
4725
  // 3. Create the baseline commit with the same tree
4665
4726
  // Use commit-tree for a clean orphan commit
4666
- const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath });
4727
+ const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4667
4728
  const treeHash = treeOut.trim();
4668
- const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath });
4729
+ const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4669
4730
  const baselineCommitHash = commitOut.trim();
4670
4731
  // 4. Reset orphan branch to this new baseline commit
4671
4732
  await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
4672
4733
  cwd: this.rootPath,
4734
+ timeout: GIT_TIMEOUT_STANDARD,
4673
4735
  });
4674
4736
  // 5. Cherry-pick boundary..HEAD (the retained commits)
4675
4737
  // Get the list of commits to cherry-pick (from boundary to the end)
@@ -4677,28 +4739,33 @@ class SquashManager {
4677
4739
  if (commitsToCherry.length > 0) {
4678
4740
  await execFileAsync('git', ['cherry-pick', ...commitsToCherry], {
4679
4741
  cwd: this.rootPath,
4742
+ timeout: GIT_TIMEOUT_CHERRY_PICK,
4680
4743
  });
4681
4744
  }
4682
- // 6. Force-update the original branch to the orphan
4683
- const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath });
4684
- await execFileAsync('git', ['branch', '-f', currentBranch, newHead.trim()], { cwd: this.rootPath });
4685
- // 7. Switch back to original branch
4686
- await execFileAsync('git', ['checkout', currentBranch], {
4745
+ // 6. Force-update the configured branch to the orphan
4746
+ const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4747
+ await execFileAsync('git', ['branch', '-f', targetBranch, newHead.trim()], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4748
+ // 7. Switch back to configured branch
4749
+ await execFileAsync('git', ['checkout', targetBranch], {
4687
4750
  cwd: this.rootPath,
4751
+ timeout: GIT_TIMEOUT_STANDARD,
4688
4752
  });
4689
4753
  // 8. Delete orphan branch
4690
4754
  await execFileAsync('git', ['branch', '-D', orphanBranch], {
4691
4755
  cwd: this.rootPath,
4756
+ timeout: GIT_TIMEOUT_STANDARD,
4692
4757
  });
4693
4758
  }
4694
4759
  catch (error) {
4695
- // Attempt cleanup: try to get back to original branch
4760
+ // Attempt cleanup: try to get back to configured branch
4696
4761
  try {
4697
- await execFileAsync('git', ['checkout', '-f', currentBranch], {
4762
+ await execFileAsync('git', ['checkout', '-f', targetBranch], {
4698
4763
  cwd: this.rootPath,
4764
+ timeout: GIT_TIMEOUT_STANDARD,
4699
4765
  });
4700
4766
  await execFileAsync('git', ['branch', '-D', orphanBranch], {
4701
4767
  cwd: this.rootPath,
4768
+ timeout: GIT_TIMEOUT_STANDARD,
4702
4769
  });
4703
4770
  }
4704
4771
  catch {
@@ -4722,12 +4789,11 @@ class SquashManager {
4722
4789
  if (!this.remoteUrl)
4723
4790
  return;
4724
4791
  try {
4725
- const pushUrl = this.accessToken
4726
- ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
4727
- : this.remoteUrl;
4792
+ const pushUrl = buildAuthenticatedPushUrl(this.remoteUrl, this.accessToken);
4728
4793
  await execFileAsync('git', ['push', '--force', pushUrl, 'HEAD'], {
4729
4794
  cwd: this.rootPath,
4730
4795
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
4796
+ timeout: GIT_TIMEOUT_PUSH,
4731
4797
  });
4732
4798
  this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'Squash force push succeeded');
4733
4799
  }
@@ -4828,6 +4894,46 @@ async function configureRepoIdentity(rootPath, name, email) {
4828
4894
  cwd: rootPath,
4829
4895
  });
4830
4896
  }
4897
+ /**
4898
+ * Detect and recover from orphan branch state on startup.
4899
+ *
4900
+ * If the repo is on an unexpected branch (e.g. a leftover squash orphan),
4901
+ * force-updates the expected branch to the current HEAD and checks it out.
4902
+ * Orphan branches are NOT deleted — they serve as a recovery safety net.
4903
+ *
4904
+ * @param rootPath - Directory of the git repository.
4905
+ * @param expectedBranch - The configured branch name (e.g. "master").
4906
+ * @param logger - Logger instance for warnings and info.
4907
+ */
4908
+ async function detectAndRecoverOrphanBranch(rootPath, expectedBranch, logger) {
4909
+ const gitDir = join(rootPath, '.git');
4910
+ try {
4911
+ await access(gitDir);
4912
+ }
4913
+ catch {
4914
+ // Not a git repo — nothing to detect
4915
+ return;
4916
+ }
4917
+ const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
4918
+ const currentBranch = branchOut.trim();
4919
+ if (currentBranch === expectedBranch)
4920
+ return;
4921
+ logger.warn({ root: rootPath, currentBranch, expectedBranch }, 'Repo is on unexpected branch — recovering to configured branch');
4922
+ // Get current HEAD
4923
+ const { stdout: headOut } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
4924
+ const headHash = headOut.trim();
4925
+ // Force-update the expected branch to current HEAD
4926
+ await execFileAsync('git', ['branch', '-f', expectedBranch, headHash], {
4927
+ cwd: rootPath,
4928
+ timeout: GIT_TIMEOUT_STANDARD,
4929
+ });
4930
+ // Checkout the expected branch
4931
+ await execFileAsync('git', ['checkout', expectedBranch], {
4932
+ cwd: rootPath,
4933
+ timeout: GIT_TIMEOUT_STANDARD,
4934
+ });
4935
+ logger.info({ root: rootPath, recoveredFrom: currentBranch, expectedBranch, headHash }, 'Orphan branch recovery complete — now on configured branch');
4936
+ }
4831
4937
  /**
4832
4938
  * Ensure a .gitignore file exists at rootPath with all always-on entries.
4833
4939
  * Creates the file if missing. Appends only entries not already present.
@@ -4914,17 +5020,36 @@ function buildGlobMatcher(globs) {
4914
5020
  return isMatch(normalized);
4915
5021
  };
4916
5022
  }
5023
+ /**
5024
+ * Expand a bare directory path (no glob characters) to a recursive glob.
5025
+ * A path like `/opt/jeeves/content` becomes `/opt/jeeves/content/**`.
5026
+ * Paths that already contain glob characters are returned unchanged.
5027
+ *
5028
+ * @param glob - A glob pattern or bare directory path.
5029
+ * @returns The original glob, or the path suffixed with `/**` if bare.
5030
+ */
5031
+ function expandBareDirectoryGlob(glob) {
5032
+ const normalized = normalizeSlashes(glob);
5033
+ if (/[*?{[\]]/.test(normalized))
5034
+ return glob;
5035
+ return glob.replace(/\/+$/, '') + '/**';
5036
+ }
4917
5037
  /**
4918
5038
  * Convert an array of glob patterns into chokidar-compatible directory roots
4919
5039
  * and a filter function for post-hoc event filtering.
4920
5040
  *
5041
+ * Bare directory paths (no glob characters, e.g. `/opt/jeeves/content`) are
5042
+ * automatically expanded to recursive globs (`/opt/jeeves/content/**`) so that
5043
+ * picomatch matches all files underneath them.
5044
+ *
4921
5045
  * @param globs - Glob patterns from the watch config.
4922
5046
  * @returns Object with `roots` (directories for chokidar) and `matches` (filter function).
4923
5047
  */
4924
5048
  function resolveWatchPaths(globs) {
4925
- const rawRoots = globs.map(globRoot);
5049
+ const expandedGlobs = globs.map(expandBareDirectoryGlob);
5050
+ const rawRoots = expandedGlobs.map(globRoot);
4926
5051
  const roots = deduplicateRoots(rawRoots);
4927
- const matches = buildGlobMatcher(globs);
5052
+ const matches = buildGlobMatcher(expandedGlobs);
4928
5053
  return { roots, matches };
4929
5054
  }
4930
5055
  /**
@@ -4977,13 +5102,11 @@ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger
4977
5102
  if (!remoteUrl)
4978
5103
  return undefined;
4979
5104
  try {
4980
- // Build the authenticated URL if a token is available
4981
- const pushUrl = accessToken
4982
- ? remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`)
4983
- : remoteUrl;
5105
+ const pushUrl = buildAuthenticatedPushUrl(remoteUrl, accessToken);
4984
5106
  await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
4985
5107
  cwd: rootPath,
4986
5108
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
5109
+ timeout: GIT_TIMEOUT_PUSH,
4987
5110
  });
4988
5111
  const timestamp = new Date().toISOString();
4989
5112
  logger.info({ root: rootPath, remote: remoteUrl }, 'VCS push succeeded');
@@ -5008,7 +5131,7 @@ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger
5008
5131
  * Per-root VCS manager for git-backed content versioning.
5009
5132
  *
5010
5133
  * Each VCS-enabled watch root gets its own VcsManager instance, which owns
5011
- * a debounced commit pipeline: file changes are batched, staged, committed
5134
+ * a throttled commit pipeline: file changes are batched, staged, committed
5012
5135
  * (with optional AI-generated messages), and optionally pushed to a remote.
5013
5136
  *
5014
5137
  * Concurrency: only one commit is in-flight at a time per root. Index.lock
@@ -5025,10 +5148,11 @@ class VcsManager {
5025
5148
  pendingReversions = [];
5026
5149
  _pushErrors = [];
5027
5150
  squashManager;
5028
- debounceTimer;
5151
+ throttleTimer;
5029
5152
  commitInFlight = Promise.resolve();
5030
5153
  consecutiveCommitFailures = 0;
5031
5154
  started = false;
5155
+ paused = false;
5032
5156
  isBaseline = true;
5033
5157
  _lastPushTime = null;
5034
5158
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
@@ -5039,7 +5163,17 @@ class VcsManager {
5039
5163
  this.accessToken = accessToken;
5040
5164
  this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
5041
5165
  if (config.retention) {
5042
- this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
5166
+ this.squashManager = new SquashManager(rootPath, config.retention, logger, {
5167
+ branch: config.branch,
5168
+ pauseCommits: () => {
5169
+ return this.pause();
5170
+ },
5171
+ resumeCommits: () => {
5172
+ this.resume();
5173
+ },
5174
+ remoteUrl,
5175
+ accessToken,
5176
+ });
5043
5177
  }
5044
5178
  }
5045
5179
  get lastPushTime() {
@@ -5052,10 +5186,21 @@ class VcsManager {
5052
5186
  * Begin accepting file changes. Sets the manager to started state and
5053
5187
  * starts the squash manager if configured.
5054
5188
  *
5189
+ * Performs startup orphan detection/recovery before starting the squash
5190
+ * manager, ensuring the repo is on the configured branch.
5191
+ *
5055
5192
  * Baseline mode (commit prefix `"baseline:"`) remains active until
5056
5193
  * {@link endBaseline} is called by the coordinator after the initial scan.
5057
5194
  */
5058
- start() {
5195
+ async start() {
5196
+ // Bug 6: Detect and recover from orphan branches before normal operations
5197
+ const branch = this.config.branch;
5198
+ try {
5199
+ await detectAndRecoverOrphanBranch(this.rootPath, branch, this.logger);
5200
+ }
5201
+ catch (error) {
5202
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Orphan branch recovery failed — continuing with current state');
5203
+ }
5059
5204
  this.started = true;
5060
5205
  this.squashManager?.start();
5061
5206
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
@@ -5068,7 +5213,7 @@ class VcsManager {
5068
5213
  this.isBaseline = false;
5069
5214
  }
5070
5215
  /**
5071
- * Add a file to the pending set and reset the debounce timer.
5216
+ * Add a file to the pending set and start the throttle timer.
5072
5217
  * If the pending set exceeds maxBatchSize, flush immediately.
5073
5218
  *
5074
5219
  * @param filePath - Absolute path of the changed file.
@@ -5083,15 +5228,15 @@ class VcsManager {
5083
5228
  }
5084
5229
  this.pending.add(filePath);
5085
5230
  if (this.pending.size >= this.config.maxBatchSize) {
5086
- this.clearDebounce();
5231
+ this.clearThrottle();
5087
5232
  const batch = this.takeBatch(this.config.maxBatchSize);
5088
5233
  this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
5089
5234
  if (this.pending.size > 0) {
5090
- this.resetDebounce();
5235
+ this.resetThrottle();
5091
5236
  }
5092
5237
  return;
5093
5238
  }
5094
- this.resetDebounce();
5239
+ this.resetThrottle();
5095
5240
  }
5096
5241
  /**
5097
5242
  * Record reversion metadata so the next commit uses a revert-prefixed message.
@@ -5112,11 +5257,33 @@ class VcsManager {
5112
5257
  return;
5113
5258
  this.fileChanged(filePath);
5114
5259
  }
5260
+ /**
5261
+ * Pause the commit pipeline. Drains pending commits via flush(), then
5262
+ * sets a flag that makes commitBatch queue instead of execute.
5263
+ * Used by SquashManager to coordinate squash operations.
5264
+ */
5265
+ async pause() {
5266
+ await this.flush();
5267
+ this.paused = true;
5268
+ this.logger.debug({ root: this.rootPath }, 'VcsManager paused');
5269
+ }
5270
+ /**
5271
+ * Resume the commit pipeline after a pause.
5272
+ * Re-enables normal commit operations and starts a throttle timer
5273
+ * if there are pending files that accumulated during the pause.
5274
+ */
5275
+ resume() {
5276
+ this.paused = false;
5277
+ this.logger.debug({ root: this.rootPath }, 'VcsManager resumed');
5278
+ if (this.pending.size > 0) {
5279
+ this.resetThrottle();
5280
+ }
5281
+ }
5115
5282
  /**
5116
5283
  * Flush all pending files immediately.
5117
5284
  */
5118
5285
  async flush() {
5119
- this.clearDebounce();
5286
+ this.clearThrottle();
5120
5287
  if (this.pending.size > 0) {
5121
5288
  const batch = [...this.pending];
5122
5289
  this.pending.clear();
@@ -5149,23 +5316,25 @@ class VcsManager {
5149
5316
  return batch;
5150
5317
  }
5151
5318
  /**
5152
- * Clear the debounce timer.
5319
+ * Clear the throttle timer.
5153
5320
  */
5154
- clearDebounce() {
5155
- if (this.debounceTimer !== undefined) {
5156
- clearTimeout(this.debounceTimer);
5157
- this.debounceTimer = undefined;
5321
+ clearThrottle() {
5322
+ if (this.throttleTimer !== undefined) {
5323
+ clearTimeout(this.throttleTimer);
5324
+ this.throttleTimer = undefined;
5158
5325
  }
5159
5326
  }
5160
5327
  /**
5161
- * Reset the debounce timer to fire flush() after commitDebounceMs.
5328
+ * Start the throttle timer to fire flush() after commitThrottleMs.
5329
+ * If a timer is already running, do not reset it — this is throttle, not debounce.
5162
5330
  */
5163
- resetDebounce() {
5164
- this.clearDebounce();
5165
- this.debounceTimer = setTimeout(() => {
5166
- this.debounceTimer = undefined;
5331
+ resetThrottle() {
5332
+ if (this.throttleTimer !== undefined)
5333
+ return;
5334
+ this.throttleTimer = setTimeout(() => {
5335
+ this.throttleTimer = undefined;
5167
5336
  void this.flush();
5168
- }, this.config.commitDebounceMs);
5337
+ }, this.config.commitThrottleMs);
5169
5338
  }
5170
5339
  /**
5171
5340
  * Check for and remove stale index.lock files before retrying.
@@ -5187,13 +5356,31 @@ class VcsManager {
5187
5356
  }
5188
5357
  }
5189
5358
  }
5359
+ /**
5360
+ * Check whether a git error represents a "nothing to commit" state.
5361
+ * Git outputs this message to stdout (not stderr) on exit code 1.
5362
+ */
5363
+ isNothingToCommitError(error) {
5364
+ const { message, stderr, stdout } = getExecErrorFields(error);
5365
+ return (message.includes('nothing to commit') ||
5366
+ stderr.includes('nothing to commit') ||
5367
+ stdout.includes('nothing to commit'));
5368
+ }
5190
5369
  /**
5191
5370
  * Commit a batch of files to the git repo with index.lock retry,
5192
- * stale lock detection, circuit breaker, and re-queue cap.
5371
+ * stale lock detection, circuit breaker, pause support, and re-queue cap.
5193
5372
  */
5194
5373
  async commitBatch(files) {
5195
5374
  if (files.length === 0)
5196
5375
  return;
5376
+ // Pause support: re-queue files without counting as failure
5377
+ if (this.paused) {
5378
+ for (const f of files) {
5379
+ this.pending.add(f);
5380
+ }
5381
+ this.logger.debug({ root: this.rootPath, fileCount: files.length }, 'VcsManager paused — files re-queued');
5382
+ return;
5383
+ }
5197
5384
  // Circuit breaker: skip if too many consecutive failures
5198
5385
  if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
5199
5386
  this.logger.error({
@@ -5207,7 +5394,7 @@ class VcsManager {
5207
5394
  // Check for stale lock before first attempt
5208
5395
  await this.removeStaleLock();
5209
5396
  await retry(async (attempt) => {
5210
- await gitAddViaStdin(files, this.rootPath);
5397
+ await gitAddViaStdin(files, this.rootPath, GIT_TIMEOUT_STANDARD);
5211
5398
  // Build message after staging so getStagedDiff can see the changes.
5212
5399
  // Skip AI for baselines and retries — use template directly.
5213
5400
  const message = this.isBaseline || attempt > 1
@@ -5216,8 +5403,9 @@ class VcsManager {
5216
5403
  this.pendingReversions.length = 0;
5217
5404
  await execFileAsync('git', ['commit', '-m', message], {
5218
5405
  cwd: this.rootPath,
5406
+ timeout: GIT_TIMEOUT_STANDARD,
5219
5407
  });
5220
- const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
5408
+ const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
5221
5409
  const hash = hashOut.trim();
5222
5410
  this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
5223
5411
  const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
@@ -5235,6 +5423,11 @@ class VcsManager {
5235
5423
  this.consecutiveCommitFailures = 0;
5236
5424
  }
5237
5425
  catch (error) {
5426
+ // Bug 5: "nothing to commit" is a no-op, not a failure
5427
+ if (this.isNothingToCommitError(error)) {
5428
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'VCS commit skipped — nothing to commit');
5429
+ return;
5430
+ }
5238
5431
  this.consecutiveCommitFailures++;
5239
5432
  // Re-queue cap: only re-queue up to maxBatchSize files
5240
5433
  const cap = this.config.maxBatchSize;
@@ -5248,6 +5441,8 @@ class VcsManager {
5248
5441
  }
5249
5442
  this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
5250
5443
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
5444
+ // Bug 2: Restart throttle timer so re-queued files get retried
5445
+ this.resetThrottle();
5251
5446
  }
5252
5447
  }
5253
5448
  }
@@ -5308,11 +5503,14 @@ class VcsCoordinator {
5308
5503
  }
5309
5504
  /**
5310
5505
  * Start all VcsManager instances.
5506
+ * Runs startup orphan detection/recovery for each root.
5311
5507
  */
5312
- start() {
5508
+ async start() {
5509
+ const startPromises = [];
5313
5510
  this.managers.forEach((manager) => {
5314
- manager.start();
5511
+ startPromises.push(manager.start());
5315
5512
  });
5513
+ await Promise.all(startPromises);
5316
5514
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
5317
5515
  }
5318
5516
  /**
@@ -5336,8 +5534,8 @@ class VcsCoordinator {
5336
5534
  /**
5337
5535
  * Signal that the initial filesystem scan is complete.
5338
5536
  *
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
5537
+ * Flushes each manager's throttle buffer before ending baseline mode, ensuring
5538
+ * any files still pending in the VCS throttle are committed with a
5341
5539
  * `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
5342
5540
  * until all managers have completed their flush.
5343
5541
  */
@@ -5901,36 +6099,36 @@ function createApiServer(options) {
5901
6099
  };
5902
6100
  },
5903
6101
  });
5904
- app.get('/status', withCache(cacheTtlMs, async () => {
6102
+ app.get(getEndpoint('status').path, withCache(cacheTtlMs, async () => {
5905
6103
  const result = await coreStatusHandler();
5906
6104
  return result.body;
5907
6105
  }));
5908
- app.post('/metadata', createMetadataHandler({
6106
+ app.post(getEndpoint('metadata').path, createMetadataHandler({
5909
6107
  processor,
5910
6108
  getConfig,
5911
6109
  logger,
5912
6110
  configDir: dirname(configPath),
5913
6111
  }));
5914
- app.post('/render', withCache(cacheTtlMs, createRenderHandler({
6112
+ app.post(getEndpoint('render').path, withCache(cacheTtlMs, createRenderHandler({
5915
6113
  processor,
5916
6114
  getWatch: () => getConfig().watch,
5917
6115
  logger,
5918
6116
  })));
5919
- app.get('/search/facets', createFacetsHandler({
6117
+ app.get(getEndpoint('searchFacets').path, createFacetsHandler({
5920
6118
  getConfig,
5921
6119
  valuesManager,
5922
6120
  configDir: dirname(configPath),
5923
6121
  }));
5924
- app.post('/scan', createScanHandler({
6122
+ app.post(getEndpoint('scan').path, createScanHandler({
5925
6123
  vectorStore,
5926
6124
  logger,
5927
6125
  }));
5928
- app.post('/walk', createWalkHandler({
6126
+ app.post(getEndpoint('walk').path, createWalkHandler({
5929
6127
  getWatchPaths: () => extractWatchPathStrings(getConfig().watch.paths),
5930
6128
  getFileSystemWatcher,
5931
6129
  logger,
5932
6130
  }));
5933
- app.post('/search', createSearchHandler({
6131
+ app.post(getEndpoint('search').path, createSearchHandler({
5934
6132
  embeddingProvider,
5935
6133
  vectorStore,
5936
6134
  logger,
@@ -5941,12 +6139,12 @@ function createApiServer(options) {
5941
6139
  : undefined;
5942
6140
  },
5943
6141
  }));
5944
- app.post('/rebuild-metadata', createRebuildMetadataHandler({
6142
+ app.post(getEndpoint('rebuildMetadata').path, createRebuildMetadataHandler({
5945
6143
  enrichmentStore: options.enrichmentStore,
5946
6144
  vectorStore,
5947
6145
  logger,
5948
6146
  }));
5949
- app.post('/reindex', createConfigReindexHandler({
6147
+ app.post(getEndpoint('reindex').path, createConfigReindexHandler({
5950
6148
  getConfig,
5951
6149
  processor,
5952
6150
  logger,
@@ -5957,10 +6155,10 @@ function createApiServer(options) {
5957
6155
  vectorStore,
5958
6156
  queue,
5959
6157
  }));
5960
- app.get('/issues', withCache(cacheTtlMs, createIssuesHandler({ issuesManager })));
5961
- app.get('/config/schema', withCache(cacheTtlMs, createConfigSchemaHandler()));
5962
- app.post('/config/match', createConfigMatchHandler({ getConfig, logger }));
5963
- app.get('/config', withCache(cacheTtlMs, createConfigQueryHandler({
6158
+ app.get(getEndpoint('issues').path, withCache(cacheTtlMs, createIssuesHandler({ issuesManager })));
6159
+ app.get(getEndpoint('configSchema').path, withCache(cacheTtlMs, createConfigSchemaHandler()));
6160
+ app.post(getEndpoint('configMatch').path, createConfigMatchHandler({ getConfig, logger }));
6161
+ app.get(getEndpoint('config').path, withCache(cacheTtlMs, createConfigQueryHandler({
5964
6162
  getConfig,
5965
6163
  valuesManager,
5966
6164
  issuesManager,
@@ -5970,7 +6168,7 @@ function createApiServer(options) {
5970
6168
  ? () => virtualRuleStore.getAll()
5971
6169
  : undefined,
5972
6170
  })));
5973
- app.post('/config/validate', createConfigValidateHandler({
6171
+ app.post(getEndpoint('configValidate').path, createConfigValidateHandler({
5974
6172
  getConfig,
5975
6173
  logger,
5976
6174
  configDir: dirname(configPath),
@@ -5984,7 +6182,7 @@ function createApiServer(options) {
5984
6182
  return Promise.resolve();
5985
6183
  },
5986
6184
  });
5987
- app.post('/config/apply', async (request, reply) => {
6185
+ app.post(getEndpoint('configApply').path, async (request, reply) => {
5988
6186
  const { patch, replace } = request.body;
5989
6187
  const result = await coreConfigApplyHandler({ patch, replace });
5990
6188
  return reply.status(result.status).send(result.body);
@@ -6003,34 +6201,34 @@ function createApiServer(options) {
6003
6201
  gitignoreFilter,
6004
6202
  vectorStore,
6005
6203
  });
6006
- app.post('/rules/register', createRulesRegisterHandler({
6204
+ app.post(getEndpoint('rulesRegister').path, createRulesRegisterHandler({
6007
6205
  virtualRuleStore,
6008
6206
  logger,
6009
6207
  onRulesChanged,
6010
6208
  }));
6011
- app.delete('/rules/unregister', createRulesUnregisterHandler({
6209
+ app.delete(getEndpoint('rulesUnregister').path, createRulesUnregisterHandler({
6012
6210
  virtualRuleStore,
6013
6211
  logger,
6014
6212
  onRulesChanged,
6015
6213
  }));
6016
- app.delete('/rules/unregister/:source', createRulesUnregisterParamHandler({
6214
+ app.delete(getEndpoint('rulesUnregisterBySource').path, createRulesUnregisterParamHandler({
6017
6215
  virtualRuleStore,
6018
6216
  logger,
6019
6217
  onRulesChanged,
6020
6218
  }));
6021
- app.post('/points/delete', createPointsDeleteHandler({ vectorStore, logger }));
6022
- app.post('/rules/reapply', createRulesReapplyHandler({ processor, vectorStore, logger }));
6219
+ app.post(getEndpoint('pointsDelete').path, createPointsDeleteHandler({ vectorStore, logger }));
6220
+ app.post(getEndpoint('rulesReapply').path, createRulesReapplyHandler({ processor, vectorStore, logger }));
6023
6221
  }
6024
6222
  // VCS routes — only registered when a VcsCoordinator is provided
6025
6223
  if (options.vcsCoordinator) {
6026
6224
  const coordinator = options.vcsCoordinator;
6027
- app.get('/vcs/status', createVcsStatusHandler({ coordinator, logger }));
6028
- app.get('/vcs/history', createVcsHistoryHandler({ coordinator, logger }));
6029
- app.get('/vcs/show', createVcsShowHandler({ coordinator, logger }));
6030
- app.get('/vcs/diff', createVcsDiffHandler({ coordinator, logger }));
6031
- app.get('/vcs/check-exclusion', createVcsCheckExclusionHandler({ coordinator, logger }));
6032
- app.post('/vcs/revert', createVcsRevertHandler({ coordinator, logger }));
6033
- app.post('/vcs/exclude', createVcsExcludeHandler({ coordinator, logger }));
6225
+ app.get(getEndpoint('vcsStatus').path, createVcsStatusHandler({ coordinator, logger }));
6226
+ app.get(getEndpoint('vcsHistory').path, createVcsHistoryHandler({ coordinator, logger }));
6227
+ app.get(getEndpoint('vcsShow').path, createVcsShowHandler({ coordinator, logger }));
6228
+ app.get(getEndpoint('vcsDiff').path, createVcsDiffHandler({ coordinator, logger }));
6229
+ app.get(getEndpoint('vcsCheckExclusion').path, createVcsCheckExclusionHandler({ coordinator, logger }));
6230
+ app.post(getEndpoint('vcsRevert').path, createVcsRevertHandler({ coordinator, logger }));
6231
+ app.post(getEndpoint('vcsExclude').path, createVcsExcludeHandler({ coordinator, logger }));
6034
6232
  }
6035
6233
  return app;
6036
6234
  }
@@ -9129,7 +9327,7 @@ class JeevesWatcher {
9129
9327
  this.watcher = watcher;
9130
9328
  this.gitignoreFilter = gitignoreFilter;
9131
9329
  this.server = await this.startApiServer();
9132
- vcsCoordinator.start();
9330
+ await vcsCoordinator.start();
9133
9331
  this.watcher.start();
9134
9332
  this.startConfigWatch();
9135
9333
  logger.info('jeeves-watcher started');