@suco/su-auggie-mcp 0.1.0

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.
@@ -0,0 +1,697 @@
1
+ /**
2
+ * Per-workspace manager: DirectContext + chokidar + ignore + FileIndexer
3
+ *
4
+ * All indexing is delegated to FileIndexer for centralized file-level error handling.
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import { DirectContext, APIError } from '@augmentcode/auggie-sdk';
9
+ import chokidar from 'chokidar';
10
+ import { INDEXING_CONFIG, WATCHER_CONFIG, PERSISTENCE_CONFIG, isPersistenceEnabled } from './config.js';
11
+ import { createIgnoreFilter, shouldIgnore } from './ignore-filter.js';
12
+ import { createLogger } from './logger.js';
13
+ import { sendLogToClient } from './mcp-notifications.js';
14
+ import { FileIndexer } from './file-indexer.js';
15
+ const logger = createLogger('Workspace');
16
+ /**
17
+ * Retry an async operation with exponential backoff.
18
+ */
19
+ async function retryWithBackoff(operation, operationName, maxRetries = INDEXING_CONFIG.MAX_RETRIES) {
20
+ let lastError;
21
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
22
+ try {
23
+ return await operation();
24
+ }
25
+ catch (err) {
26
+ lastError = err;
27
+ const errorMsg = err instanceof Error ? err.message : String(err);
28
+ // Fatal errors don't retry
29
+ if (err instanceof APIError && (err.status === 401 || err.status === 403)) {
30
+ throw err;
31
+ }
32
+ if (attempt < maxRetries) {
33
+ const delay = INDEXING_CONFIG.RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
34
+ logger.info(`${operationName} failed (attempt ${attempt + 1}/${maxRetries + 1}): ${errorMsg}, retrying in ${Math.round(delay)}ms`);
35
+ await new Promise(resolve => setTimeout(resolve, delay));
36
+ }
37
+ }
38
+ }
39
+ throw lastError;
40
+ }
41
+ /** Workspace manager for a single workspace root */
42
+ export class Workspace {
43
+ root;
44
+ context = null;
45
+ watcher = null;
46
+ ignoreFilter;
47
+ status = 'initializing';
48
+ error = null;
49
+ lastIndexed = null;
50
+ progress = null;
51
+ changeQueue = [];
52
+ debounceTimer = null;
53
+ initialScanComplete = false;
54
+ // FileIndexer handles all indexing, retries, and cooldown
55
+ fileIndexer = null;
56
+ // Timing tracking
57
+ indexingStartTime = null;
58
+ initialIndexSeconds = null;
59
+ lastDeltaSeconds = null;
60
+ // Drift reconciliation stats
61
+ driftDeletedFiles = 0;
62
+ driftModifiedFiles = 0;
63
+ driftReconciled = false;
64
+ driftReconcileTimeMs = null;
65
+ // Track if we restored from persisted state
66
+ restoredFromPersistence = false;
67
+ // File metadata for mtime-based change detection
68
+ fileMetadata = {};
69
+ restoredMetadata = null;
70
+ constructor(root) {
71
+ this.root = path.resolve(root);
72
+ this.ignoreFilter = createIgnoreFilter(this.root);
73
+ }
74
+ /** Create and initialize a workspace */
75
+ static async create(root) {
76
+ const workspace = new Workspace(root);
77
+ await workspace.initialize();
78
+ return workspace;
79
+ }
80
+ /** Initialize the workspace: restore state, start watcher, initial index */
81
+ async initialize() {
82
+ try {
83
+ // Try to restore from persisted state (if persistence enabled)
84
+ const statePath = this.getStatePath();
85
+ if (isPersistenceEnabled() && fs.existsSync(statePath)) {
86
+ logger.info(`Restoring state from ${statePath}`);
87
+ try {
88
+ this.context = await DirectContext.importFromFile(statePath);
89
+ this.restoredFromPersistence = true;
90
+ const indexedCount = this.context.getIndexedPaths().length;
91
+ logger.info(`Restored ${indexedCount} indexed paths from persistence`);
92
+ // Load file metadata for mtime-based change detection
93
+ this.restoredMetadata = this.loadMetadata();
94
+ }
95
+ catch (importErr) {
96
+ // State file corrupted, delete and start fresh
97
+ const msg = importErr instanceof Error ? importErr.message : String(importErr);
98
+ logger.warn(`Failed to import state (${msg}), starting fresh`);
99
+ fs.unlinkSync(statePath);
100
+ this.context = await DirectContext.create();
101
+ this.restoredFromPersistence = false;
102
+ this.restoredMetadata = null;
103
+ }
104
+ }
105
+ else {
106
+ logger.info(`Creating new context for ${this.root}`);
107
+ this.context = await DirectContext.create();
108
+ this.restoredFromPersistence = false;
109
+ this.restoredMetadata = null;
110
+ }
111
+ // Create FileIndexer (handles all indexing, retries, cooldown)
112
+ this.fileIndexer = new FileIndexer(this.context, this.root);
113
+ // Start file watcher
114
+ this.startWatcher();
115
+ // Start initial indexing
116
+ this.status = 'indexing';
117
+ this.performInitialIndex().catch(err => {
118
+ this.handleError('Failed during initial indexing', err);
119
+ });
120
+ }
121
+ catch (err) {
122
+ this.handleError('Failed to initialize workspace', err);
123
+ }
124
+ }
125
+ /**
126
+ * Re-initialize workspace after auth becomes available.
127
+ * Called when credentials are detected after initial startup failure.
128
+ */
129
+ async reinitialize() {
130
+ if (this.status !== 'error') {
131
+ logger.debug(`Workspace ${this.root} not in error state, skipping reinit`);
132
+ return;
133
+ }
134
+ logger.info(`Re-initializing workspace ${this.root} after auth`);
135
+ sendLogToClient('info', `Re-initializing workspace: ${this.root}`);
136
+ // Reset error state
137
+ this.error = null;
138
+ // Try to initialize again
139
+ await this.initialize();
140
+ }
141
+ /** Get path to state file */
142
+ getStatePath() {
143
+ return path.join(this.root, PERSISTENCE_CONFIG.STATE_DIR, PERSISTENCE_CONFIG.STATE_FILE);
144
+ }
145
+ /** Get path to metadata file */
146
+ getMetadataPath() {
147
+ return path.join(this.root, PERSISTENCE_CONFIG.STATE_DIR, PERSISTENCE_CONFIG.METADATA_FILE);
148
+ }
149
+ /** Ensure state directory exists */
150
+ ensureStateDir() {
151
+ const stateDir = path.join(this.root, PERSISTENCE_CONFIG.STATE_DIR);
152
+ if (!fs.existsSync(stateDir)) {
153
+ fs.mkdirSync(stateDir, { recursive: true });
154
+ }
155
+ }
156
+ /** Save state to disk (if persistence enabled) */
157
+ async saveState() {
158
+ if (!this.context || !isPersistenceEnabled())
159
+ return;
160
+ try {
161
+ this.ensureStateDir();
162
+ await this.context.exportToFile(this.getStatePath());
163
+ logger.debug(`Saved state to ${this.getStatePath()}`);
164
+ }
165
+ catch (err) {
166
+ logger.warn('Failed to save state', err);
167
+ }
168
+ }
169
+ /** Save file metadata to disk (if persistence enabled) */
170
+ saveMetadata() {
171
+ if (!isPersistenceEnabled())
172
+ return;
173
+ try {
174
+ this.ensureStateDir();
175
+ const persisted = {
176
+ version: 1,
177
+ savedAt: new Date().toISOString(),
178
+ files: this.fileMetadata,
179
+ };
180
+ fs.writeFileSync(this.getMetadataPath(), JSON.stringify(persisted), 'utf-8');
181
+ logger.debug(`Saved metadata for ${Object.keys(this.fileMetadata).length} files`);
182
+ }
183
+ catch (err) {
184
+ logger.warn('Failed to save metadata', err);
185
+ }
186
+ }
187
+ /** Load file metadata from disk (returns null if not found or invalid) */
188
+ loadMetadata() {
189
+ if (!isPersistenceEnabled())
190
+ return null;
191
+ const metadataPath = this.getMetadataPath();
192
+ if (!fs.existsSync(metadataPath))
193
+ return null;
194
+ try {
195
+ const content = fs.readFileSync(metadataPath, 'utf-8');
196
+ const persisted = JSON.parse(content);
197
+ // Validate version
198
+ if (persisted.version !== 1) {
199
+ logger.warn(`Unknown metadata version ${persisted.version}, ignoring`);
200
+ return null;
201
+ }
202
+ logger.info(`Loaded metadata for ${Object.keys(persisted.files).length} files (saved at ${persisted.savedAt})`);
203
+ return persisted.files;
204
+ }
205
+ catch (err) {
206
+ const msg = err instanceof Error ? err.message : String(err);
207
+ logger.warn(`Failed to load metadata (${msg}), will rebuild`);
208
+ return null;
209
+ }
210
+ }
211
+ /** Start file watcher */
212
+ startWatcher() {
213
+ this.watcher = chokidar.watch(this.root, {
214
+ ignored: (filePath) => {
215
+ const relativePath = path.relative(this.root, filePath);
216
+ if (!relativePath)
217
+ return false;
218
+ return shouldIgnore(this.ignoreFilter, relativePath);
219
+ },
220
+ persistent: true,
221
+ ignoreInitial: true,
222
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
223
+ });
224
+ this.watcher
225
+ .on('add', (filePath) => this.queueChange({ type: 'add', path: filePath }))
226
+ .on('change', (filePath) => this.queueChange({ type: 'change', path: filePath }))
227
+ .on('unlink', (filePath) => this.queueChange({ type: 'unlink', path: filePath }))
228
+ .on('error', (err) => logger.error('Watcher error', err));
229
+ logger.debug(`Started watcher for ${this.root}`);
230
+ }
231
+ /** Queue a file change for debounced processing */
232
+ queueChange(change) {
233
+ if (!this.initialScanComplete)
234
+ return;
235
+ this.changeQueue.push(change);
236
+ this.scheduleFlush();
237
+ }
238
+ /** Schedule a debounced flush of the change queue */
239
+ scheduleFlush() {
240
+ if (this.debounceTimer) {
241
+ clearTimeout(this.debounceTimer);
242
+ }
243
+ this.debounceTimer = setTimeout(() => {
244
+ this.flushChangeQueue().catch(err => {
245
+ const msg = err instanceof Error ? err.message : String(err);
246
+ logger.error(`Change queue flush failed: ${msg}`);
247
+ });
248
+ }, WATCHER_CONFIG.DEBOUNCE_MS);
249
+ }
250
+ /** Flush the change queue and process changes using FileIndexer */
251
+ async flushChangeQueue() {
252
+ if (this.changeQueue.length === 0 || !this.fileIndexer)
253
+ return;
254
+ const startTime = Date.now();
255
+ const changes = [...this.changeQueue];
256
+ this.changeQueue = [];
257
+ const toAdd = [];
258
+ const toRemove = [];
259
+ for (const change of changes) {
260
+ const relativePath = path.relative(this.root, change.path);
261
+ if (change.type === 'unlink') {
262
+ toRemove.push(relativePath);
263
+ }
264
+ else {
265
+ toAdd.push(change.path);
266
+ }
267
+ }
268
+ try {
269
+ // Remove deleted files using FileIndexer
270
+ if (toRemove.length > 0) {
271
+ await this.fileIndexer.removeFiles(toRemove);
272
+ // Remove metadata for deleted files
273
+ for (const removedPath of toRemove) {
274
+ delete this.fileMetadata[removedPath];
275
+ }
276
+ }
277
+ // Index new/changed files using FileIndexer
278
+ if (toAdd.length > 0) {
279
+ const result = await this.fileIndexer.indexFiles(toAdd);
280
+ // Update metadata from FileIndexer result
281
+ Object.assign(this.fileMetadata, result.metadata);
282
+ }
283
+ await this.saveState();
284
+ this.saveMetadata();
285
+ this.lastIndexed = new Date();
286
+ // Log and track delta timing
287
+ const elapsedMs = Date.now() - startTime;
288
+ this.lastDeltaSeconds = Math.round(elapsedMs / 100) / 10;
289
+ const totalChanges = toAdd.length + toRemove.length;
290
+ const msg = `Delta update: ${totalChanges} files (${toAdd.length} added, ${toRemove.length} removed) in ${elapsedMs}ms`;
291
+ logger.info(msg);
292
+ sendLogToClient('info', msg);
293
+ }
294
+ catch (err) {
295
+ const msg = err instanceof Error ? err.message : String(err);
296
+ logger.error(`Error processing file changes: ${msg}`);
297
+ }
298
+ }
299
+ /**
300
+ * Reconcile drift between persisted index and current filesystem state.
301
+ * Detects files deleted or modified while MCP was offline.
302
+ * Only runs when persistence is enabled and we restored from persisted state.
303
+ * @returns Set of paths that are still valid and unchanged (not deleted or modified)
304
+ */
305
+ async reconcileDrift(indexedPaths) {
306
+ const validPaths = new Set();
307
+ // Skip if no persistence or fresh start
308
+ if (!isPersistenceEnabled() || !this.restoredFromPersistence || indexedPaths.length === 0) {
309
+ logger.debug('Skipping drift reconciliation (no persistence or fresh start)');
310
+ this.driftReconciled = false;
311
+ return new Set(indexedPaths);
312
+ }
313
+ const startTime = Date.now();
314
+ const hasMetadata = this.restoredMetadata !== null;
315
+ logger.info(`Reconciling drift for ${indexedPaths.length} indexed files (mtime check: ${hasMetadata})...`);
316
+ sendLogToClient('info', `Checking ${indexedPaths.length} indexed files for drift...`);
317
+ const toRemove = [];
318
+ const toReindex = [];
319
+ // Check each indexed path
320
+ for (const relativePath of indexedPaths) {
321
+ const absolutePath = path.join(this.root, relativePath);
322
+ try {
323
+ const stat = fs.statSync(absolutePath);
324
+ // File exists - check if it's now ignored
325
+ if (shouldIgnore(this.ignoreFilter, relativePath)) {
326
+ // File now matches ignore pattern - remove from index
327
+ toRemove.push(relativePath);
328
+ this.driftDeletedFiles++;
329
+ logger.debug(`File now ignored, removing: ${relativePath}`);
330
+ continue;
331
+ }
332
+ // Check for modification using mtime (if we have stored metadata)
333
+ if (hasMetadata && this.restoredMetadata) {
334
+ const storedMeta = this.restoredMetadata[relativePath];
335
+ if (storedMeta) {
336
+ // Compare mtime (allow 1ms tolerance for filesystem precision)
337
+ const mtimeDiff = Math.abs(stat.mtimeMs - storedMeta.mtimeMs);
338
+ const sizeChanged = stat.size !== storedMeta.size;
339
+ if (mtimeDiff > 1 || sizeChanged) {
340
+ // File was modified - queue for re-indexing (by NOT adding to validPaths)
341
+ toReindex.push(relativePath);
342
+ this.driftModifiedFiles++;
343
+ logger.debug(`File modified (mtime diff: ${mtimeDiff}ms, size: ${storedMeta.size}->${stat.size}): ${relativePath}`);
344
+ continue;
345
+ }
346
+ }
347
+ else {
348
+ // No stored metadata for this file - treat as valid but log
349
+ logger.debug(`No stored metadata for indexed file: ${relativePath}`);
350
+ }
351
+ }
352
+ // File is valid and unchanged
353
+ validPaths.add(relativePath);
354
+ // Preserve existing metadata for unchanged files
355
+ if (hasMetadata && this.restoredMetadata?.[relativePath]) {
356
+ this.fileMetadata[relativePath] = this.restoredMetadata[relativePath];
357
+ }
358
+ }
359
+ catch {
360
+ // File doesn't exist - queue for removal
361
+ toRemove.push(relativePath);
362
+ this.driftDeletedFiles++;
363
+ logger.debug(`File deleted, removing from index: ${relativePath}`);
364
+ }
365
+ }
366
+ // Remove deleted/ignored files from index
367
+ if (toRemove.length > 0 && this.fileIndexer) {
368
+ try {
369
+ await this.fileIndexer.removeFiles(toRemove);
370
+ logger.info(`Removed ${toRemove.length} deleted/ignored files from index`);
371
+ sendLogToClient('info', `Removed ${toRemove.length} stale files from index`);
372
+ // Also remove from stored metadata
373
+ for (const removedPath of toRemove) {
374
+ delete this.fileMetadata[removedPath];
375
+ }
376
+ }
377
+ catch (err) {
378
+ const msg = err instanceof Error ? err.message : String(err);
379
+ logger.error(`Failed to remove stale files: ${msg}`);
380
+ }
381
+ }
382
+ // Remove modified files from index so they can be re-indexed
383
+ if (toReindex.length > 0 && this.fileIndexer) {
384
+ try {
385
+ await this.fileIndexer.removeFiles(toReindex);
386
+ logger.info(`Removed ${toReindex.length} modified files for re-indexing`);
387
+ sendLogToClient('info', `Will re-index ${toReindex.length} modified files`);
388
+ // Clear metadata for files that will be re-indexed
389
+ for (const modifiedPath of toReindex) {
390
+ delete this.fileMetadata[modifiedPath];
391
+ }
392
+ }
393
+ catch (err) {
394
+ const msg = err instanceof Error ? err.message : String(err);
395
+ logger.error(`Failed to remove modified files: ${msg}`);
396
+ }
397
+ }
398
+ this.driftReconciled = true;
399
+ this.driftReconcileTimeMs = Date.now() - startTime;
400
+ if (toRemove.length > 0 || toReindex.length > 0) {
401
+ const msg = `Drift reconciliation: ${toRemove.length} deleted, ${toReindex.length} modified (${this.driftReconcileTimeMs}ms)`;
402
+ logger.info(msg);
403
+ sendLogToClient('info', msg);
404
+ }
405
+ else {
406
+ logger.info(`No drift detected (${this.driftReconcileTimeMs}ms)`);
407
+ }
408
+ // Clear restored metadata - we've processed it
409
+ this.restoredMetadata = null;
410
+ return validPaths;
411
+ }
412
+ /** Perform initial indexing using FileIndexer */
413
+ async performInitialIndex() {
414
+ if (!this.fileIndexer || !this.context)
415
+ return;
416
+ const startTime = Date.now();
417
+ const isColdStart = (this.context.getIndexedPaths().length) === 0;
418
+ try {
419
+ const indexedPaths = this.context.getIndexedPaths();
420
+ // Reconcile drift before processing (removes deleted files, respects -n flag)
421
+ const indexedSet = await this.reconcileDrift(indexedPaths);
422
+ // Initialize progress
423
+ this.progress = {
424
+ discovered: 0,
425
+ indexed: 0,
426
+ discovering: true,
427
+ batchesQueued: 0,
428
+ batchesCompleted: 0,
429
+ batchesFailed: 0,
430
+ batchesRetried: 0,
431
+ };
432
+ this.indexingStartTime = new Date();
433
+ // Reset FileIndexer stats for fresh indexing
434
+ this.fileIndexer.resetStats();
435
+ // Discover files to index
436
+ const filesToIndex = [];
437
+ for (const absolutePath of this.discoverFiles(indexedSet)) {
438
+ filesToIndex.push(absolutePath);
439
+ this.progress.discovered++;
440
+ }
441
+ this.progress.discovering = false;
442
+ // Hot start - nothing to index
443
+ if (filesToIndex.length === 0) {
444
+ const elapsedMs = Date.now() - startTime;
445
+ const totalFiles = this.context.getIndexedPaths().length;
446
+ const msg = `Hot start: ${totalFiles} files already indexed, scan took ${elapsedMs}ms - ${this.root}`;
447
+ logger.info(msg);
448
+ sendLogToClient('info', msg);
449
+ this.status = 'ready';
450
+ this.initialScanComplete = true;
451
+ this.progress = null;
452
+ return;
453
+ }
454
+ const discoveredMsg = `Discovered ${filesToIndex.length} files in ${this.root}...`;
455
+ logger.info(discoveredMsg);
456
+ sendLogToClient('info', discoveredMsg);
457
+ // Index files using FileIndexer (handles batching, retries, cooldown)
458
+ const result = await this.fileIndexer.indexFiles(filesToIndex, (indexed, total, message) => {
459
+ if (this.progress) {
460
+ this.progress.indexed = indexed;
461
+ const pct = total > 0 ? Math.round((indexed / total) * 100) : 0;
462
+ const msg = `Indexed: ${indexed}/${total} (${pct}%) - ${message}`;
463
+ logger.info(msg);
464
+ sendLogToClient('info', msg);
465
+ }
466
+ });
467
+ // Update metadata from FileIndexer result
468
+ Object.assign(this.fileMetadata, result.metadata);
469
+ // Wait for backend to finish indexing
470
+ await retryWithBackoff(() => this.context.waitForIndexing(), 'waitForIndexing');
471
+ await this.saveState();
472
+ this.saveMetadata();
473
+ this.lastIndexed = new Date();
474
+ this.status = 'ready';
475
+ this.initialScanComplete = true;
476
+ // Calculate timing
477
+ const elapsedMs = Date.now() - startTime;
478
+ const elapsedSeconds = Math.round(elapsedMs / 1000 * 10) / 10;
479
+ const totalIndexed = this.context.getIndexedPaths().length;
480
+ const indexType = isColdStart ? 'Cold start' : 'Hot re-index';
481
+ const stats = this.fileIndexer.getStats();
482
+ const metrics = this.fileIndexer.getBatcherMetrics();
483
+ const filesPerSec = elapsedMs > 0 ? Math.round(totalIndexed / (elapsedMs / 1000)) : 0;
484
+ // Store timing for status reporting
485
+ if (isColdStart) {
486
+ this.initialIndexSeconds = elapsedSeconds;
487
+ }
488
+ else {
489
+ this.lastDeltaSeconds = elapsedSeconds;
490
+ }
491
+ // Build completion message
492
+ let completeMsg = `${indexType} complete: ${totalIndexed} files in ${elapsedSeconds}s (${filesPerSec} files/sec, peak concurrency: ${metrics.peakConcurrency})`;
493
+ if (stats.recovered > 0) {
494
+ completeMsg += `, ${stats.recovered} files recovered via retry`;
495
+ }
496
+ if (stats.failed > 0) {
497
+ completeMsg += `, ${stats.failed} files permanently failed`;
498
+ }
499
+ if (stats.skipped > 0) {
500
+ completeMsg += `, ${stats.skipped} files skipped`;
501
+ }
502
+ logger.info(completeMsg);
503
+ sendLogToClient('info', completeMsg);
504
+ this.progress = null;
505
+ this.indexingStartTime = null;
506
+ }
507
+ catch (err) {
508
+ this.handleError('Initial indexing failed', err);
509
+ }
510
+ }
511
+ /**
512
+ * Generator that discovers files for indexing.
513
+ * Yields absolute paths of files that need indexing.
514
+ */
515
+ *discoverFiles(indexedSet) {
516
+ const walk = function* (dir, root, ignoreFilter) {
517
+ try {
518
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
519
+ for (const entry of entries) {
520
+ const absolutePath = path.join(dir, entry.name);
521
+ const relativePath = path.relative(root, absolutePath);
522
+ if (shouldIgnore(ignoreFilter, relativePath))
523
+ continue;
524
+ if (entry.isDirectory()) {
525
+ yield* walk(absolutePath, root, ignoreFilter);
526
+ }
527
+ else if (entry.isFile()) {
528
+ yield absolutePath;
529
+ }
530
+ }
531
+ }
532
+ catch {
533
+ // Ignore permission errors
534
+ }
535
+ };
536
+ for (const absolutePath of walk(this.root, this.root, this.ignoreFilter)) {
537
+ const relativePath = path.relative(this.root, absolutePath);
538
+ // Skip already indexed files
539
+ if (indexedSet.has(relativePath))
540
+ continue;
541
+ yield absolutePath;
542
+ }
543
+ }
544
+ /** Get drift reconciliation statistics */
545
+ getDriftStats() {
546
+ return {
547
+ deletedFiles: this.driftDeletedFiles,
548
+ modifiedFiles: this.driftModifiedFiles,
549
+ reconciled: this.driftReconciled,
550
+ reconcileTimeMs: this.driftReconcileTimeMs,
551
+ };
552
+ }
553
+ /** Handle errors */
554
+ handleError(message, err) {
555
+ const errorMessage = err instanceof Error ? err.message : String(err);
556
+ logger.error(`${message}: ${errorMessage}`);
557
+ this.status = 'error';
558
+ this.error = errorMessage;
559
+ }
560
+ /** Get workspace status */
561
+ getStatus() {
562
+ // Pending = discovered but not yet indexed + change queue
563
+ const progressPending = this.progress
564
+ ? this.progress.discovered - this.progress.indexed
565
+ : 0;
566
+ // Get stats from FileIndexer
567
+ const stats = this.fileIndexer?.getStats() ?? {
568
+ indexed: 0,
569
+ skipped: 0,
570
+ failed: 0,
571
+ recovered: 0,
572
+ pendingRetry: 0,
573
+ pendingCooldown: 0,
574
+ cooldownCycle: 0,
575
+ cooldownNextRetryAt: null,
576
+ };
577
+ const metrics = this.fileIndexer?.getBatcherMetrics() ?? {
578
+ currentConcurrency: 0,
579
+ peakConcurrency: 0,
580
+ concurrencyBounds: { min: 0, max: 0 },
581
+ batchLimits: { maxFiles: 0, maxBytes: 0 },
582
+ };
583
+ // Add concurrency to progress if available
584
+ const progressWithConcurrency = this.progress
585
+ ? { ...this.progress, concurrency: metrics.currentConcurrency }
586
+ : undefined;
587
+ // Calculate live elapsed time during indexing
588
+ const elapsedSeconds = this.indexingStartTime && this.status === 'indexing'
589
+ ? Math.round((Date.now() - this.indexingStartTime.getTime()) / 100) / 10
590
+ : null;
591
+ const driftStats = this.getDriftStats();
592
+ // Build retry stats from FileIndexer
593
+ const retryStats = {
594
+ pendingRetries: 0,
595
+ pendingRetryFiles: stats.pendingRetry,
596
+ retriedBatches: 0,
597
+ retriedFiles: stats.recovered,
598
+ permanentlyFailedBatches: 0,
599
+ permanentlyFailedFiles: stats.failed,
600
+ cooldown: {
601
+ pendingFiles: stats.pendingCooldown,
602
+ currentCycle: stats.cooldownCycle,
603
+ maxCycles: INDEXING_CONFIG.MAX_COOLDOWN_CYCLES,
604
+ nextRetryAt: stats.cooldownNextRetryAt,
605
+ recoveredFiles: 0,
606
+ },
607
+ };
608
+ // Calculate pending (change queue + retry queue + cooldown)
609
+ const pending = this.changeQueue.length + progressPending + stats.pendingRetry + stats.pendingCooldown;
610
+ // Calculate total = indexed + skipped + failed + pending
611
+ const indexed = stats.indexed;
612
+ const total = indexed + stats.skipped + stats.failed + pending;
613
+ return {
614
+ root: this.root,
615
+ status: this.status,
616
+ total,
617
+ skipped: stats.skipped,
618
+ failed: stats.failed,
619
+ pending,
620
+ indexed,
621
+ watching: this.watcher !== null,
622
+ lastIndexed: this.lastIndexed?.toISOString() ?? null,
623
+ error: this.error ?? undefined,
624
+ progress: progressWithConcurrency,
625
+ timing: {
626
+ startedAt: this.indexingStartTime?.toISOString() ?? null,
627
+ elapsedSeconds,
628
+ initialIndexSeconds: this.initialIndexSeconds,
629
+ lastDeltaSeconds: this.lastDeltaSeconds,
630
+ },
631
+ retry: retryStats,
632
+ concurrency: {
633
+ current: metrics.currentConcurrency,
634
+ peak: metrics.peakConcurrency,
635
+ bounds: metrics.concurrencyBounds,
636
+ batchLimits: metrics.batchLimits,
637
+ },
638
+ drift: driftStats.reconciled ? driftStats : undefined,
639
+ };
640
+ }
641
+ /** Get indexed paths */
642
+ getIndexedPaths() {
643
+ return this.context?.getIndexedPaths() ?? [];
644
+ }
645
+ /** Search the codebase */
646
+ async search(query) {
647
+ if (!this.context) {
648
+ throw new Error('Workspace not initialized');
649
+ }
650
+ // Trigger retry of failed files if conditions are met (fire and forget)
651
+ this.triggerRetryIfNeeded();
652
+ return this.context.search(query);
653
+ }
654
+ /** Search and ask */
655
+ async searchAndAsk(query, prompt) {
656
+ if (!this.context) {
657
+ throw new Error('Workspace not initialized');
658
+ }
659
+ // Trigger retry of failed files if conditions are met (fire and forget)
660
+ this.triggerRetryIfNeeded();
661
+ return this.context.searchAndAsk(query, prompt);
662
+ }
663
+ /**
664
+ * Trigger retry of failed files if conditions are met.
665
+ * This is a "hit and run" call - triggers async retry and returns immediately.
666
+ */
667
+ triggerRetryIfNeeded() {
668
+ this.fileIndexer?.triggerRetryIfNeeded();
669
+ }
670
+ /** Force reindex */
671
+ async reindex(full = false) {
672
+ if (!this.context) {
673
+ throw new Error('Workspace not initialized');
674
+ }
675
+ if (full) {
676
+ await this.context.clearIndex();
677
+ // Clear file metadata for full reindex
678
+ this.fileMetadata = {};
679
+ }
680
+ this.status = 'indexing';
681
+ await this.performInitialIndex();
682
+ }
683
+ /** Close the workspace */
684
+ async close() {
685
+ if (this.debounceTimer) {
686
+ clearTimeout(this.debounceTimer);
687
+ }
688
+ // Close FileIndexer (handles its own timers)
689
+ this.fileIndexer?.close();
690
+ if (this.watcher) {
691
+ await this.watcher.close();
692
+ }
693
+ await this.saveState();
694
+ this.saveMetadata();
695
+ logger.debug(`Closed workspace ${this.root}`);
696
+ }
697
+ }