diff --git a/.gitignore b/.gitignore index 6d4632529..4c42dc529 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,18 @@ coverage *evals.env # Generated proto files -src/shared/proto/*.ts src/core/controller/*/methods.ts src/core/controller/*/index.ts src/core/controller/grpc-service-config.ts +# Shared +src/shared/proto/*.ts +src/shared/proto/host/*.ts +# Webview webview-ui/src/services/grpc-client.ts +# Standalone src/standalone/server-setup.ts +src/standalone/services/host-grpc-client.ts +# Host bridge +hosts/vscode/*/methods.ts +hosts/vscode/*/index.ts +hosts/vscode/host-grpc-service-config.ts diff --git a/hosts/vscode/host-grpc-handler.ts b/hosts/vscode/host-grpc-handler.ts new file mode 100644 index 000000000..c90bba62f --- /dev/null +++ b/hosts/vscode/host-grpc-handler.ts @@ -0,0 +1,197 @@ +import { v4 as uuidv4 } from "uuid" +import { hostServiceHandlers } from "./host-grpc-service-config" +import { GrpcRequestRegistry } from "../../src/core/controller/grpc-request-registry" + +/** + * Type definition for a streaming response handler + */ +export type StreamingResponseHandler = (response: any, isLast?: boolean, sequenceNumber?: number) => Promise + +// Registry to track active gRPC requests and their cleanup functions +const requestRegistry = new GrpcRequestRegistry() + +/** + * Callback interface for streaming requests + */ +export interface StreamingCallbacks { + onResponse: (response: T) => void + onError?: (error: Error) => void + onComplete?: () => void +} + +/** + * Handles gRPC requests from the webview + */ +export class GrpcHandler { + constructor() {} + + /** + * Handle a gRPC request from the webview + * @param service The service name + * @param method The method name + * @param message The request message + * @param requestId The request ID for response correlation + * @param streamingCallbacks Optional callbacks for streaming responses + * @returns For unary requests: the response message or error. For streaming requests: a cancel function. + */ + async handleRequest( + service: string, + method: string, + message: any, + requestId: string, + streamingCallbacks?: StreamingCallbacks, + ): Promise< + | { + message?: any + error?: string + request_id: string + } + | (() => void) + > { + // If streaming callbacks are provided, handle as a streaming request + if (streamingCallbacks) { + let completionCalled = false + + // Create a response handler that will call the client's callbacks + const responseHandler: StreamingResponseHandler = async (response, isLast = false, sequenceNumber) => { + try { + // Call the client's onResponse callback with the response + streamingCallbacks.onResponse(response) + + // If this is the last response, call the onComplete callback + if (isLast && streamingCallbacks.onComplete && !completionCalled) { + completionCalled = true + streamingCallbacks.onComplete() + } + } catch (error) { + // If there's an error in the callback, call the onError callback + if (streamingCallbacks.onError) { + streamingCallbacks.onError(error instanceof Error ? error : new Error(String(error))) + } + } + } + + // Register the response handler with the registry + requestRegistry.registerRequest( + requestId, + () => { + console.log(`[DEBUG] Cleaning up streaming request: ${requestId}`) + if (streamingCallbacks.onComplete && !completionCalled) { + completionCalled = true + streamingCallbacks.onComplete() + } + }, + { type: "streaming_request", service, method }, + responseHandler, + ) + + // Call the streaming handler directly + console.log(`[DEBUG] Streaming gRPC host call to ${service}.${method} req:${requestId}`) + try { + await this.handleStreamingRequest(service, method, message, requestId) + } catch (error) { + if (streamingCallbacks.onError) { + streamingCallbacks.onError(error instanceof Error ? error : new Error(String(error))) + } + } + + // Return a function to cancel the stream + return () => { + console.log(`[DEBUG] Cancelling streaming request: ${requestId}`) + this.cancelRequest(requestId) + } + } + + // Handle as a unary request + try { + // Get the service handler from the config + const serviceConfig = hostServiceHandlers[service] + if (!serviceConfig) { + throw new Error(`Unknown service: ${service}`) + } + + // Handle unary request + return { + message: await serviceConfig.requestHandler(method, message), + request_id: requestId, + } + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + request_id: requestId, + } + } + } + + /** + * Cancel a gRPC request + * @param requestId The request ID to cancel + * @returns True if the request was found and cancelled, false otherwise + */ + public async cancelRequest(requestId: string): Promise { + const cancelled = requestRegistry.cancelRequest(requestId) + + if (cancelled) { + // Get the registered response handler from the registry + const requestInfo = requestRegistry.getRequestInfo(requestId) + if (requestInfo && requestInfo.responseStream) { + try { + // Send cancellation confirmation using the registered response handler + await requestInfo.responseStream( + { cancelled: true }, + true, // Mark as last message + ) + } catch (e) { + console.error(`Error sending cancellation response for ${requestId}:`, e) + } + } + } else { + console.log(`[DEBUG] Request not found for cancellation: ${requestId}`) + } + + return cancelled + } + + /** + * Handle a streaming gRPC request + * @param service The service name + * @param method The method name + * @param message The request message + * @param requestId The request ID for response correlation + */ + private async handleStreamingRequest(service: string, method: string, message: any, requestId: string): Promise { + // Get the service handler from the config + const serviceConfig = hostServiceHandlers[service] + if (!serviceConfig) { + throw new Error(`Unknown service: ${service}`) + } + + // Check if the service supports streaming + if (!serviceConfig.streamingHandler) { + throw new Error(`Service ${service} does not support streaming`) + } + + // Get the registered response handler from the registry + const requestInfo = requestRegistry.getRequestInfo(requestId) + if (!requestInfo || !requestInfo.responseStream) { + throw new Error(`No response handler registered for request: ${requestId}`) + } + + // Use the registered response handler + const responseStream = requestInfo.responseStream + + // Handle streaming request and pass the requestId to all streaming handlers + await serviceConfig.streamingHandler(method, message, responseStream, requestId) + + // Don't send a final message here - the stream should stay open for future updates + // The stream will be closed when the client disconnects or when the service explicitly ends it + } +} + +/** + * Get the request registry instance + * This allows other parts of the code to access the registry + */ +export function getRequestRegistry(): GrpcRequestRegistry { + return requestRegistry +} diff --git a/hosts/vscode/host-grpc-service.ts b/hosts/vscode/host-grpc-service.ts new file mode 100644 index 000000000..f4733a4fa --- /dev/null +++ b/hosts/vscode/host-grpc-service.ts @@ -0,0 +1,138 @@ +import { StreamingResponseHandler } from "./host-grpc-handler" + +/** + * Generic type for service method handlers + */ +export type ServiceMethodHandler = (message: any) => Promise + +/** + * Type for streaming method handlers + */ +export type StreamingMethodHandler = (message: any, responseStream: StreamingResponseHandler, requestId?: string) => Promise + +/** + * Method metadata including streaming information + */ +export interface MethodMetadata { + isStreaming: boolean +} + +/** + * Generic service registry for gRPC services + */ +export class ServiceRegistry { + private serviceName: string + private methodRegistry: Record = {} + private streamingMethodRegistry: Record = {} + private methodMetadata: Record = {} + + /** + * Create a new service registry + * @param serviceName The name of the service (used for logging) + */ + constructor(serviceName: string) { + this.serviceName = serviceName + } + + /** + * Register a method handler + * @param methodName The name of the method to register + * @param handler The handler function for the method + * @param metadata Optional metadata about the method + */ + registerMethod(methodName: string, handler: ServiceMethodHandler | StreamingMethodHandler, metadata?: MethodMetadata): void { + const isStreaming = metadata?.isStreaming || false + + if (isStreaming) { + this.streamingMethodRegistry[methodName] = handler as StreamingMethodHandler + } else { + this.methodRegistry[methodName] = handler as ServiceMethodHandler + } + + this.methodMetadata[methodName] = { isStreaming, ...metadata } + console.log(`Registered ${this.serviceName} method: ${methodName}${isStreaming ? " (streaming)" : ""}`) + } + + /** + * Check if a method is a streaming method + * @param method The method name + * @returns True if the method is a streaming method + */ + isStreamingMethod(method: string): boolean { + return this.methodMetadata[method]?.isStreaming || false + } + + /** + * Get a streaming method handler + * @param method The method name + * @returns The streaming method handler or undefined if not found + */ + getStreamingHandler(method: string): StreamingMethodHandler | undefined { + return this.streamingMethodRegistry[method] + } + + /** + * Handle a service request + * @param method The method name + * @param message The request message + * @returns The response message + */ + async handleRequest(method: string, message: any): Promise { + const handler = this.methodRegistry[method] + + if (!handler) { + if (this.isStreamingMethod(method)) { + throw new Error(`Method ${method} is a streaming method and should be handled with handleStreamingRequest`) + } + throw new Error(`Unknown ${this.serviceName} method: ${method}`) + } + + return handler(message) + } + + /** + * Handle a streaming service request + * @param method The method name + * @param message The request message + * @param responseStream The streaming response handler + * @param requestId The request ID for correlation and cleanup + */ + async handleStreamingRequest( + method: string, + message: any, + responseStream: StreamingResponseHandler, + requestId?: string, + ): Promise { + const handler = this.streamingMethodRegistry[method] + + if (!handler) { + if (this.methodRegistry[method]) { + throw new Error(`Method ${method} is not a streaming method and should be handled with handleRequest`) + } + throw new Error(`Unknown ${this.serviceName} streaming method: ${method}`) + } + + await handler(message, responseStream, requestId) + } +} + +/** + * Create a service registry factory function + * @param serviceName The name of the service + * @returns An object with register and handle functions + */ +export function createServiceRegistry(serviceName: string) { + const registry = new ServiceRegistry(serviceName) + + return { + registerMethod: (methodName: string, handler: ServiceMethodHandler | StreamingMethodHandler, metadata?: MethodMetadata) => + registry.registerMethod(methodName, handler, metadata), + + handleRequest: (method: string, message: any) => registry.handleRequest(method, message), + + handleStreamingRequest: (method: string, message: any, responseStream: StreamingResponseHandler, requestId?: string) => + registry.handleStreamingRequest(method, message, responseStream, requestId), + + isStreamingMethod: (method: string) => registry.isStreamingMethod(method), + } +} diff --git a/hosts/vscode/uri/file.ts b/hosts/vscode/uri/file.ts new file mode 100644 index 000000000..945ce6d36 --- /dev/null +++ b/hosts/vscode/uri/file.ts @@ -0,0 +1,20 @@ +import * as vscode from "vscode" +import { Uri } from "../../../src/shared/proto/host/uri" +import { StringRequest } from "../../../src/shared/proto/common" + +/** + * Creates a file URI from a file path + * @param request The request containing the file path + * @returns A URI object representing the file + */ +export async function file(request: StringRequest): Promise { + const uri = vscode.Uri.file(request.value) + return Uri.create({ + scheme: uri.scheme, + authority: uri.authority, + path: uri.path, + query: uri.query, + fragment: uri.fragment, + fsPath: uri.fsPath, + }) +} diff --git a/hosts/vscode/uri/joinPath.ts b/hosts/vscode/uri/joinPath.ts new file mode 100644 index 000000000..43f363074 --- /dev/null +++ b/hosts/vscode/uri/joinPath.ts @@ -0,0 +1,28 @@ +import * as vscode from "vscode" +import { JoinPathRequest, Uri } from "../../../src/shared/proto/host/uri" + +/** + * Joins a URI with additional path segments + * @param request The request containing the base URI and path segments + * @returns A new URI with the path segments joined + */ +export async function joinPath(request: JoinPathRequest): Promise { + // Convert proto Uri to vscode.Uri + if (!request.base) { + throw new Error("Base URI is required") + } + const baseUri = vscode.Uri.parse(`${request.base.scheme}://${request.base.authority}${request.base.path}`) + + // Join paths + const result = vscode.Uri.joinPath(baseUri, ...request.pathSegments) + + // Convert back to proto Uri + return Uri.create({ + scheme: result.scheme, + authority: result.authority, + path: result.path, + query: result.query, + fragment: result.fragment, + fsPath: result.fsPath, + }) +} diff --git a/hosts/vscode/uri/parse.ts b/hosts/vscode/uri/parse.ts new file mode 100644 index 000000000..db8e851b9 --- /dev/null +++ b/hosts/vscode/uri/parse.ts @@ -0,0 +1,20 @@ +import * as vscode from "vscode" +import { Uri } from "../../../src/shared/proto/host/uri" +import { StringRequest } from "../../../src/shared/proto/common" + +/** + * Parses a string URI into a Uri object + * @param request The request containing the URI string + * @returns A URI object representing the parsed URI + */ +export async function parse(request: StringRequest): Promise { + const uri = vscode.Uri.parse(request.value) + return Uri.create({ + scheme: uri.scheme, + authority: uri.authority, + path: uri.path, + query: uri.query, + fragment: uri.fragment, + fsPath: uri.fsPath, + }) +} diff --git a/hosts/vscode/watch/subscribeToFile.ts b/hosts/vscode/watch/subscribeToFile.ts new file mode 100644 index 000000000..e431052b3 --- /dev/null +++ b/hosts/vscode/watch/subscribeToFile.ts @@ -0,0 +1,225 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import { SubscribeToFileRequest, FileChangeEvent, FileChangeEvent_ChangeType } from "../../../src/shared/proto/host/watch" +import { StreamingResponseHandler, getRequestRegistry } from "../host-grpc-handler" + +// Debounce configuration +const DEBOUNCE_DELAY = 100 // ms + +// Keep track of active file watchers +const fileWatchers = new Map< + string, + { + watcher: fsSync.FSWatcher + subscribers: Set + lastEventTime: Map // Track last event time by event type + } +>() + +/** + * Subscribe to file changes + * @param request The request containing the file path + * @param responseStream The streaming response handler + * @param requestId The ID of the request (passed by the gRPC handler) + */ +export async function subscribeToFile( + request: SubscribeToFileRequest, + responseStream: StreamingResponseHandler, + requestId?: string, +): Promise { + const filePath = request.path + console.log(`[DEBUG] Setting up file subscription for ${filePath}`) + + try { + // We don't send an initial event to avoid triggering handlers immediately + console.log(`[DEBUG] Now watching file: ${filePath}`) + + // Set up or reuse file watcher + if (!fileWatchers.has(filePath)) { + // Create a new watcher for this file using Node.js fs.watch API + // This is more reliable than the VSCode FileSystemWatcher for detecting file saves + const watcher = fsSync.watch(filePath, { persistent: true }, async (eventType, filename) => { + if (eventType === "change") { + try { + const content = await fs.readFile(filePath, "utf8") + console.log(`[DEBUG] File changed: ${filePath}`) + + // Get the watcher info + const watcherInfo = fileWatchers.get(filePath) + if (watcherInfo) { + // Check if this event should be debounced + const eventType = FileChangeEvent_ChangeType.CHANGED + const now = Date.now() + const lastTime = watcherInfo.lastEventTime.get(eventType) || 0 + + if (now - lastTime < DEBOUNCE_DELAY) { + console.log( + `[DEBUG] Debouncing change event for ${filePath} (${now - lastTime}ms since last event)`, + ) + return // Skip this event due to debounce + } + + // Update the last event time + watcherInfo.lastEventTime.set(eventType, now) + + // Notify all subscribers + for (const subscriber of watcherInfo.subscribers) { + try { + await subscriber({ + path: filePath, + type: eventType, + content, + }) + } catch (error) { + console.error(`Error sending file change event: ${error}`) + watcherInfo.subscribers.delete(subscriber) + } + } + } + } catch (error) { + console.error(`Error reading changed file: ${error}`) + } + } else if (eventType === "rename") { + // In Node.js fs.watch, 'rename' can mean either creation or deletion + // We need to check if the file exists to determine which it is + try { + await fs.access(filePath) + // File exists, so it was created or renamed + const content = await fs.readFile(filePath, "utf8") + console.log(`[DEBUG] File created/renamed: ${filePath}`) + + // Get the watcher info + const watcherInfo = fileWatchers.get(filePath) + if (watcherInfo) { + // Check if this event should be debounced + const eventType = FileChangeEvent_ChangeType.CREATED + const now = Date.now() + const lastTime = watcherInfo.lastEventTime.get(eventType) || 0 + + if (now - lastTime < DEBOUNCE_DELAY) { + console.log( + `[DEBUG] Debouncing creation event for ${filePath} (${now - lastTime}ms since last event)`, + ) + return // Skip this event due to debounce + } + + // Update the last event time + watcherInfo.lastEventTime.set(eventType, now) + + // Notify all subscribers + for (const subscriber of watcherInfo.subscribers) { + try { + await subscriber({ + path: filePath, + type: eventType, + content, + }) + } catch (error) { + console.error(`Error sending file creation event: ${error}`) + watcherInfo.subscribers.delete(subscriber) + } + } + } + } catch (error) { + // File doesn't exist, so it was deleted + console.log(`[DEBUG] File deleted: ${filePath}`) + + // Get the watcher info + const watcherInfo = fileWatchers.get(filePath) + if (watcherInfo) { + // Check if this event should be debounced + const eventType = FileChangeEvent_ChangeType.DELETED + const now = Date.now() + const lastTime = watcherInfo.lastEventTime.get(eventType) || 0 + + if (now - lastTime < DEBOUNCE_DELAY) { + console.log( + `[DEBUG] Debouncing deletion event for ${filePath} (${now - lastTime}ms since last event)`, + ) + return // Skip this event due to debounce + } + + // Update the last event time + watcherInfo.lastEventTime.set(eventType, now) + + // Notify all subscribers + for (const subscriber of watcherInfo.subscribers) { + try { + await subscriber({ + path: filePath, + type: eventType, + content: "", + }) + } catch (error) { + console.error(`Error sending file deletion event: ${error}`) + watcherInfo.subscribers.delete(subscriber) + } + } + + // Clean up the watcher + cleanupWatcher(filePath) + } + } + } + }) + + // Set up the watcher info + const watcherInfo = { + watcher, + subscribers: new Set(), + lastEventTime: new Map(), + } + + fileWatchers.set(filePath, watcherInfo) + } + + // Add this subscriber to the watcher + const watcherInfo = fileWatchers.get(filePath)! + watcherInfo.subscribers.add(responseStream) + + // Register cleanup when the connection is closed + const cleanup = () => { + console.log(`[DEBUG] Cleaning up file subscription for ${filePath}`) + const watcherInfo = fileWatchers.get(filePath) + if (watcherInfo) { + watcherInfo.subscribers.delete(responseStream) + + // If no subscribers left, clean up the watcher + if (watcherInfo.subscribers.size === 0) { + cleanupWatcher(filePath) + } + } + } + + // Register the cleanup function with the request registry + if (requestId) { + getRequestRegistry().registerRequest( + requestId, + cleanup, + { type: "file_subscription", path: filePath }, + responseStream, + ) + } + } catch (error) { + console.error(`Error setting up file subscription: ${error}`) + // Send an error response + await responseStream({ + path: filePath, + type: FileChangeEvent_ChangeType.DELETED, + content: `Error: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Clean up a file watcher + * @param filePath The path of the file to clean up + */ +function cleanupWatcher(filePath: string): void { + const watcherInfo = fileWatchers.get(filePath) + if (watcherInfo) { + watcherInfo.watcher.close() + fileWatchers.delete(filePath) + console.log(`[DEBUG] Removed file watcher for ${filePath}`) + } +} diff --git a/proto/build-proto.js b/proto/build-proto.js index bea28a28c..7e309a5e0 100755 --- a/proto/build-proto.js +++ b/proto/build-proto.js @@ -72,6 +72,15 @@ const serviceNameMap = { } const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey)) +// List of host gRPC services (IDE API bridge) +// These services are implemented in the IDE extension and called by the standalone Cline Core +const hostServiceNameMap = { + uri: "host.UriService", + watch: "host.WatchService", + // Add new host services here +} +const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) => path.join(ROOT_DIR, "hosts", "vscode", serviceKey)) + async function main() { console.log(chalk.bold.blue("Starting Protocol Buffer code generation...")) @@ -80,9 +89,11 @@ async function main() { // Define output directories const TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto") + const HOST_TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto", "host") - // Create output directory if it doesn't exist + // Create output directories if they don't exist await fs.mkdir(TS_OUT_DIR, { recursive: true }) + await fs.mkdir(HOST_TS_OUT_DIR, { recursive: true }) // Clean up existing generated files console.log(chalk.cyan("Cleaning up existing generated TypeScript files...")) @@ -94,7 +105,7 @@ async function main() { // Check for missing proto files for services in serviceNameMap await ensureProtoFilesExist() - // Process all proto files + // Process main proto files console.log(chalk.cyan("Processing proto files from"), SCRIPT_DIR) const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR, realpath: true }) @@ -115,16 +126,42 @@ async function main() { process.exit(1) } + // Process host proto files + console.log(chalk.cyan("Processing host proto files from"), path.join(SCRIPT_DIR, "host")) + const hostProtoFiles = await globby("*.proto", { cwd: path.join(SCRIPT_DIR, "host"), absolute: true }) + + if (hostProtoFiles.length > 0) { + // Build the protoc command for host proto files + const hostTsProtocCommand = [ + protoc, + `--proto_path="${SCRIPT_DIR}"`, + `--proto_path="${path.join(SCRIPT_DIR, "host")}"`, + `--plugin=protoc-gen-ts_proto="${tsProtoPlugin}"`, + `--ts_proto_out="${TS_OUT_DIR}"`, + "--ts_proto_opt=outputServices=generic-definitions,env=node,esModuleInterop=true,useDate=false,useOptionals=messages", + ...hostProtoFiles, + ].join(" ") + try { + console.log(chalk.cyan(`Generating TypeScript code for host proto files:\n${hostProtoFiles.join("\n")}...`)) + execSync(hostTsProtocCommand, { stdio: "inherit" }) + } catch (error) { + console.error(chalk.red("Error generating TypeScript for host proto files:"), error) + process.exit(1) + } + } + const descriptorOutDir = path.join(ROOT_DIR, "dist-standalone", "proto") await fs.mkdir(descriptorOutDir, { recursive: true }) const descriptorFile = path.join(descriptorOutDir, "descriptor_set.pb") + const allProtoFiles = [...protoFiles, ...hostProtoFiles] const descriptorProtocCommand = [ protoc, `--proto_path="${SCRIPT_DIR}"`, + `--proto_path="${path.join(SCRIPT_DIR, "host")}"`, `--descriptor_set_out="${descriptorFile}"`, "--include_imports", - ...protoFiles, + ...allProtoFiles, ].join(" ") try { console.log(chalk.cyan("Generating descriptor set...")) @@ -138,8 +175,11 @@ async function main() { console.log(chalk.green(`TypeScript files generated in: ${TS_OUT_DIR}`)) await generateMethodRegistrations() + await generateHostMethodRegistrations() await generateServiceConfig() + await generateHostServiceConfig() await generateGrpcClientConfig() + await generateHostGrpcClientConfig() } /** @@ -282,7 +322,7 @@ async function generateMethodRegistrations() { // Import all method implementations import { registerMethod } from "./index"\n` - // Add imports for all implementation files + // Import implementations directly for (const file of implementationFiles) { const baseName = path.basename(file, ".ts") methodsContent += `import { ${baseName} } from "./${baseName}"\n` @@ -456,6 +496,212 @@ service ${serviceClassName} { } } +/** + * Generate method registration files for host services + */ +async function generateHostMethodRegistrations() { + console.log(chalk.cyan("Generating host method registration files...")) + + // Parse proto files for streaming methods + const hostProtoFiles = await globby("*.proto", { cwd: path.join(SCRIPT_DIR, "host") }) + const streamingMethodsMap = await parseProtoForStreamingMethods(hostProtoFiles, path.join(SCRIPT_DIR, "host")) + + for (const serviceDir of hostServiceDirs) { + try { + await fs.access(serviceDir) + } catch (error) { + console.log(chalk.cyan(`Creating directory ${serviceDir} for new host service`)) + await fs.mkdir(serviceDir, { recursive: true }) + } + + const serviceName = path.basename(serviceDir) + const registryFile = path.join(serviceDir, "methods.ts") + const indexFile = path.join(serviceDir, "index.ts") + + const fullServiceName = hostServiceNameMap[serviceName] + const streamingMethods = streamingMethodsMap.get(fullServiceName) || [] + + console.log(chalk.cyan(`Generating method registrations for host ${serviceName}...`)) + + // Get all TypeScript files in the service directory + const files = await globby("*.ts", { cwd: serviceDir }) + + // Filter out index.ts and methods.ts + const implementationFiles = files.filter((file) => file !== "index.ts" && file !== "methods.ts") + + // Create the methods.ts file with header + let methodsContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +// Import all method implementations +import { registerMethod } from "./index"\n` + + // Import implementations directly + for (const file of implementationFiles) { + const baseName = path.basename(file, ".ts") + methodsContent += `import { ${baseName} } from "./${baseName}"\n` + } + + // Add streaming methods information + if (streamingMethods.length > 0) { + methodsContent += `\n// Streaming methods for this service +export const streamingMethods = ${JSON.stringify( + streamingMethods.map((m) => m.name), + null, + 2, + )}\n` + } + + // Add registration function + methodsContent += `\n// Register all ${serviceName} service methods +export function registerAllMethods(): void { +\t// Register each method with the registry\n` + + // Add registration statements + for (const file of implementationFiles) { + const baseName = path.basename(file, ".ts") + const isStreaming = streamingMethods.some((m) => m.name === baseName) + + if (isStreaming) { + methodsContent += `\tregisterMethod("${baseName}", ${baseName}, { isStreaming: true })\n` + } else { + methodsContent += `\tregisterMethod("${baseName}", ${baseName})\n` + } + } + + // Close the function + methodsContent += `}` + + // Write the methods.ts file + await fs.writeFile(registryFile, methodsContent) + console.log(chalk.green(`Generated ${registryFile}`)) + + // Generate index.ts file + const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1) + const indexContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../host-grpc-service" +import { StreamingResponseHandler } from "../host-grpc-handler" +import { registerAllMethods } from "./methods" + +// Create ${serviceName} service registry +const ${serviceName}Service = createServiceRegistry("${serviceName}") + +// Export the method handler types and registration function +export type ${capitalizedServiceName}MethodHandler = ServiceMethodHandler +export type ${capitalizedServiceName}StreamingMethodHandler = StreamingMethodHandler +export const registerMethod = ${serviceName}Service.registerMethod + +// Export the request handlers +export const handle${capitalizedServiceName}ServiceRequest = ${serviceName}Service.handleRequest +export const handle${capitalizedServiceName}ServiceStreamingRequest = ${serviceName}Service.handleStreamingRequest +export const isStreamingMethod = ${serviceName}Service.isStreamingMethod + +// Register all ${serviceName} methods +registerAllMethods()` + + // Write the index.ts file + await fs.writeFile(indexFile, indexContent) + console.log(chalk.green(`Generated ${indexFile}`)) + } + + console.log(chalk.green("Host method registration files generated successfully.")) +} + +/** + * Generate a service configuration file for host services + */ +async function generateHostServiceConfig() { + console.log(chalk.cyan("Generating host service configuration file...")) + + const serviceImports = [] + const serviceConfigs = [] + + // Add all services from the hostServiceNameMap + for (const [dirName, fullServiceName] of Object.entries(hostServiceNameMap)) { + const capitalizedName = dirName.charAt(0).toUpperCase() + dirName.slice(1) + serviceImports.push( + `import { handle${capitalizedName}ServiceRequest, handle${capitalizedName}ServiceStreamingRequest } from "./${dirName}/index"`, + ) + serviceConfigs.push(` + "${fullServiceName}": { + requestHandler: handle${capitalizedName}ServiceRequest, + streamingHandler: handle${capitalizedName}ServiceStreamingRequest + }`) + } + + const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { StreamingResponseHandler } from "./host-grpc-handler" +${serviceImports.join("\n")} + +/** + * Configuration for a host service handler + */ +export interface HostServiceHandlerConfig { + requestHandler: (method: string, message: any) => Promise; + streamingHandler: (method: string, message: any, responseStream: StreamingResponseHandler, requestId?: string) => Promise; +} + +/** + * Map of host service names to their handler configurations + */ +export const hostServiceHandlers: Record = {${serviceConfigs.join(",")} +};` + + const configPath = path.join(ROOT_DIR, "hosts", "vscode", "host-grpc-service-config.ts") + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, content) + console.log(chalk.green(`Generated host service configuration at ${configPath}`)) +} + +/** + * Generate a gRPC client configuration file for host services + */ +async function generateHostGrpcClientConfig() { + console.log(chalk.cyan("Generating host gRPC client configuration...")) + + const serviceImports = [] + const serviceClientCreations = [] + const serviceExports = [] + + // Process each service in the hostServiceNameMap + for (const [dirName, fullServiceName] of Object.entries(hostServiceNameMap)) { + const capitalizedName = dirName.charAt(0).toUpperCase() + dirName.slice(1) + + // Add import statement + serviceImports.push(`import { ${capitalizedName}ServiceDefinition } from "@shared/proto/host/${dirName}"`) + + // Add client creation + serviceClientCreations.push( + `const ${capitalizedName}ServiceClient = createGrpcClient(${capitalizedName}ServiceDefinition)`, + ) + + // Add to exports + serviceExports.push(`${capitalizedName}ServiceClient`) + } + + // Generate the file content + const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createGrpcClient } from "./host-grpc-client-base" +${serviceImports.join("\n")} + +${serviceClientCreations.join("\n")} + +export { + ${serviceExports.join(",\n\t")} +}` + + const configPath = path.join(ROOT_DIR, "src", "standalone", "services", "host-grpc-client.ts") + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, content) + console.log(chalk.green(`Generated host gRPC client at ${configPath}`)) +} + // Run the main function main().catch((error) => { console.error(chalk.red("Error:"), error) diff --git a/proto/host/uri.proto b/proto/host/uri.proto new file mode 100644 index 000000000..83d7d9a5b --- /dev/null +++ b/proto/host/uri.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package host; +option java_package = "bot.cline.host.proto"; +option java_multiple_files = true; + +import "common.proto"; + +// UriService provides methods for working with URIs in the IDE +service UriService { + // Create a new file URI from a file path + rpc file(cline.StringRequest) returns (Uri); + + // Join a URI with additional path segments + rpc joinPath(JoinPathRequest) returns (Uri); + + // Parse a string URI into a Uri object + rpc parse(cline.StringRequest) returns (Uri); +} + +// Uri represents a URI in the IDE +message Uri { + string scheme = 1; + string authority = 2; + string path = 3; + string query = 4; + string fragment = 5; + string fsPath = 6; +} + +// Request for joining path segments to a URI +message JoinPathRequest { + cline.Metadata metadata = 1; + Uri base = 2; + repeated string pathSegments = 3; +} diff --git a/proto/host/watch.proto b/proto/host/watch.proto new file mode 100644 index 000000000..b9c11bd86 --- /dev/null +++ b/proto/host/watch.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package host; +option java_package = "bot.cline.host.proto"; +option java_multiple_files = true; + +import "common.proto"; + +// WatchService provides methods for watching files in the IDE +service WatchService { + // Subscribe to file changes + rpc subscribeToFile(SubscribeToFileRequest) returns (stream FileChangeEvent); +} + +// Request to subscribe to file changes +message SubscribeToFileRequest { + cline.Metadata metadata = 1; + string path = 2; +} + +// Event representing a file change +message FileChangeEvent { + enum ChangeType { + CREATED = 0; + CHANGED = 1; + DELETED = 2; + } + + string path = 1; + ChangeType type = 2; + string content = 3; // Optional content of the file after change +} diff --git a/src/core/controller/file/getRelativePaths.ts b/src/core/controller/file/getRelativePaths.ts index 3c377a820..c2906d823 100644 --- a/src/core/controller/file/getRelativePaths.ts +++ b/src/core/controller/file/getRelativePaths.ts @@ -3,6 +3,8 @@ import { RelativePathsRequest, RelativePaths } from "@shared/proto/file" import { FileMethodHandler } from "./index" import * as vscode from "vscode" import * as path from "path" +import { UriServiceClient } from "../../../standalone/services/host-grpc-client" +import { Metadata, StringRequest } from "@shared/proto/common" /** * Converts a list of URIs to workspace-relative paths @@ -17,7 +19,15 @@ export const getRelativePaths: FileMethodHandler = async ( const resolvedPaths = await Promise.all( request.uris.map(async (uriString) => { try { - const fileUri = vscode.Uri.parse(uriString, true) + // Use the host URI service client instead of directly using vscode.Uri.parse + const parseResponse = await UriServiceClient.parse( + StringRequest.create({ + metadata: Metadata.create({}), + value: uriString, + }), + ) + const fileUri = vscode.Uri.parse(`${parseResponse.scheme}://${parseResponse.authority}${parseResponse.path}`) + console.log("[DEBUG] UriServiceClient.parse:", fileUri) const relativePathToGet = vscode.workspace.asRelativePath(fileUri, false) // If the path is still absolute, it's outside the workspace diff --git a/src/core/controller/task/newTask.ts b/src/core/controller/task/newTask.ts index 48fb37e84..15274264e 100644 --- a/src/core/controller/task/newTask.ts +++ b/src/core/controller/task/newTask.ts @@ -1,6 +1,7 @@ import { Controller } from ".." import { Empty } from "../../../shared/proto/common" import { NewTaskRequest } from "../../../shared/proto/task" +import { handleFileServiceRequest } from "../file" /** * Creates a new task with the given text and optional images diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index bf35c2857..f9106cf48 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -17,6 +17,9 @@ import * as fs from "fs/promises" import * as path from "path" import * as vscode from "vscode" import { z } from "zod" +import { WatchServiceClient } from "../../standalone/services/host-grpc-client" +import { FileChangeEvent_ChangeType, SubscribeToFileRequest } from "../../shared/proto/host/watch" +import { Metadata } from "../../shared/proto/common" import { DEFAULT_MCP_TIMEOUT_SECONDS, McpMode, @@ -118,22 +121,45 @@ export class McpHub { private async watchMcpSettingsFile(): Promise { const settingsPath = await this.getMcpSettingsFilePath() - this.disposables.push( - vscode.workspace.onDidSaveTextDocument(async (document) => { - if (arePathsEqual(document.uri.fsPath, settingsPath)) { - const settings = await this.readAndValidateMcpSettingsFile() - if (settings) { - try { - vscode.window.showInformationMessage("Updating MCP servers...") - await this.updateServerConnections(settings.mcpServers) - vscode.window.showInformationMessage("MCP servers updated") - } catch (error) { - console.error("Failed to process MCP settings change:", error) + + // Subscribe to file changes using the gRPC WatchService + console.log("[DEBUG] subscribing to mcp file changes") + const cancelSubscription = WatchServiceClient.subscribeToFile( + SubscribeToFileRequest.create({ + metadata: Metadata.create({}), + path: settingsPath, + }), + { + onResponse: async (response) => { + console.log( + `[DEBUG] MCP settings ${response.type === FileChangeEvent_ChangeType.CHANGED ? "changed" : "event"}`, + ) + + // Only process the file if it was changed (not created or deleted) + if (response.type === FileChangeEvent_ChangeType.CHANGED) { + const settings = await this.readAndValidateMcpSettingsFile() + if (settings) { + try { + vscode.window.showInformationMessage("Updating MCP servers...") + await this.updateServerConnections(settings.mcpServers) + vscode.window.showInformationMessage("MCP servers updated") + } catch (error) { + console.error("Failed to process MCP settings change:", error) + } } } - } - }), + }, + onError: (error) => { + console.error("Error watching MCP settings file:", error) + }, + onComplete: () => { + console.log("[DEBUG] MCP settings file watch completed") + }, + }, ) + + // Add the cancellation function to disposables + this.disposables.push({ dispose: cancelSubscription }) } private async initializeMcpServers(): Promise { diff --git a/src/standalone/services/host-grpc-client-base.ts b/src/standalone/services/host-grpc-client-base.ts new file mode 100644 index 000000000..8e693a94f --- /dev/null +++ b/src/standalone/services/host-grpc-client-base.ts @@ -0,0 +1,106 @@ +import { v4 as uuidv4 } from "uuid" +import { GrpcHandler, StreamingCallbacks } from "../../../hosts/vscode/host-grpc-handler" + +// Generic type for any protobuf service definition +export type ProtoService = { + name: string + fullName: string + methods: { + [key: string]: { + name: string + requestType: any + responseType: any + requestStream: boolean + responseStream: boolean + options: any + } + } +} + +// Define a unified client type that handles both unary and streaming methods +export type GrpcClientType = { + [K in keyof T["methods"]]: T["methods"][K]["responseStream"] extends true + ? ( + request: InstanceType, + options: StreamingCallbacks>, + ) => () => void // Returns a cancel function + : (request: InstanceType) => Promise> +} + +// Create a client for any protobuf service with inferred types +export function createGrpcClient(service: T): GrpcClientType { + const client = {} as GrpcClientType + const grpcHandler = new GrpcHandler() + + Object.values(service.methods).forEach((method) => { + // Streaming method implementation + if (method.responseStream) { + // Use lowercase method name as the key in the client object + const methodKey = method.name.charAt(0).toLowerCase() + method.name.slice(1) + client[methodKey as keyof GrpcClientType] = (( + request: any, + options: StreamingCallbacks>, + ) => { + // Use handleRequest with streaming callbacks + const requestId = uuidv4() + console.log(`[DEBUG] Streaming gRPC host call to ${service.fullName}.${methodKey} req:${requestId}`) + + // We need to await the promise and then return the cancel function + return (async () => { + try { + const result = await grpcHandler.handleRequest>( + service.fullName, + methodKey, + request, + requestId, + options, + ) + + // If the result is a function, it's the cancel function + if (typeof result === "function") { + return result + } else { + // This shouldn't happen, but just in case + console.error(`Expected cancel function but got response object for streaming request: ${requestId}`) + return () => {} + } + } catch (error) { + console.error(`Error in streaming request: ${error}`) + if (options.onError) { + options.onError(error instanceof Error ? error : new Error(String(error))) + } + return () => {} + } + })() + }) as any + } else { + // Unary method implementation + const methodKey = method.name.charAt(0).toLowerCase() + method.name.slice(1) + client[methodKey as keyof GrpcClientType] = ((request: any) => { + return new Promise(async (resolve, reject) => { + const requestId = uuidv4() + console.log(`[DEBUG] gRPC host call to ${service.fullName}.${methodKey} req:${requestId}`) + try { + const response = await grpcHandler.handleRequest(service.fullName, methodKey, request, requestId) + console.log(`[DEBUG] gRPC host resp to ${service.fullName}.${methodKey} req:${requestId}`) + + // Check if the response is a function (streaming) or an object (unary) + if (typeof response === "function") { + // This shouldn't happen for unary requests + throw new Error("Received streaming response for unary request") + } else if (response && response.message) { + resolve(response.message) + } else { + throw new Error("gRPC response didn't have a message") + } + } catch (e) { + console.log(`[DEBUG] gRPC host ERR to ${service.fullName}.${methodKey} req:${requestId} err:${e}`) + reject(e) + } + }) + }) as any + } + }) + + return client +}