mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-17 23:51:08 +00:00
* feat: add per-workspace indexing opt-in and stop/cancel control - Add codeIndexWorkspaceEnabled flag in workspaceState (default: false) - Thread AbortController/AbortSignal through orchestrator → scanner - Add Stop Indexing button and Stopping state to UI - Fix handleSettingsChange() to abort active scan when disabling toggle - Add translations for all 18 locales * fix: correct abort handling in indexing scanner and orchestrator - Re-throw AbortError in scanner's file processing catch block to prevent abort signals from being silently swallowed as file errors - Reorder stopWatcher() before setSystemState() in orchestrator abort catch path to ensure watcher cleanup before state transition - Update scanner test to assert AbortError propagation on mid-scan abort * fix: optimize workspace check ordering, translate new i18n keys, fix abort handling - Move workspace-enabled check before _recreateServices() in initialize() to avoid creating Qdrant/embedder connections for disabled workspaces - Translate new i18n keys (indexingStopped, indexingStoppedPartial, stopping, stopIndexingButton, stoppingButton, workspaceToggleLabel, workspaceDisabledMessage) in all 17 non-English locales - Re-throw AbortError in scanner catch block to prevent silent swallowing - Reorder stopWatcher() before setSystemState() in orchestrator abort path - Update scanner test to assert AbortError propagation on mid-scan abort - Fix recoverFromError test for workspace-enabled check ordering * fix: per-folder enablement key, abort-safe dispose and back-pressure, translate i18n Addresses 0xMink review feedback: - Store workspace enablement keyed by folder path to support multi-root workspaces (codeIndexWorkspaceEnabled:<path> instead of single boolean) - Add test proving folder A enabled does not enable folder B - dispose() now calls stopIndexing() to abort orphaned scans on folder removal - Scanner back-pressure loop checks abort signal to avoid spin-waiting - Move workspace-enabled check before _recreateServices() in initialize() - Translate new i18n keys in all 17 non-English locales - Fix abort handling in orchestrator and scanner catch blocks * fix: flush debounced cache writes on abort to preserve indexing progress * feat: add global auto-enable default for backward-compatible workspace indexing * fix: stop/start indexer when auto-enable default changes effective state * fix: URI-keyed enablement, throw AbortError in back-pressure, stopWatcher on early-return * fix: iterate all managers when auto-enable default changes in multi-root workspaces --------- Co-authored-by: James Mtendamema <jmtendamema@geologicai.com>
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
import * as vscode from "vscode"
|
|
import * as path from "path"
|
|
import { CodeIndexConfigManager } from "./config-manager"
|
|
import { CodeIndexStateManager, IndexingState } from "./state-manager"
|
|
import { IFileWatcher, IVectorStore, BatchProcessingSummary } from "./interfaces"
|
|
import { DirectoryScanner } from "./processors"
|
|
import { CacheManager } from "./cache-manager"
|
|
import { TelemetryService } from "@roo-code/telemetry"
|
|
import { TelemetryEventName } from "@roo-code/types"
|
|
import { t } from "../../i18n"
|
|
|
|
/**
|
|
* Manages the code indexing workflow, coordinating between different services and managers.
|
|
*/
|
|
export class CodeIndexOrchestrator {
|
|
private _fileWatcherSubscriptions: vscode.Disposable[] = []
|
|
private _isProcessing: boolean = false
|
|
private _abortController: AbortController | null = null
|
|
|
|
constructor(
|
|
private readonly configManager: CodeIndexConfigManager,
|
|
private readonly stateManager: CodeIndexStateManager,
|
|
private readonly workspacePath: string,
|
|
private readonly cacheManager: CacheManager,
|
|
private readonly vectorStore: IVectorStore,
|
|
private readonly scanner: DirectoryScanner,
|
|
private readonly fileWatcher: IFileWatcher,
|
|
) {}
|
|
|
|
/**
|
|
* Starts the file watcher if not already running.
|
|
*/
|
|
private async _startWatcher(): Promise<void> {
|
|
if (!this.configManager.isFeatureConfigured) {
|
|
throw new Error("Cannot start watcher: Service not configured.")
|
|
}
|
|
|
|
this.stateManager.setSystemState("Indexing", "Initializing file watcher...")
|
|
|
|
try {
|
|
await this.fileWatcher.initialize()
|
|
|
|
this._fileWatcherSubscriptions = [
|
|
this.fileWatcher.onDidStartBatchProcessing((filePaths: string[]) => {}),
|
|
this.fileWatcher.onBatchProgressUpdate(({ processedInBatch, totalInBatch, currentFile }) => {
|
|
if (totalInBatch > 0 && this.stateManager.state !== "Indexing") {
|
|
this.stateManager.setSystemState("Indexing", "Processing file changes...")
|
|
}
|
|
this.stateManager.reportFileQueueProgress(
|
|
processedInBatch,
|
|
totalInBatch,
|
|
currentFile ? path.basename(currentFile) : undefined,
|
|
)
|
|
if (processedInBatch === totalInBatch) {
|
|
// Covers (N/N) and (0/0)
|
|
if (totalInBatch > 0) {
|
|
// Batch with items completed
|
|
this.stateManager.setSystemState("Indexed", "File changes processed. Index up-to-date.")
|
|
} else {
|
|
if (this.stateManager.state === "Indexing") {
|
|
// Only transition if it was "Indexing"
|
|
this.stateManager.setSystemState("Indexed", "Index up-to-date. File queue empty.")
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
this.fileWatcher.onDidFinishBatchProcessing((summary: BatchProcessingSummary) => {
|
|
if (summary.batchError) {
|
|
console.error(`[CodeIndexOrchestrator] Batch processing failed:`, summary.batchError)
|
|
} else {
|
|
const successCount = summary.processedFiles.filter(
|
|
(f: { status: string }) => f.status === "success",
|
|
).length
|
|
const errorCount = summary.processedFiles.filter(
|
|
(f: { status: string }) => f.status === "error" || f.status === "local_error",
|
|
).length
|
|
}
|
|
}),
|
|
]
|
|
} catch (error) {
|
|
console.error("[CodeIndexOrchestrator] Failed to start file watcher:", error)
|
|
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
location: "_startWatcher",
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the status of a file in the state manager.
|
|
*/
|
|
|
|
/**
|
|
* Initiates the indexing process (initial scan and starts watcher).
|
|
*/
|
|
public async startIndexing(): Promise<void> {
|
|
// Check if workspace is available first
|
|
if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) {
|
|
this.stateManager.setSystemState("Error", t("embeddings:orchestrator.indexingRequiresWorkspace"))
|
|
console.warn("[CodeIndexOrchestrator] Start rejected: No workspace folder open.")
|
|
return
|
|
}
|
|
|
|
if (!this.configManager.isFeatureConfigured) {
|
|
this.stateManager.setSystemState("Standby", "Missing configuration. Save your settings to start indexing.")
|
|
console.warn("[CodeIndexOrchestrator] Start rejected: Missing configuration.")
|
|
return
|
|
}
|
|
|
|
if (
|
|
this._isProcessing ||
|
|
(this.stateManager.state !== "Standby" &&
|
|
this.stateManager.state !== "Error" &&
|
|
this.stateManager.state !== "Indexed")
|
|
) {
|
|
console.warn(
|
|
`[CodeIndexOrchestrator] Start rejected: Already processing or in state ${this.stateManager.state}.`,
|
|
)
|
|
return
|
|
}
|
|
|
|
this._isProcessing = true
|
|
this._abortController = new AbortController()
|
|
const signal = this._abortController.signal
|
|
this.stateManager.setSystemState("Indexing", "Initializing services...")
|
|
|
|
// Track whether we successfully connected to Qdrant and started indexing
|
|
// This helps us decide whether to preserve cache on error
|
|
let indexingStarted = false
|
|
|
|
try {
|
|
const collectionCreated = await this.vectorStore.initialize()
|
|
|
|
// Successfully connected to Qdrant
|
|
indexingStarted = true
|
|
|
|
if (collectionCreated) {
|
|
await this.cacheManager.clearCacheFile()
|
|
}
|
|
|
|
// Check if the collection already has indexed data
|
|
// If it does, we can skip the full scan and just start the watcher
|
|
const hasExistingData = await this.vectorStore.hasIndexedData()
|
|
|
|
if (hasExistingData && !collectionCreated) {
|
|
// Collection exists with data - run incremental scan to catch any new/changed files
|
|
// This handles files added while workspace was closed or Qdrant was inactive
|
|
console.log(
|
|
"[CodeIndexOrchestrator] Collection already has indexed data. Running incremental scan for new/changed files...",
|
|
)
|
|
this.stateManager.setSystemState("Indexing", "Checking for new or modified files...")
|
|
|
|
// Mark as incomplete at the start of incremental scan
|
|
await this.vectorStore.markIndexingIncomplete()
|
|
|
|
let cumulativeBlocksIndexed = 0
|
|
let cumulativeBlocksFoundSoFar = 0
|
|
let batchErrors: Error[] = []
|
|
|
|
const handleFileParsed = (fileBlockCount: number) => {
|
|
cumulativeBlocksFoundSoFar += fileBlockCount
|
|
this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar)
|
|
}
|
|
|
|
const handleBlocksIndexed = (indexedCount: number) => {
|
|
cumulativeBlocksIndexed += indexedCount
|
|
this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar)
|
|
}
|
|
|
|
// Run incremental scan - scanner will skip unchanged files using cache
|
|
const result = await this.scanner.scanDirectory(
|
|
this.workspacePath,
|
|
(batchError: Error) => {
|
|
console.error(
|
|
`[CodeIndexOrchestrator] Error during incremental scan batch: ${batchError.message}`,
|
|
batchError,
|
|
)
|
|
batchErrors.push(batchError)
|
|
},
|
|
handleBlocksIndexed,
|
|
handleFileParsed,
|
|
signal,
|
|
)
|
|
|
|
if (signal.aborted) {
|
|
await this.cacheManager.flush()
|
|
this.stopWatcher()
|
|
this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped"))
|
|
return
|
|
}
|
|
|
|
if (!result) {
|
|
throw new Error("Incremental scan failed, is scanner initialized?")
|
|
}
|
|
|
|
// If new files were found and indexed, log the results
|
|
if (cumulativeBlocksFoundSoFar > 0) {
|
|
console.log(
|
|
`[CodeIndexOrchestrator] Incremental scan completed: ${cumulativeBlocksIndexed} blocks indexed from new/changed files`,
|
|
)
|
|
} else {
|
|
console.log("[CodeIndexOrchestrator] No new or changed files found")
|
|
}
|
|
|
|
await this._startWatcher()
|
|
|
|
// Mark indexing as complete after successful incremental scan
|
|
await this.vectorStore.markIndexingComplete()
|
|
|
|
this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted"))
|
|
} else {
|
|
// No existing data or collection was just created - do a full scan
|
|
this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...")
|
|
|
|
// Mark as incomplete at the start of full scan
|
|
await this.vectorStore.markIndexingIncomplete()
|
|
|
|
let cumulativeBlocksIndexed = 0
|
|
let cumulativeBlocksFoundSoFar = 0
|
|
let batchErrors: Error[] = []
|
|
|
|
const handleFileParsed = (fileBlockCount: number) => {
|
|
cumulativeBlocksFoundSoFar += fileBlockCount
|
|
this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar)
|
|
}
|
|
|
|
const handleBlocksIndexed = (indexedCount: number) => {
|
|
cumulativeBlocksIndexed += indexedCount
|
|
this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar)
|
|
}
|
|
|
|
const result = await this.scanner.scanDirectory(
|
|
this.workspacePath,
|
|
(batchError: Error) => {
|
|
console.error(
|
|
`[CodeIndexOrchestrator] Error during initial scan batch: ${batchError.message}`,
|
|
batchError,
|
|
)
|
|
batchErrors.push(batchError)
|
|
},
|
|
handleBlocksIndexed,
|
|
handleFileParsed,
|
|
signal,
|
|
)
|
|
|
|
if (signal.aborted) {
|
|
await this.cacheManager.flush()
|
|
this.stopWatcher()
|
|
this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped"))
|
|
return
|
|
}
|
|
|
|
if (!result) {
|
|
throw new Error("Scan failed, is scanner initialized?")
|
|
}
|
|
|
|
const { stats } = result
|
|
|
|
// Check if any blocks were actually indexed successfully
|
|
// If no blocks were indexed but blocks were found, it means all batches failed
|
|
if (cumulativeBlocksIndexed === 0 && cumulativeBlocksFoundSoFar > 0) {
|
|
if (batchErrors.length > 0) {
|
|
// Use the first batch error as it's likely representative of the main issue
|
|
const firstError = batchErrors[0]
|
|
throw new Error(`Indexing failed: ${firstError.message}`)
|
|
} else {
|
|
throw new Error(t("embeddings:orchestrator.indexingFailedNoBlocks"))
|
|
}
|
|
}
|
|
|
|
// Check for partial failures - if a significant portion of blocks failed
|
|
const failureRate = (cumulativeBlocksFoundSoFar - cumulativeBlocksIndexed) / cumulativeBlocksFoundSoFar
|
|
if (batchErrors.length > 0 && failureRate > 0.1) {
|
|
// More than 10% of blocks failed to index
|
|
const firstError = batchErrors[0]
|
|
throw new Error(
|
|
`Indexing partially failed: Only ${cumulativeBlocksIndexed} of ${cumulativeBlocksFoundSoFar} blocks were indexed. ${firstError.message}`,
|
|
)
|
|
}
|
|
|
|
// CRITICAL: If there were ANY batch errors and NO blocks were successfully indexed,
|
|
// this is a complete failure regardless of the failure rate calculation
|
|
if (batchErrors.length > 0 && cumulativeBlocksIndexed === 0) {
|
|
const firstError = batchErrors[0]
|
|
throw new Error(`Indexing failed completely: ${firstError.message}`)
|
|
}
|
|
|
|
// Final sanity check: If we found blocks but indexed none and somehow no errors were reported,
|
|
// this is still a failure
|
|
if (cumulativeBlocksFoundSoFar > 0 && cumulativeBlocksIndexed === 0) {
|
|
throw new Error(t("embeddings:orchestrator.indexingFailedCritical"))
|
|
}
|
|
|
|
await this._startWatcher()
|
|
|
|
// Mark indexing as complete after successful full scan
|
|
await this.vectorStore.markIndexingComplete()
|
|
|
|
this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted"))
|
|
}
|
|
} catch (error: any) {
|
|
// Handle abort gracefully — not an error, just a user-initiated stop
|
|
if (error?.name === "AbortError" || signal.aborted) {
|
|
console.log("[CodeIndexOrchestrator] Indexing aborted by user.")
|
|
await this.cacheManager.flush()
|
|
this.stopWatcher()
|
|
this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped"))
|
|
return
|
|
}
|
|
|
|
console.error("[CodeIndexOrchestrator] Error during indexing:", error)
|
|
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
location: "startIndexing",
|
|
})
|
|
if (indexingStarted) {
|
|
try {
|
|
await this.vectorStore.clearCollection()
|
|
} catch (cleanupError) {
|
|
console.error("[CodeIndexOrchestrator] Failed to clean up after error:", cleanupError)
|
|
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
|
|
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
|
stack: cleanupError instanceof Error ? cleanupError.stack : undefined,
|
|
location: "startIndexing.cleanup",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Only clear cache if indexing had started (Qdrant connection succeeded)
|
|
// If we never connected to Qdrant, preserve cache for incremental scan when it comes back
|
|
if (indexingStarted) {
|
|
// Indexing started but failed mid-way - clear cache to avoid cache-Qdrant mismatch
|
|
await this.cacheManager.clearCacheFile()
|
|
console.log(
|
|
"[CodeIndexOrchestrator] Indexing failed after starting. Clearing cache to avoid inconsistency.",
|
|
)
|
|
} else {
|
|
// Never connected to Qdrant - preserve cache for future incremental scan
|
|
console.log(
|
|
"[CodeIndexOrchestrator] Failed to connect to Qdrant. Preserving cache for future incremental scan.",
|
|
)
|
|
}
|
|
|
|
this.stateManager.setSystemState(
|
|
"Error",
|
|
t("embeddings:orchestrator.failedDuringInitialScan", {
|
|
errorMessage: error.message || t("embeddings:orchestrator.unknownError"),
|
|
}),
|
|
)
|
|
this.stopWatcher()
|
|
} finally {
|
|
this._isProcessing = false
|
|
this._abortController = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stops any in-progress indexing by aborting the scan and stopping the file watcher.
|
|
*/
|
|
public stopIndexing(): void {
|
|
if (this._abortController) {
|
|
this.stateManager.setSystemState("Stopping", t("embeddings:orchestrator.indexingStoppedPartial"))
|
|
this._abortController.abort()
|
|
this._abortController = null
|
|
}
|
|
this.stopWatcher()
|
|
}
|
|
|
|
/**
|
|
* Stops the file watcher and cleans up resources.
|
|
*/
|
|
public stopWatcher(): void {
|
|
this.fileWatcher.dispose()
|
|
this._fileWatcherSubscriptions.forEach((sub) => sub.dispose())
|
|
this._fileWatcherSubscriptions = []
|
|
|
|
if (this.stateManager.state !== "Error" && this.stateManager.state !== "Stopping") {
|
|
this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.fileWatcherStopped"))
|
|
}
|
|
this._isProcessing = false
|
|
}
|
|
|
|
/**
|
|
* Clears all index data by stopping the watcher, clearing the vector store,
|
|
* and resetting the cache file.
|
|
*/
|
|
public async clearIndexData(): Promise<void> {
|
|
this._isProcessing = true
|
|
|
|
try {
|
|
await this.stopWatcher()
|
|
|
|
try {
|
|
if (this.configManager.isFeatureConfigured) {
|
|
await this.vectorStore.deleteCollection()
|
|
} else {
|
|
console.warn("[CodeIndexOrchestrator] Service not configured, skipping vector collection clear.")
|
|
}
|
|
} catch (error: any) {
|
|
console.error("[CodeIndexOrchestrator] Failed to clear vector collection:", error)
|
|
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
location: "clearIndexData",
|
|
})
|
|
this.stateManager.setSystemState("Error", `Failed to clear vector collection: ${error.message}`)
|
|
}
|
|
|
|
await this.cacheManager.clearCacheFile()
|
|
|
|
if (this.stateManager.state !== "Error") {
|
|
this.stateManager.setSystemState("Standby", "Index data cleared successfully.")
|
|
}
|
|
} finally {
|
|
this._isProcessing = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the current state of the indexing system.
|
|
*/
|
|
public get state(): IndexingState {
|
|
return this.stateManager.state
|
|
}
|
|
}
|