fix: resolve indexing worker issues for PR #5356

- Fix extension hanging when no workspace is open by adding validation to WorkspaceTracker
- Fix Qdrant point ID format error by using UUID v5 instead of file paths
- Remove console.log statements that were causing extension to get stuck loading
- Add tests for UUID generation in indexing worker
- Add tests for no workspace scenario in WorkspaceTracker
- Add empty path validation across multiple files to prevent errors
This commit is contained in:
hannesrudolph 2025-07-03 15:59:54 -06:00
parent 55d3e4f168
commit f8658b2ba8
23 changed files with 439 additions and 172 deletions

View file

@ -143,6 +143,14 @@ export function copyWasms(srcDir: string, distDir: string): void {
console.log(`[copyWasms] Copied tree-sitter.wasm to ${distDir}`)
// Also copy tree-sitter.wasm to the workers directory.
fs.copyFileSync(
path.join(nodeModulesDir, "web-tree-sitter", "tree-sitter.wasm"),
path.join(workersDir, "tree-sitter.wasm"),
)
console.log(`[copyWasms] Copied tree-sitter.wasm to ${workersDir}`)
// Copy language-specific WASM files.
const languageWasmDir = path.join(nodeModulesDir, "tree-sitter-wasms", "out")
@ -158,6 +166,13 @@ export function copyWasms(srcDir: string, distDir: string): void {
})
console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`)
// Also copy language-specific WASM files to the workers directory.
wasmFiles.forEach((filename) => {
fs.copyFileSync(path.join(languageWasmDir, filename), path.join(workersDir, filename))
})
console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${workersDir}`)
}
export function copyLocales(srcDir: string, distDir: string): void {

View file

@ -24,7 +24,6 @@ export function getCheckpointService(cline: Task) {
}
if (cline.checkpointServiceInitializing) {
console.log("[Task#getCheckpointService] checkpoint service is still initializing")
return undefined
}
@ -137,7 +136,6 @@ async function getInitializedCheckpointService(
try {
await pWaitFor(
() => {
console.log("[Task#getCheckpointService] waiting for service to initialize")
return service.isInitialized
},
{ interval, timeout },
@ -167,7 +165,6 @@ export async function checkpointSave(cline: Task, force = false) {
// Start the checkpoint process in the background.
return service.saveCheckpoint(`Task: ${cline.taskId}, Time: ${Date.now()}`, { allowEmpty: force }).catch((err) => {
console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err)
cline.enableCheckpoints = false
})
}

View file

@ -250,7 +250,7 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
// Don't want to immediately access desktop since it would show
// permission popup.
details += "(Desktop files not shown automatically. Use list_files to explore if needed.)"
} else {
} else if (cline.cwd && cline.cwd.trim() !== "") {
const maxFiles = maxWorkspaceFiles ?? 200
const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles)
const { showRooIgnoredFiles = true } = state ?? {}
@ -264,6 +264,8 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
)
details += result
} else {
details += "(No workspace folder open - file listing not available)"
}
}

View file

@ -1008,8 +1008,6 @@ export class Task extends EventEmitter<ClineEvents> {
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
console.log(`[subtasks] task ${this.taskId}.${this.instanceId} resuming from history item`)
await this.initiateTaskLoop(newUserContent)
}
@ -1067,8 +1065,6 @@ export class Task extends EventEmitter<ClineEvents> {
}
public async abortTask(isAbandoned = false) {
console.log(`[subtasks] aborting task ${this.taskId}.${this.instanceId}`)
// Will stop any autonomously running promises.
if (isAbandoned) {
this.abandoned = true
@ -1383,8 +1379,6 @@ export class Task extends EventEmitter<ClineEvents> {
}
if (this.abort) {
console.log(`aborting stream, this.abandoned = ${this.abandoned}`)
if (!this.abandoned) {
// Only need to gracefully abort if this instance
// isn't abandoned (sometimes OpenRouter stream
@ -1716,7 +1710,9 @@ export class Task extends EventEmitter<ClineEvents> {
const contextWindow = modelInfo.contextWindow
const currentProfileId = state?.listApiConfigMeta.find((profile) => profile.name === state?.currentApiConfigName)?.id ?? "default";
const currentProfileId =
state?.listApiConfigMeta.find((profile) => profile.name === state?.currentApiConfigName)?.id ??
"default"
const truncateResult = await truncateConversationIfNeeded({
messages: this.apiConversationHistory,

View file

@ -179,7 +179,6 @@ export async function executeCommand(
completed = true
},
onShellExecutionStarted: (pid: number | undefined) => {
console.log(`[executeCommand] onShellExecutionStarted: ${pid}`)
const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
},

View file

@ -37,6 +37,17 @@ export async function listFilesTool(
// Calculate if the path is outside workspace
const absolutePath = relDirPath ? path.resolve(cline.cwd, relDirPath) : cline.cwd
// Check if cline.cwd is empty (no workspace open)
if (!cline.cwd || cline.cwd.trim() === "") {
cline.consecutiveMistakeCount++
cline.recordToolError("list_files")
pushToolResult(
await cline.sayAndCreateMissingParamError("list_files", "workspace", "No workspace folder is open"),
)
return
}
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const sharedMessageProps: ClineSayTool = {

View file

@ -124,7 +124,6 @@ export class ClineProvider
) {
super()
this.log("ClineProvider instantiated")
ClineProvider.activeInstances.add(this)
this.codeIndexManager = codeIndexManager
@ -163,8 +162,6 @@ export class ClineProvider
// The instance is pushed to the top of the stack (LIFO order).
// When the task is completed, the top instance is removed, reactivating the previous task.
async addClineToStack(cline: Task) {
console.log(`[subtasks] adding task ${cline.taskId}.${cline.instanceId} to stack`)
// Add this cline instance into the stack that represents the order of all the called tasks.
this.clineStack.push(cline)
@ -187,8 +184,6 @@ export class ClineProvider
let cline = this.clineStack.pop()
if (cline) {
console.log(`[subtasks] removing task ${cline.taskId}.${cline.instanceId} from stack`)
try {
// Abort the running task and set isAbandoned to true so
// all running promises will exit as well.
@ -227,7 +222,6 @@ export class ClineProvider
// and resume the previous task/cline instance (if it exists)
// this is used when a sub task is finished and the parent task needs to be resumed
async finishSubTask(lastMessage: string) {
console.log(`[subtasks] finishing subtask ${lastMessage}`)
// remove the last cline instance from the stack (this is the finished sub task)
await this.removeClineFromStack()
// resume the last cline instance in the stack (if it exists - this is the 'parent' calling task)
@ -255,13 +249,10 @@ export class ClineProvider
}
async dispose() {
this.log("Disposing ClineProvider...")
await this.removeClineFromStack()
this.log("Cleared task")
if (this.view && "dispose" in this.view) {
this.view.dispose()
this.log("Disposed webview")
}
this.clearWebviewResources()
@ -280,7 +271,6 @@ export class ClineProvider
this.mcpHub = undefined
this.marketplaceManager?.cleanup()
this.customModesManager?.dispose()
this.log("Disposed all disposables")
ClineProvider.activeInstances.delete(this)
McpServerManager.unregisterProvider(this)
@ -383,8 +373,6 @@ export class ClineProvider
}
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
this.log("Resolving webview view")
this.view = webviewView
// Set panel reference according to webview type
@ -485,10 +473,8 @@ export class ClineProvider
webviewView.onDidDispose(
async () => {
if (inTabMode) {
this.log("Disposing ClineProvider instance for tab view")
await this.dispose()
} else {
this.log("Clearing webview resources for sidebar view")
this.clearWebviewResources()
this.codeIndexStatusSubscription?.dispose()
this.codeIndexStatusSubscription = undefined
@ -509,8 +495,6 @@ export class ClineProvider
// If the extension is starting a new session, clear previous task state.
await this.removeClineFromStack()
this.log("Webview view resolved")
}
public async initClineWithSubTask(parent: Task, task?: string, images?: string[]) {
@ -565,10 +549,6 @@ export class ClineProvider
await this.addClineToStack(cline)
this.log(
`[subtasks] ${cline.parentTask ? "child" : "parent"} task ${cline.taskId}.${cline.instanceId} instantiated`,
)
return cline
}
@ -598,9 +578,6 @@ export class ClineProvider
})
await this.addClineToStack(cline)
this.log(
`[subtasks] ${cline.parentTask ? "child" : "parent"} task ${cline.taskId}.${cline.instanceId} instantiated`,
)
return cline
}
@ -618,11 +595,6 @@ export class ClineProvider
if (fs.existsSync(portFilePath)) {
localPort = fs.readFileSync(portFilePath, "utf8").trim()
console.log(`[ClineProvider:Vite] Using Vite server port from ${portFilePath}: ${localPort}`)
} else {
console.log(
`[ClineProvider:Vite] Port file not found at ${portFilePath}, using default port: ${localPort}`,
)
}
} catch (err) {
console.error("[ClineProvider:Vite] Failed to read Vite port file:", err)
@ -965,8 +937,6 @@ export class ClineProvider
return
}
console.log(`[subtasks] cancelling task ${cline.taskId}.${cline.instanceId}`)
const { historyItem } = await this.getTaskWithId(cline.taskId)
// Preserve parent and root task information for history item.
const rootTask = cline.rootTask
@ -1218,7 +1188,6 @@ export class ClineProvider
// delete the entire task directory including checkpoints and all content
try {
await fs.rm(taskDirPath, { recursive: true, force: true })
console.log(`[deleteTaskWithId${id}] removed task directory`)
} catch (error) {
console.error(
`[deleteTaskWithId${id}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`,

View file

@ -98,14 +98,17 @@ export async function activate(context: vscode.ExtensionContext) {
}
const contextProxy = await ContextProxy.getInstance(context)
const codeIndexManager = CodeIndexManager.getInstance(context)
try {
await codeIndexManager?.initialize(contextProxy)
} catch (error) {
outputChannel.appendLine(
`[CodeIndexManager] Error during background CodeIndexManager configuration/indexing: ${error.message || error}`,
)
if (codeIndexManager) {
try {
await codeIndexManager.initialize(contextProxy)
} catch (error) {
outputChannel.appendLine(
`[CodeIndexManager] Error during background CodeIndexManager configuration/indexing: ${error.message || error}`,
)
}
}
const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, codeIndexManager, mdmService)

View file

@ -26,7 +26,7 @@ class WorkspaceTracker {
async initializeFilePaths() {
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
if (!this.cwd) {
if (!this.cwd || this.cwd.trim() === "") {
return
}
const tempCwd = this.cwd
@ -39,25 +39,29 @@ class WorkspaceTracker {
}
private registerListeners() {
const watcher = vscode.workspace.createFileSystemWatcher("**")
this.prevWorkSpacePath = this.cwd
this.disposables.push(
watcher.onDidCreate(async (uri) => {
await this.addFilePath(uri.fsPath)
this.workspaceDidUpdate()
}),
)
// Renaming files triggers a delete and create event
this.disposables.push(
watcher.onDidDelete(async (uri) => {
if (await this.removeFilePath(uri.fsPath)) {
// Only create file watcher if we have a valid workspace
if (this.cwd && this.cwd.trim() !== "") {
const watcher = vscode.workspace.createFileSystemWatcher("**")
this.disposables.push(
watcher.onDidCreate(async (uri) => {
await this.addFilePath(uri.fsPath)
this.workspaceDidUpdate()
}
}),
)
}),
)
this.disposables.push(watcher)
// Renaming files triggers a delete and create event
this.disposables.push(
watcher.onDidDelete(async (uri) => {
if (await this.removeFilePath(uri.fsPath)) {
this.workspaceDidUpdate()
}
}),
)
this.disposables.push(watcher)
}
this.prevWorkSpacePath = this.cwd
// Listen for tab changes and call workspaceDidUpdate directly
this.disposables.push(
@ -114,7 +118,7 @@ class WorkspaceTracker {
clearTimeout(this.updateTimer)
}
this.updateTimer = setTimeout(() => {
if (!this.cwd) {
if (!this.cwd || this.cwd.trim() === "") {
return
}

View file

@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import * as vscode from "vscode"
import WorkspaceTracker from "../WorkspaceTracker"
import { ClineProvider } from "../../../core/webview/ClineProvider"
// Mock vscode module
vi.mock("vscode", () => ({
workspace: {
createFileSystemWatcher: vi.fn(),
fs: {
stat: vi.fn(),
},
},
window: {
tabGroups: {
all: [],
onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
},
},
FileType: {
Directory: 2,
},
Uri: {
file: vi.fn((path) => ({ fsPath: path })),
},
}))
// Mock dependencies
vi.mock("../../../services/glob/list-files", () => ({
listFiles: vi.fn().mockResolvedValue([[], false]),
}))
vi.mock("../../../utils/path", () => ({
toRelativePath: vi.fn((path) => path),
getWorkspacePath: vi.fn().mockReturnValue(""), // Empty workspace path
}))
describe("WorkspaceTracker with no workspace", () => {
let mockProvider: ClineProvider
let workspaceTracker: WorkspaceTracker
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockProvider = {
postMessageToWebview: vi.fn(),
} as any
})
afterEach(() => {
if (workspaceTracker) {
workspaceTracker.dispose()
}
vi.useRealTimers()
})
it("should not create file watcher when workspace is empty", () => {
// Create tracker with empty workspace
workspaceTracker = new WorkspaceTracker(mockProvider)
// Verify that createFileSystemWatcher was NOT called
expect(vscode.workspace.createFileSystemWatcher).not.toHaveBeenCalled()
})
it("should handle initialization without hanging", async () => {
// Create tracker
workspaceTracker = new WorkspaceTracker(mockProvider)
// Initialize file paths (should return early)
await workspaceTracker.initializeFilePaths()
// Verify no workspace update was sent
expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
})
it("should still register tab change listener even without workspace", () => {
// Create tracker
workspaceTracker = new WorkspaceTracker(mockProvider)
// Verify tab change listener was registered
expect(vscode.window.tabGroups.onDidChangeTabs).toHaveBeenCalled()
})
it("should handle workspaceDidUpdate gracefully when workspace is empty", () => {
// Create tracker
workspaceTracker = new WorkspaceTracker(mockProvider)
// Manually trigger workspaceDidUpdate (simulating internal call)
// This is a private method, so we need to trigger it indirectly
const tabChangeCallback = (vscode.window.tabGroups.onDidChangeTabs as any).mock.calls[0][0]
tabChangeCallback()
// Wait for debounce
vi.advanceTimersByTime(300)
// Verify no message was sent (because workspace is empty)
expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
})
})

View file

@ -323,7 +323,7 @@ describe("OpenAICompatibleEmbedder", () => {
await embedder.createEmbeddings(testTexts)
// Should warn about oversized text
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("exceeds maximum token limit"))
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("exceeds token limit"))
// Should only process normal texts (1 call for 2 normal texts batched together)
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1)
@ -398,7 +398,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(authError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings: Authentication failed. Please check your API key.",
"Authentication failed. Please check your API key.",
)
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1)
@ -413,7 +413,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(serverError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: HTTP 500 - Internal server error",
"Failed to create embeddings after 3 attempts. Status: 500. Error: Internal server error",
)
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1)
@ -431,7 +431,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(apiError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: API connection failed",
"Failed to create embeddings after 3 attempts. Error: API connection failed",
)
expect(console.error).toHaveBeenCalledWith(
@ -447,7 +447,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(batchError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Batch processing failed",
"Failed to create embeddings after 3 attempts. Error: Batch processing failed",
)
expect(console.error).toHaveBeenCalledWith(
@ -488,7 +488,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(authError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings: Authentication failed. Please check your API key.",
"Authentication failed. Please check your API key.",
)
})
@ -500,7 +500,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(httpError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: HTTP 400 - Bad request",
"Failed to create embeddings after 3 attempts. Status: 400. Error: Bad request",
)
})
@ -511,7 +511,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(networkError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Network timeout",
"Failed to create embeddings after 3 attempts. Error: Network timeout",
)
})
@ -522,7 +522,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(weirdError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Custom error object",
"Failed to create embeddings after 3 attempts. Error: Custom error object",
)
})
@ -533,7 +533,7 @@ describe("OpenAICompatibleEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(unknownError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Unknown error",
"Failed to create embeddings after 3 attempts. Error: Unknown error",
)
})
})

View file

@ -192,7 +192,7 @@ describe("OpenAiEmbedder", () => {
const result = await embedder.createEmbeddings(testTexts)
// Verify warning was logged
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(`exceeds maximum token limit`))
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("exceeds token limit"))
// Verify only normal texts were processed
expect(mockEmbeddingsCreate).toHaveBeenCalledWith({
@ -300,7 +300,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(authError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings: Authentication failed. Please check your OpenAI API key.",
"Authentication failed. Please check your API key.",
)
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1)
@ -315,7 +315,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(serverError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: HTTP 500 - Internal server error",
"Failed to create embeddings after 3 attempts. Status: 500. Error: Internal server error",
)
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1)
@ -333,7 +333,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(apiError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: API connection failed",
"Failed to create embeddings after 3 attempts. Error: API connection failed",
)
expect(console.error).toHaveBeenCalledWith(
@ -374,7 +374,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(authError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings: Authentication failed. Please check your OpenAI API key.",
"Authentication failed. Please check your API key.",
)
})
@ -386,7 +386,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(httpError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: HTTP 400 - Bad request",
"Failed to create embeddings after 3 attempts. Status: 400. Error: Bad request",
)
})
@ -397,7 +397,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(networkError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Network timeout",
"Failed to create embeddings after 3 attempts. Error: Network timeout",
)
})
@ -408,7 +408,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(weirdError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Custom error object",
"Failed to create embeddings after 3 attempts. Error: Custom error object",
)
})
@ -419,7 +419,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(unknownError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Unknown error",
"Failed to create embeddings after 3 attempts. Error: Unknown error",
)
})
@ -430,7 +430,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(stringError)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Something went wrong",
"Failed to create embeddings after 3 attempts. Error: Something went wrong",
)
})
@ -445,7 +445,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(errorWithFailingToString)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: Unknown error",
"Failed to create embeddings after 3 attempts. Error: Unknown error",
)
})
@ -459,7 +459,7 @@ describe("OpenAiEmbedder", () => {
mockEmbeddingsCreate.mockRejectedValue(errorWithResponseStatus)
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
"Failed to create embeddings after 3 attempts: HTTP 403 - Request failed",
"Failed to create embeddings after 3 attempts. Status: 403. Error: Request failed",
)
})
})

View file

@ -26,7 +26,7 @@ export class CodeIndexManager {
public static getInstance(context: vscode.ExtensionContext): CodeIndexManager | undefined {
const workspacePath = getWorkspacePath() // Assumes single workspace for now
if (!workspacePath) {
if (!workspacePath || workspacePath.trim() === "") {
return undefined
}
@ -216,7 +216,14 @@ export class CodeIndexManager {
// Create new worker
// Use the extension path from context to ensure we get the correct path in both dev and production
const workerPath = path.join(this.context.extensionPath, "dist/workers/indexing-worker.js")
this._worker = new Worker(workerPath)
try {
this._worker = new Worker(workerPath)
} catch (error) {
console.error("[CodeIndexManager] Failed to create worker:", error)
throw error
}
this._workerReady = false
// Set up message handling
@ -226,6 +233,7 @@ export class CodeIndexManager {
// Initialize the worker
const response = await this.sendWorkerCommand({ type: "initialize", config: workerConfig })
if (response.type === "initialized" && response.success) {
this._workerReady = true
} else {
@ -266,7 +274,24 @@ export class CodeIndexManager {
const message: WorkerMessage<WorkerCommand> = { id, payload: command }
return new Promise((resolve, reject) => {
this._pendingMessages.set(id, { resolve, reject })
// Add a timeout to detect if the worker is not responding
const timeout = setTimeout(() => {
this._pendingMessages.delete(id)
reject(new Error(`Worker timeout for command: ${command.type}`))
}, 30000)
// Store the promise handlers with timeout cleanup
this._pendingMessages.set(id, {
resolve: (value) => {
clearTimeout(timeout)
resolve(value)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
},
})
this._worker!.postMessage(message)
})
}

View file

@ -629,9 +629,7 @@ describe("QdrantVectorStore", () => {
vitest.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error
// The actual error message includes the URL and error details
await expect(vectorStore.initialize()).rejects.toThrow(
/Failed to connect to Qdrant vector database|vectorStore\.qdrantConnectionFailed/,
)
await expect(vectorStore.initialize()).rejects.toThrow(/Failed to connect to Qdrant at/)
expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1)
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1)
@ -692,9 +690,7 @@ describe("QdrantVectorStore", () => {
vitest.spyOn(console, "warn").mockImplementation(() => {})
// The actual error message includes the URL and error details
await expect(vectorStore.initialize()).rejects.toThrow(
/Failed to connect to Qdrant vector database|vectorStore\.qdrantConnectionFailed/,
)
await expect(vectorStore.initialize()).rejects.toThrow(/Failed to connect to Qdrant at/)
expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1)
expect(mockQdrantClientInstance.deleteCollection).toHaveBeenCalledTimes(1)

View file

@ -23,6 +23,11 @@ export class QdrantVectorStore implements IVectorStore {
* @param url Optional URL to the Qdrant server
*/
constructor(workspacePath: string, url: string, vectorSize: number, apiKey?: string) {
// Validate workspacePath is not empty
if (!workspacePath || workspacePath.trim() === "") {
throw new Error("Workspace path must not be empty")
}
// Parse the URL to determine the appropriate QdrantClient configuration
const parsedUrl = this.parseQdrantUrl(url)

View file

@ -12,6 +12,9 @@ export class RooIgnoreController {
private initialized = false
constructor(private workspacePath: string) {
if (!workspacePath || workspacePath.trim() === "") {
throw new Error("Workspace path cannot be empty")
}
this.rooIgnorePath = path.join(workspacePath, ".rooignore")
this.ignoreInstance = ignore()
}
@ -48,7 +51,7 @@ export class RooIgnoreController {
}
const relativePath = path.relative(this.workspacePath, filePath)
return this.ignoreInstance.ignores(relativePath)
return relativePath ? this.ignoreInstance.ignores(relativePath) : false
}
/**
@ -61,7 +64,7 @@ export class RooIgnoreController {
return paths.filter((filePath) => {
const relativePath = path.relative(this.workspacePath, filePath)
return !this.ignoreInstance.ignores(relativePath)
return relativePath ? !this.ignoreInstance.ignores(relativePath) : true
})
}

View file

@ -22,6 +22,11 @@ export class CacheManager implements ICacheManager {
private context: { globalStorageUri: { fsPath: string } },
private workspacePath: string,
) {
// Validate workspacePath is not empty
if (!workspacePath || workspacePath.trim() === "") {
throw new Error("Workspace path must not be empty")
}
const cacheFileName = `roo-index-cache-${createHash("sha256").update(workspacePath).digest("hex")}.json`
this.cachePath = path.join(context.globalStorageUri.fsPath, cacheFileName)

View file

@ -25,6 +25,9 @@ export class FileWatcher {
private workspacePath: string,
private options: FileWatcherOptions = {},
) {
if (!workspacePath || workspacePath.trim() === "") {
throw new Error("Workspace path cannot be empty")
}
// Initialize ignore instance with exclude patterns
this.ignoreInstance = ignore()
if (options.excludePatterns) {
@ -45,7 +48,7 @@ export class FileWatcher {
cwd: this.workspacePath,
ignored: (filePath: string) => {
const relativePath = path.relative(this.workspacePath, filePath)
return this.ignoreInstance.ignores(relativePath)
return relativePath ? this.ignoreInstance.ignores(relativePath) : false
},
persistent: true,
ignoreInitial: true,
@ -112,7 +115,7 @@ export class FileWatcher {
const relativePath = path.relative(this.workspacePath, filePath)
// Check if ignored
if (this.ignoreInstance.ignores(relativePath)) {
if (relativePath && this.ignoreInstance.ignores(relativePath)) {
return false
}

View file

@ -310,7 +310,11 @@ export class Scanner {
private workspacePath: string,
private excludePatterns: string[] = [],
private includePatterns: string[] = [],
) {}
) {
if (!workspacePath || workspacePath.trim() === "") {
throw new Error("Workspace path cannot be empty")
}
}
/**
* Scans the workspace for files to index
@ -336,7 +340,7 @@ export class Scanner {
// Double-check exclusion patterns using ignore
const relativePath = path.relative(this.workspacePath, file)
const ig = ignore().add(allExcludePatterns)
const isExcluded = ig.ignores(relativePath)
const isExcluded = relativePath ? ig.ignores(relativePath) : false
if (!isExcluded && !files.includes(file)) {
files.push(file)

View file

@ -16,6 +16,11 @@ import { DIRS_TO_IGNORE } from "./constants"
* @returns Tuple of [file paths array, whether the limit was reached]
*/
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
// Handle empty directory path
if (!dirPath || dirPath.trim() === "") {
return [[], false]
}
// Handle special directories
const specialResult = await handleSpecialDirectories(dirPath)

View file

@ -143,6 +143,11 @@ export async function regexSearchFiles(
filePattern?: string,
rooIgnoreController?: RooIgnoreController,
): Promise<string> {
// Check for empty paths
if (!cwd || cwd.trim() === "" || !directoryPath || directoryPath.trim() === "") {
return "No results found"
}
const vscodeAppRoot = vscode.env.appRoot
const rgPath = await getBinPath(vscodeAppRoot)

View file

@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { Worker } from "worker_threads"
import * as path from "path"
import { v5 as uuidv5 } from "uuid"
import { QDRANT_CODE_BLOCK_NAMESPACE } from "../../services/code-index/constants"
// Mock worker_threads
vi.mock("worker_threads", () => ({
Worker: vi.fn(),
parentPort: null,
}))
describe("IndexingWorker", () => {
let mockWorker: any
beforeEach(() => {
mockWorker = {
postMessage: vi.fn(),
on: vi.fn(),
terminate: vi.fn(),
}
;(Worker as any).mockImplementation(() => mockWorker)
})
afterEach(() => {
vi.clearAllMocks()
})
it("should generate valid UUID v5 point IDs for Qdrant", () => {
// Test data
const testFilePath = "/Users/test/project/src/index.ts"
const testStartLine = 10
const workspacePath = "/Users/test/project"
// Simulate what the worker does
const normalizedPath = path.resolve(workspacePath, testFilePath)
const stableName = `${normalizedPath}:${testStartLine}`
const pointId = uuidv5(stableName, QDRANT_CODE_BLOCK_NAMESPACE)
// Verify the point ID is a valid UUID
expect(pointId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
// Verify it's deterministic (same input produces same output)
const pointId2 = uuidv5(stableName, QDRANT_CODE_BLOCK_NAMESPACE)
expect(pointId2).toBe(pointId)
// Verify different inputs produce different IDs
const differentPath = `${normalizedPath}:${testStartLine + 1}`
const differentId = uuidv5(differentPath, QDRANT_CODE_BLOCK_NAMESPACE)
expect(differentId).not.toBe(pointId)
})
it("should handle relative paths correctly", () => {
const workspacePath = "/Users/test/project"
const relativePath = "src/components/Button.tsx"
const startLine = 25
// Simulate path normalization
const absolutePath = path.resolve(workspacePath, relativePath)
const normalizedPath = path.normalize(absolutePath)
const stableName = `${normalizedPath}:${startLine}`
const pointId = uuidv5(stableName, QDRANT_CODE_BLOCK_NAMESPACE)
// Verify it's a valid UUID
expect(pointId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
})
it("should generate consistent IDs across platforms", () => {
const workspacePath = "/Users/test/project"
const filePath = "src/utils/helper.js"
const startLine = 42
// Normalize path to handle platform differences
const absolutePath = path.resolve(workspacePath, filePath)
const normalizedPath = path.normalize(absolutePath)
const stableName = `${normalizedPath}:${startLine}`
const pointId = uuidv5(stableName, QDRANT_CODE_BLOCK_NAMESPACE)
// The ID should be deterministic regardless of platform
expect(pointId).toBeTruthy()
expect(typeof pointId).toBe("string")
expect(pointId.length).toBe(36) // Standard UUID length
})
})

View file

@ -2,13 +2,14 @@ import { parentPort } from "worker_threads"
import { WorkerCommand, WorkerResponse, WorkerMessage, WorkerInitConfig } from "../services/code-index/worker-messenger"
import { CodeIndexStateManager } from "../services/code-index/worker-utils/state-manager"
import { CacheManager } from "../services/code-index/worker-utils/cache-manager"
import { Scanner } from "../services/code-index/worker-utils/scanner"
import { Scanner as DirectoryScanner } from "../services/code-index/worker-utils/scanner"
import { FileWatcher } from "../services/code-index/worker-utils/file-watcher"
import { RooIgnoreController } from "../services/code-index/worker-utils/RooIgnoreController"
import { VectorStoreSearchResult } from "../services/code-index/interfaces"
import ignore from "ignore"
import * as fs from "fs/promises"
import * as path from "path"
import { v5 as uuidv5 } from "uuid"
// Import embedders and vector store directly
import { OpenAiEmbedder } from "../services/code-index/embedders/openai"
@ -17,12 +18,17 @@ import { OpenAICompatibleEmbedder } from "../services/code-index/embedders/opena
import { QdrantVectorStore } from "../services/code-index/vector-store/qdrant-client"
import { codeParser } from "../services/code-index/worker-utils/parser"
import { EmbedderProvider, getDefaultModelId, getModelDimension } from "../shared/embeddingModels"
import { QDRANT_CODE_BLOCK_NAMESPACE } from "../services/code-index/constants"
import {
generateNormalizedAbsolutePath,
generateRelativeFilePath,
} from "../services/code-index/worker-utils/get-relative-path"
class IndexingWorker {
private config: WorkerInitConfig | null = null
private stateManager: CodeIndexStateManager | null = null
private cacheManager: CacheManager | null = null
private scanner: Scanner | null = null
private scanner: DirectoryScanner | null = null
private fileWatcher: FileWatcher | null = null
private embedder: any = null
private vectorStore: any = null
@ -74,6 +80,7 @@ class IndexingWorker {
})
}
} catch (error) {
console.error("[IndexingWorker] Error handling message:", error)
this.sendResponse(id, {
type: "error",
error: error instanceof Error ? error.message : String(error),
@ -84,67 +91,82 @@ class IndexingWorker {
private async initialize(config: WorkerInitConfig) {
this.config = config
// Initialize state manager
this.stateManager = new CodeIndexStateManager()
// Set up state change listeners to forward to main thread
this.stateManager.onProgressUpdate((status) => {
// Send progress updates based on the current unit
if (status.currentItemUnit === "files") {
this.sendResponse("progress", {
type: "progress",
processedInBatch: status.processedItems,
totalInBatch: status.totalItems,
currentFile: status.message.includes("Current:") ? status.message.split("Current: ")[1] : undefined,
})
} else if (status.currentItemUnit === "blocks") {
this.sendResponse("blockProgress", {
type: "blockProgress",
blocksIndexed: status.processedItems,
totalBlocks: status.totalItems,
})
try {
// Validate required configuration
if (!config.workspacePath || config.workspacePath.trim() === "") {
throw new Error("Workspace path is required for indexing")
}
// Always send status updates
this.sendResponse("status", {
type: "status",
state: status.systemStatus,
message: status.message,
// Initialize state manager
this.stateManager = new CodeIndexStateManager()
// Set up state change listeners to forward to main thread
this.stateManager.onProgressUpdate((status) => {
// Send progress updates based on the current unit
if (status.currentItemUnit === "files") {
this.sendResponse("progress", {
type: "progress",
processedInBatch: status.processedItems,
totalInBatch: status.totalItems,
currentFile: status.message.includes("Current:")
? status.message.split("Current: ")[1]
: undefined,
})
} else if (status.currentItemUnit === "blocks") {
this.sendResponse("blockProgress", {
type: "blockProgress",
blocksIndexed: status.processedItems,
totalBlocks: status.totalItems,
})
}
// Always send status updates
this.sendResponse("status", {
type: "status",
state: status.systemStatus,
message: status.message,
})
})
})
// Initialize cache manager
this.cacheManager = new CacheManager({ globalStorageUri: { fsPath: config.contextPath } }, config.workspacePath)
await this.cacheManager.initialize()
// Initialize cache manager
this.cacheManager = new CacheManager(
{ globalStorageUri: { fsPath: config.contextPath } },
config.workspacePath,
)
await this.cacheManager.initialize()
// Initialize embedder based on config
this.embedder = this.createEmbedder(config)
// Initialize embedder based on config
this.embedder = this.createEmbedder(config)
// Initialize vector store
this.vectorStore = this.createVectorStore(config)
// Initialize vector store
this.vectorStore = this.createVectorStore(config)
// Load .gitignore
this.ignoreInstance = ignore()
const ignorePath = path.join(config.workspacePath, ".gitignore")
try {
const content = await fs.readFile(ignorePath, "utf8")
this.ignoreInstance.add(content)
this.ignoreInstance.add(".gitignore")
// Load .gitignore
this.ignoreInstance = ignore()
const ignorePath = path.join(config.workspacePath, ".gitignore")
try {
const content = await fs.readFile(ignorePath, "utf8")
this.ignoreInstance.add(content)
this.ignoreInstance.add(".gitignore")
} catch (error) {
// Ignore error if .gitignore doesn't exist
}
// Initialize RooIgnoreController
this.rooIgnoreController = new RooIgnoreController(config.workspacePath)
await this.rooIgnoreController.initialize()
// Initialize scanner
this.scanner = new DirectoryScanner(config.workspacePath)
// Initialize file watcher
this.fileWatcher = new FileWatcher(config.workspacePath, {
excludePatterns: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/out/**"],
})
} catch (error) {
console.error("Failed to load .gitignore:", error)
console.error("[IndexingWorker] Initialization error:", error)
throw error
}
// Initialize RooIgnoreController
this.rooIgnoreController = new RooIgnoreController(config.workspacePath)
await this.rooIgnoreController.initialize()
// Initialize scanner
this.scanner = new Scanner(config.workspacePath)
// Initialize file watcher
this.fileWatcher = new FileWatcher(config.workspacePath, {
excludePatterns: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/out/**"],
})
}
private createEmbedder(config: WorkerInitConfig): any {
@ -282,16 +304,28 @@ class IndexingWorker {
const { embeddings } = await this.embedder.createEmbeddings(texts)
// Prepare points for vector store
const points = blocks.map((block, index) => ({
id: `${block.file_path}:${block.start_line}`,
vector: embeddings[index],
payload: {
filePath: block.file_path,
codeChunk: block.content,
startLine: block.start_line,
endLine: block.end_line,
},
}))
const points = blocks.map((block, index) => {
const normalizedAbsolutePath = generateNormalizedAbsolutePath(
block.file_path,
this.config!.workspacePath,
)
const stableName = `${normalizedAbsolutePath}:${block.start_line}`
const pointId = uuidv5(stableName, QDRANT_CODE_BLOCK_NAMESPACE)
return {
id: pointId,
vector: embeddings[index],
payload: {
filePath: generateRelativeFilePath(
normalizedAbsolutePath,
this.config!.workspacePath,
),
codeChunk: block.content,
startLine: block.start_line,
endLine: block.end_line,
},
}
})
await this.vectorStore.upsertPoints(points)
totalBlocksIndexed += blocks.length
@ -304,8 +338,6 @@ class IndexingWorker {
// Start file watcher
if (this.fileWatcher) {
this.fileWatcher.onFileChange(async (event) => {
// Handle file changes
console.log(`File ${event.type}: ${event.path}`)
// TODO: Implement file change handling
})
this.fileWatcher.start()
@ -375,4 +407,9 @@ class IndexingWorker {
}
// Start the worker
new IndexingWorker()
try {
new IndexingWorker()
} catch (error) {
console.error("[IndexingWorker] Failed to create IndexingWorker:", error)
process.exit(1)
}