Compare commits

...
Author SHA1 Message Date
pashpashpash 4e21a466ae sync cli cline core changes with main 2025-09-29 14:21:10 -07:00
13 changed files with 1288 additions and 290 deletions
+446 -220
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -409,14 +409,16 @@
"@vscode/test-cli": "^0.0.10",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.6.0",
"archiver": "^7.0.1",
"c8": "^10.1.3",
"chai": "^4.3.10",
"chalk": "5.6.2",
"esbuild": "^0.25.0",
"glob": "^11.0.3",
"grpc-tools": "^1.13.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"minimatch": "^3.0.3",
"minimatch": "^3.1.2",
"npm-run-all": "^4.1.5",
"prebuild-install": "^7.1.3",
"protoc-gen-ts": "^0.8.7",
@@ -424,6 +426,7 @@
"rimraf": "^6.0.1",
"should": "^13.2.3",
"sinon": "^19.0.2",
"tar": "^7.5.1",
"tree-kill": "^1.2.2",
"ts-node": "^10.9.2",
"ts-proto": "^2.6.1",
@@ -456,8 +459,8 @@
"@streamparser/json": "^0.0.22",
"@types/uuid": "^10.0.0",
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"axios": "^1.12.0",
"better-sqlite3": "^12.4.1",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"chrome-launcher": "^1.1.2",
+8
View File
@@ -19,6 +19,7 @@ service StateService {
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
rpc getProcessInfo(EmptyRequest) returns (ProcessInfo);
}
message DictationSettings {
bool feature_enabled = 1;
@@ -302,3 +303,10 @@ message Viewport {
message UpdateTerminalConnectionTimeoutResponse {
optional int32 timeout_ms = 1;
}
message ProcessInfo {
int32 process_id = 1;
optional string version = 2;
optional int64 uptime_ms = 3;
}
+3
View File
@@ -31,6 +31,9 @@ service EnvService {
// Returns events when the telemetry settings change.
rpc subscribeToTelemetrySettings(cline.EmptyRequest) returns (stream TelemetrySettingsEvent);
// Initiates a graceful shutdown of the host bridge service.
rpc shutdown(cline.EmptyRequest) returns (cline.Empty);
}
message GetHostVersionResponse {
@@ -0,0 +1,20 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { ProcessInfo } from "@shared/proto/cline/state"
import { Controller } from ".."
/**
* Gets process information including PID, version, and uptime
* @param controller The controller instance
* @param request Empty request
* @returns ProcessInfo with process details
*/
export async function getProcessInfo(controller: Controller, request: EmptyRequest): Promise<ProcessInfo> {
// Get the current state to access the version (same source as webview)
const state = await controller.getStateToPostToWebview()
return ProcessInfo.create({
processId: process.pid,
version: state.version || "unknown",
uptimeMs: Math.floor(process.uptime() * 1000), // Convert seconds to milliseconds
})
}
+229
View File
@@ -0,0 +1,229 @@
import Database from "better-sqlite3"
import { existsSync, mkdirSync, unlinkSync } from "fs"
import * as path from "path"
import type { InstanceLockData, SqliteLockManagerOptions } from "./types"
export class SqliteLockManager {
private db!: Database.Database
private instanceAddress: string
private dbPath: string
private readonly STALE_LOCK_TIMEOUT = 1 * 60 * 1000 // 1 minute in milliseconds
constructor(options: SqliteLockManagerOptions) {
this.instanceAddress = options.instanceAddress
this.dbPath = options.dbPath
// Ensure the directory exists before creating the database
const dbDir = path.dirname(this.dbPath)
try {
mkdirSync(dbDir, { recursive: true })
} catch (error) {
console.error(`CRITICAL ERROR: Failed to create SQLite database directory ${dbDir}:`, error)
throw new Error(`Failed to create SQLite database directory: ${error}`)
}
try {
this.initializeDatabaseWithLockSync()
} catch (error) {
console.error(`CRITICAL ERROR: Failed to initialize SQLite database at ${this.dbPath}:`, error)
throw new Error(`Failed to initialize SQLite database: ${error}`)
}
}
private initializeDatabaseWithLockSync(): void {
const lockFile = `${this.dbPath}.lock`
// Clean up stale lock files first
this.cleanupStaleLockSync(lockFile)
try {
// Try to acquire exclusive file lock for database creation
const fs = require("fs")
let fd: number | null = null
try {
fd = fs.openSync(lockFile, "wx") // Exclusive creation - fails if file exists
// Write timestamp to lock file for stale lock detection
fs.writeFileSync(fd, Date.now().toString())
// Check if database already exists
const dbExists = existsSync(this.dbPath)
if (!dbExists) {
// Database doesn't exist, create it
this.db = new Database(this.dbPath)
this.initializeDatabase()
} else {
// Database exists, just open it
this.db = new Database(this.dbPath)
}
} finally {
// Always clean up the lock file
if (fd !== null) {
fs.closeSync(fd)
}
try {
unlinkSync(lockFile)
} catch {} // Ignore errors if file was already deleted
}
} catch (error: any) {
if (error.code === "EEXIST") {
// Another process is initializing the database, wait and retry
const delay = 100 + Math.random() * 100 // Add jitter
this.sleepSync(delay)
this.initializeDatabaseWithLockSync()
return
}
throw error
}
}
private sleepSync(ms: number) {
// Non-spinning, synchronous sleep using Atomics.wait
// Works in Node main thread (since v12.16+) and worker threads.
const sab = new SharedArrayBuffer(4)
const ia = new Int32Array(sab)
Atomics.wait(ia, 0, 0, Math.max(0, Math.floor(ms)))
}
private cleanupStaleLockSync(lockFile: string): void {
try {
if (!existsSync(lockFile)) {
return // Lock file doesn't exist, nothing to clean up
}
const fs = require("fs")
try {
const timestampStr = fs.readFileSync(lockFile, "utf8").trim()
const timestamp = parseInt(timestampStr, 10)
if (isNaN(timestamp) || Date.now() - timestamp > this.STALE_LOCK_TIMEOUT) {
// Stale lock, remove it
unlinkSync(lockFile)
console.warn(`Removed stale database lock file: ${lockFile}`)
}
} catch (readError) {
// If we can't read the timestamp, assume it's stale
unlinkSync(lockFile)
console.warn(`Removed unreadable database lock file: ${lockFile}`)
}
} catch (error: any) {
if (error.code !== "ENOENT") {
// Lock file doesn't exist, which is fine
console.warn(`Error checking lock file ${lockFile}:`, error)
}
}
}
private initializeDatabase() {
// Create the locks table with the unified schema (matches cli/pkg/common/schema.go)
this.db.exec(`
CREATE TABLE IF NOT EXISTS locks (
id INTEGER PRIMARY KEY,
held_by TEXT NOT NULL,
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
lock_target TEXT NOT NULL,
locked_at INTEGER NOT NULL,
UNIQUE(lock_type, lock_target)
);
`)
// Create indexes for performance (matches cli/pkg/common/schema.go)
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
`)
}
/**
* Register this instance in the locks table
*/
async registerInstance(data: {
corePort: number
hostPort: number
version?: string
status?: InstanceLockData["status"]
}): Promise<void> {
const now = Date.now()
const hostAddress = `localhost:${data.hostPort}`
// Create instance lock entry
const insertLock = this.db.prepare(`
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
VALUES (?, 'instance', ?, ?)
`)
insertLock.run(this.instanceAddress, hostAddress, now)
}
/**
* Update the timestamp for this instance (touch)
*/
touchInstance(): void {
const now = Date.now()
const updateLock = this.db.prepare(`
UPDATE locks
SET locked_at = ?
WHERE held_by = ? AND lock_type = 'instance'
`)
updateLock.run(now, this.instanceAddress)
}
/**
* Remove this instance from the locks table
*/
unregisterInstance(): void {
const deleteLock = this.db.prepare(`
DELETE FROM locks
WHERE held_by = ? AND lock_type = 'instance'
`)
deleteLock.run(this.instanceAddress)
}
/**
* Query the registry for any instance registered on the given port
*/
getInstanceByPort(port: number): { instanceAddress: string; hostAddress: string } | null {
const query = this.db.prepare(`
SELECT held_by, lock_target
FROM locks
WHERE lock_type = 'instance'
AND (held_by LIKE '%:' || ? OR lock_target LIKE '%:' || ?)
`)
const result = query.get(port, port) as { held_by: string; lock_target: string } | undefined
if (result) {
return {
instanceAddress: result.held_by,
hostAddress: result.lock_target,
}
}
return null
}
/**
* Remove a specific instance entry from the registry
*/
removeInstanceByAddress(instanceAddress: string): void {
const deleteLock = this.db.prepare(`
DELETE FROM locks
WHERE held_by = ? AND lock_type = 'instance'
`)
deleteLock.run(instanceAddress)
}
/**
* Close the database connection
*/
close(): void {
this.db.close()
}
}
+28
View File
@@ -0,0 +1,28 @@
export type LockType = "file" | "instance" | "folder"
export type LockStatus = "starting" | "healthy" | "unhealthy"
export interface LockRow {
id: number
held_by: string // address:port of instance holding the lock
lock_type: LockType
lock_target: string // varies by type: file path, host address, or folder path
locked_at: number // timestamp when lock was acquired
}
export interface InstanceLockData {
address: string
core_port: number
host_port: number
status: LockStatus
last_seen: string
process_pid: number
version?: string
created_at: string
metadata?: Record<string, any>
}
export interface SqliteLockManagerOptions {
dbPath: string
instanceAddress: string // host:port format
}
+240 -18
View File
@@ -2,37 +2,200 @@ import { ExternalDiffViewProvider } from "@hosts/external/ExternalDiffviewProvid
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
import { WebviewProviderType } from "@shared/webview/types"
import * as path from "path"
import { retryOperation } from "@utils/retry"
import os from "os"
import path from "path"
import { initialize, tearDown } from "@/common"
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
import { WebviewProvider } from "@/core/webview"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { waitForHostBridgeReady } from "./hostbridge-client"
import { startProtobusService } from "./protobus-service"
import { log } from "./utils"
import { DATA_DIR, EXTENSION_DIR, extensionContext } from "./vscode-context"
import { checkPortAvailability } from "./port-checker"
import { startProtobusService, waitForHostBridgeReady } from "./protobus-service"
import { log, SETTINGS_SUBFOLDER } from "./utils"
import { createExtensionContext } from "./vscode-context"
// Default ports
export const DEFAULT_PROTOBUS_PORT = 26040
export const DEFAULT_HOSTBRIDGE_PORT = 26041
// Parse command line arguments
interface CliArgs {
port?: number
hostBridgePort?: number
config?: string
help?: boolean
}
function parseArgs(): CliArgs {
const args: CliArgs = {}
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
switch (arg) {
case "--port":
case "-p":
args.port = parseInt(argv[++i], 10)
break
case "--host-bridge-port":
args.hostBridgePort = parseInt(argv[++i], 10)
break
case "--config":
case "-c":
args.config = argv[++i]
break
case "--help":
case "-h":
args.help = true
break
}
}
return args
}
function showHelp() {
console.log(`
Cline Core - Standalone Server
Usage: node cline-core.js [options]
Options:
-p, --port <port> Port for the main gRPC service (default: ${DEFAULT_PROTOBUS_PORT})
--host-bridge-port <port> Port for the host bridge service (default: ${DEFAULT_HOSTBRIDGE_PORT})
-c, --config <path> Directory for Cline data storage (default: ~/.cline or CLINE_DIR env var)
-h, --help Show this help message
Environment Variables:
PROTOBUS_ADDRESS Override the main service address (format: host:port)
HOSTBRIDGE_ADDRESS Override the host bridge address (format: host:port)
CLINE_DIR Default Cline data directory (overridden by --config flag)
`)
}
async function main() {
// Parse command line arguments
const args = parseArgs()
// Show help if requested
if (args.help) {
showHelp()
process.exit(0)
}
// Configure ports from arguments or env vars
let protobusPort = DEFAULT_PROTOBUS_PORT
let hostBridgePort = DEFAULT_HOSTBRIDGE_PORT
if (args.port) {
protobusPort = args.port
// If only port is specified, calculate hostbridge port as port + 1000
if (!args.hostBridgePort) {
hostBridgePort = protobusPort + 1000
}
}
if (args.hostBridgePort) {
hostBridgePort = args.hostBridgePort
}
// Set environment variables for the services to use
if (!process.env.PROTOBUS_ADDRESS) {
process.env.PROTOBUS_ADDRESS = `localhost:${protobusPort}`
}
if (!process.env.HOSTBRIDGE_ADDRESS) {
process.env.HOSTBRIDGE_ADDRESS = `localhost:${hostBridgePort}`
}
// Configure Cline directory from CLI args, env var, or default
// Priority: --config flag > CLINE_DIR env var > ~/.cline default
const clineDir = args.config || process.env.CLINE_DIR || `${os.homedir()}/.cline`
log("\n\n\nStarting cline-core service...\n\n\n")
log(`Using Protobus port: ${protobusPort}`)
log(`Using Host Bridge port: ${hostBridgePort}`)
log(`Using Cline directory: ${clineDir}`)
await waitForHostBridgeReady()
// Initialize SQLite lock manager for instance registration
const dbPath = `${clineDir}/${SETTINGS_SUBFOLDER}/locks.db`
// Use host:port everywhere (no scheme)
const fullAddress = `localhost:${protobusPort}`
let lockManager: SqliteLockManager | undefined
try {
lockManager = new SqliteLockManager({
dbPath,
instanceAddress: fullAddress,
})
// The host bridge should be available before creating the host provider because it depends on the host bridge.
setupHostProvider()
// Check port availability before proceeding
log(`Checking port availability for ${protobusPort}...`)
const portCheck = await checkPortAvailability(protobusPort, lockManager)
if (!portCheck.canProceed) {
log(`STARTUP BLOCKED: ${portCheck.error}`)
lockManager.close()
process.exit(1)
}
await lockManager.registerInstance({
corePort: protobusPort,
hostPort: hostBridgePort,
version: process.env.CLINE_VERSION,
status: "starting",
})
log(`Registered instance in SQLite locks: ${fullAddress}`)
} catch (err) {
log(`CRITICAL ERROR: Failed to register instance in SQLite locks: ${String(err)}`)
log(`This is a fatal error - cline-core cannot start without proper instance registration`)
if (lockManager) {
try {
lockManager.close()
} catch {}
}
process.exit(1)
}
try {
await waitForHostBridgeReady()
log("HostBridge is serving; continuing startup")
} catch (err) {
log(`ERROR: HostBridge error: ${String(err)}`)
// Cleanup lock manager entry if startup fails
if (lockManager) {
try {
lockManager.unregisterInstance()
lockManager.close()
} catch {}
}
process.exit(1)
}
// Create extension context with the configured directory
const extensionContext = createExtensionContext(clineDir)
// Get EXTENSION_DIR and DATA_DIR from the extension context for use by HostProvider
const EXTENSION_DIR = extensionContext.extensionPath
const DATA_DIR = extensionContext.globalStoragePath
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR)
// Set up global error handlers to prevent process crashes
setupGlobalErrorHandlers()
setupGlobalErrorHandlers(lockManager)
const webviewProvider = await initialize(extensionContext)
// Enable the localhost HTTP server that handles auth redirects.
AuthHandler.getInstance().setEnabled(true)
startProtobusService(webviewProvider.controller)
// Mark instance healthy after services are up
try {
lockManager?.touchInstance()
} catch {}
}
function setupHostProvider() {
function setupHostProvider(extensionContext: any, extensionDir: string, dataDir: string) {
const createWebview = (_: WebviewProviderType): WebviewProvider => {
return new ExternalWebviewProvider(extensionContext, WebviewProviderType.SIDEBAR)
}
@@ -52,8 +215,8 @@ function setupHostProvider() {
log,
getCallbackUrl,
getBinaryLocation,
EXTENSION_DIR,
DATA_DIR,
extensionDir,
dataDir,
)
}
@@ -61,7 +224,7 @@ function setupHostProvider() {
* Sets up global error handlers to prevent the process from crashing
* on unhandled exceptions and promise rejections
*/
function setupGlobalErrorHandlers() {
function setupGlobalErrorHandlers(lockManager?: SqliteLockManager) {
// Handle unhandled exceptions
process.on("uncaughtException", (error: Error) => {
log(`ERROR: Uncaught exception: ${error.message}`)
@@ -86,15 +249,74 @@ function setupGlobalErrorHandlers() {
// Graceful shutdown handlers
process.on("SIGINT", () => {
log("Received SIGINT, shutting down gracefully...")
process.exit(0)
shutdownGracefully(lockManager)
})
process.on("SIGTERM", () => {
log("Received SIGTERM, shutting down gracefully...")
tearDown()
process.exit(0)
shutdownGracefully(lockManager)
})
}
/**
* Request host bridge shutdown with retry logic and timeout handling.
* Uses best-effort approach - logs failures but doesn't block shutdown.
*/
async function requestHostBridgeShutdown(): Promise<void> {
try {
await retryOperation(3, 2000, async () => {
await HostProvider.env.shutdown({})
})
log("Host bridge shutdown requested successfully")
} catch (error) {
log(`Warning: Failed to request host bridge shutdown: ${error}`)
log("Proceeding with cleanup")
}
}
/**
* Gracefully shutdown the cline-core process by:
* 1. Calling shutdown RPC on the paired host bridge
* 2. Cleaning up the lock manager entry
* 3. Tearing down services
* 4. Exiting the process
*/
async function shutdownGracefully(lockManager?: SqliteLockManager) {
try {
// Step 1: Tell the paired host bridge to shut down
log("Requesting host bridge shutdown...")
if (HostProvider.isInitialized()) {
await requestHostBridgeShutdown()
} else {
log("Warning: HostProvider not initialized, cannot request shutdown")
}
// Step 2: Clean up lock manager entry
log("Cleaning up lock manager entry...")
try {
lockManager?.unregisterInstance()
lockManager?.close()
log("Lock manager entry cleaned up successfully")
} catch (error) {
log(`Warning: Failed to clean up lock manager: ${error}`)
}
// Step 3: Tear down services
log("Tearing down services...")
try {
tearDown()
log("Services torn down successfully")
} catch (error) {
log(`Warning: Failed to tear down services: ${error}`)
}
log("Graceful shutdown completed")
} catch (error) {
log(`Error during graceful shutdown: ${error}`)
} finally {
// Step 4: Exit the process
process.exit(0)
}
}
main()
+171
View File
@@ -0,0 +1,171 @@
import * as grpc from "@grpc/grpc-js"
import * as protoLoader from "@grpc/proto-loader"
import * as health from "grpc-health-check"
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
import { log } from "./utils"
const SERVING_STATUS = 1
interface PortCheckResult {
canProceed: boolean
error?: string
}
interface RegistryEntry {
instanceAddress: string
hostAddress: string
}
/**
* Creates a gRPC health client for the given address
*/
function createHealthClient(address: string): any {
const healthDef = protoLoader.loadSync(health.protoPath)
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
const Health = grpcObj.grpc.health.v1.Health
return new Health(address, grpc.credentials.createInsecure())
}
/**
* Performs a single health check on the given address
*/
async function checkHealthOnce(address: string): Promise<{ success: boolean; status?: number; error?: Error }> {
const client = createHealthClient(address)
return new Promise((resolve) => {
const timeout = setTimeout(() => {
try {
client.close?.()
} catch {}
resolve({ success: false, error: new Error("Health check timeout") })
}, 5000) // 5 second timeout
client.check({ service: "" }, (err: unknown, resp: any) => {
clearTimeout(timeout)
try {
client.close?.()
} catch {}
if (err) {
resolve({ success: false, error: err as Error })
} else {
resolve({ success: true, status: resp?.status })
}
})
})
}
/**
* Attempts to shut down a host bridge instance
*/
async function shutdownHostBridge(hostAddress: string): Promise<boolean> {
try {
log(`Attempting to shutdown host bridge at ${hostAddress}`)
// This would need to be implemented - we need a way to send shutdown to a specific host
// For now, we'll just log that we would do this
log(`Would send shutdown command to host bridge at ${hostAddress}`)
return true
} catch (error) {
log(`Failed to shutdown host bridge at ${hostAddress}: ${error}`)
return false
}
}
/**
* Checks if a port is available for binding, following the registry-first approach
*/
export async function checkPortAvailability(port: number, lockManager: SqliteLockManager): Promise<PortCheckResult> {
log(`Checking port availability for port ${port}`)
// Step 1: Check registry first
const registryEntry = lockManager.getInstanceByPort(port)
if (!registryEntry) {
log(`No registry entry found for port ${port}, free to bind`)
return { canProceed: true }
}
log(`Found registry entry for port ${port}: instance=${registryEntry.instanceAddress}, host=${registryEntry.hostAddress}`)
// Step 2: Perform health check on the registered instance
const coreAddress = registryEntry.instanceAddress
const performHealthCheck = async (): Promise<{ success: boolean; status?: number; error?: Error }> => {
return await checkHealthOnce(coreAddress)
}
// First health check attempt
let healthResult = await performHealthCheck()
if (!healthResult.success) {
// Health check ERROR - not our process
log(`Health check failed for ${coreAddress}: ${healthResult.error?.message}`)
log(`This indicates a non-Cline process is using port ${port}`)
// Attempt to shutdown the registered host bridge
const shutdownSuccess = await shutdownHostBridge(registryEntry.hostAddress)
if (shutdownSuccess) {
log(`Successfully requested shutdown of host bridge ${registryEntry.hostAddress}`)
}
// Remove from registry
lockManager.removeInstanceByAddress(registryEntry.instanceAddress)
log(`Removed stale registry entry for ${registryEntry.instanceAddress}`)
return {
canProceed: false,
error: `Port ${port} is occupied by a non-Cline process. Registry has been cleaned up.`,
}
}
// Health check succeeded - it's our process
if (healthResult.status === SERVING_STATUS) {
// Healthy Cline instance already running
log(`Healthy Cline instance already running on port ${port}`)
return {
canProceed: false,
error: `A healthy Cline instance is already running on port ${port}`,
}
}
// Health check succeeded but status is not SERVING - unhealthy Cline instance
log(`Unhealthy Cline instance detected on port ${port} (status: ${healthResult.status}), retrying in 1 second`)
// Wait 1 second and retry
await new Promise((resolve) => setTimeout(resolve, 1000))
// Second health check attempt
healthResult = await performHealthCheck()
if (!healthResult.success) {
// Now it's erroring - something changed
log(`Health check now failing after retry for ${coreAddress}: ${healthResult.error?.message}`)
// Clean up registry since the instance is no longer responding
lockManager.removeInstanceByAddress(registryEntry.instanceAddress)
log(`Removed non-responsive registry entry for ${registryEntry.instanceAddress}`)
return {
canProceed: false,
error: `Port ${port} had an unhealthy Cline instance that is now non-responsive. Registry cleaned up.`,
}
}
if (healthResult.status === SERVING_STATUS) {
// Instance recovered
log(`Cline instance on port ${port} has recovered and is now healthy`)
return {
canProceed: false,
error: `Cline instance on port ${port} has recovered and is now serving`,
}
}
// Still unhealthy after retry
log(`Cline instance on port ${port} remains unhealthy after retry (status: ${healthResult.status})`)
return {
canProceed: false,
error: `Cline instance on port ${port} is unhealthy and did not recover after retry`,
}
}
+47 -3
View File
@@ -2,13 +2,14 @@ import { Controller } from "@core/controller"
import { StreamingResponseHandler } from "@core/controller/grpc-handler"
import { addProtobusServices } from "@generated/hosts/standalone/protobus-server-setup"
import * as grpc from "@grpc/grpc-js"
import * as protoLoader from "@grpc/proto-loader"
import { ReflectionService } from "@grpc/reflection"
import { GrpcHandler, GrpcStreamingResponseHandler } from "@hosts/external/grpc-types"
import * as health from "grpc-health-check"
import { getPackageDefinition, log } from "./utils"
export const PROTOBUS_PORT = 26040
export const DEFAULT_PROTOBUS_PORT = 26040
export const DEFAULT_HOSTBRIDGE_PORT = 26041
export function startProtobusService(controller: Controller) {
const server = new grpc.Server()
@@ -28,7 +29,7 @@ export function startProtobusService(controller: Controller) {
reflection.addToServer(server)
// Start the server.
const host = process.env.PROTOBUS_ADDRESS || `127.0.0.1:${PROTOBUS_PORT}`
const host = process.env.PROTOBUS_ADDRESS || `127.0.0.1:${DEFAULT_PROTOBUS_PORT}`
server.bindAsync(host, grpc.ServerCredentials.createInsecure(), (err) => {
if (err) {
log(`Could not start ProtoBus service: Failed to bind to ${host}, port may be unavailable. ${err.message}`)
@@ -109,3 +110,46 @@ function wrapStreamingResponseHandler<TRequest, TResponse>(
}
}
}
// Client-side health check for the hostbridge service (kept at bottom for clarity)
const SERVING_STATUS = 1
function createHealthClient(address?: string) {
const healthDef = protoLoader.loadSync(health.protoPath)
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
const Health = grpcObj.grpc.health.v1.Health
const target = address || process.env.HOSTBRIDGE_ADDRESS || `localhost:${DEFAULT_HOSTBRIDGE_PORT}`
return new Health(target, grpc.credentials.createInsecure())
}
async function checkHealthOnce(client: any): Promise<boolean> {
return new Promise<boolean>((resolve) => {
client.check({ service: "" }, (err: unknown, resp: any) => {
if (err) {
return resolve(false)
}
return resolve(resp?.status === SERVING_STATUS)
})
})
}
export async function waitForHostBridgeReady(timeoutMs = 60000, intervalMs = 500, address?: string): Promise<void> {
const client = createHealthClient(address)
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
const ok = await checkHealthOnce(client)
if (ok) {
try {
client.close?.()
} catch {}
return
}
log("Waiting for hostbridge to be ready...")
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, intervalMs))
}
try {
client.close?.()
} catch {}
throw new Error("HostBridge health check timed out")
}
+4 -1
View File
@@ -3,6 +3,9 @@ import * as fs from "fs"
import * as health from "grpc-health-check"
import { StreamingCallbacks } from "@/hosts/host-provider-types"
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
const SETTINGS_SUBFOLDER = "data"
const log = (...args: unknown[]) => {
const now = new Date()
const year = now.getFullYear()
@@ -51,4 +54,4 @@ async function asyncIteratorToCallbacks<T>(stream: AsyncIterable<T>, callbacks:
}
}
export { getPackageDefinition, log, asyncIteratorToCallbacks }
export { getPackageDefinition, log, asyncIteratorToCallbacks, SETTINGS_SUBFOLDER }
+58 -46
View File
@@ -8,62 +8,74 @@ import { ExtensionRegistryInfo } from "@/registry"
import { log } from "./utils"
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
log("Running standalone cline", ExtensionRegistryInfo.version)
log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
export const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
export const DATA_DIR = path.join(CLINE_DIR, "data")
const INSTALL_DIR = process.env.INSTALL_DIR || __dirname
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspace")
mkdirSync(DATA_DIR, { recursive: true })
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
log("Using settings dir:", DATA_DIR)
export const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
const extension: Extension<void> = {
id: ExtensionRegistryInfo.id,
isActive: true,
extensionPath: EXTENSION_DIR,
extensionUri: URI.file(EXTENSION_DIR),
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
exports: undefined, // There are no API exports in the standalone version.
activate: async () => {},
extensionKind: ExtensionKind.UI,
function getPackageVersion(): string {
// Use build-time injected version (only method)
return process.env.CLINE_VERSION || "unknown" // todo: sarah wanted to change the way we get the extension version
}
const extensionContext: ExtensionContext = {
extension: extension,
extensionMode: EXTENSION_MODE,
const VERSION = getPackageVersion() // todo: sarah wanted to change the way we get the extension version
log("Running standalone cline ", VERSION)
// Set up KV stores.
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
// Set up URIs.
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
storagePath: WORKSPACE_STORAGE_DIR, // Deprecated, not used in cline.
globalStorageUri: URI.file(DATA_DIR),
globalStoragePath: DATA_DIR, // Deprecated, not used in cline.
// Accept clineDir parameter, but fall back to env variable, then default
function createExtensionContext(clineDir?: string): ExtensionContext {
const CLINE_DIR = clineDir || process.env.CLINE_DIR || `${os.homedir()}/.cline`
const DATA_DIR = path.join(CLINE_DIR, "data")
const INSTALL_DIR = process.env.INSTALL_DIR || __dirname
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspace")
// Logs are global per extension, not per workspace.
logUri: URI.file(DATA_DIR),
logPath: DATA_DIR, // Deprecated, not used in cline.
mkdirSync(DATA_DIR, { recursive: true })
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
log("Using settings dir:", DATA_DIR)
extensionUri: URI.file(EXTENSION_DIR),
extensionPath: EXTENSION_DIR, // Deprecated, not used in cline.
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
subscriptions: [], // These need to be destroyed when the extension is deactivated.
const extension: Extension<void> = {
id: ExtensionRegistryInfo.id,
isActive: true,
extensionPath: EXTENSION_DIR,
extensionUri: URI.file(EXTENSION_DIR),
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
exports: undefined,
activate: async () => {},
extensionKind: ExtensionKind.UI,
}
environmentVariableCollection: new EnvironmentVariableCollection(),
const extensionContext: ExtensionContext = {
extension: extension,
extensionMode: EXTENSION_MODE,
// Workspace state is per project/workspace when WORKSPACE_STORAGE_DIR is provided by the host.
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
// Set up KV stores.
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
// Set up URIs.
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
storagePath: WORKSPACE_STORAGE_DIR,
globalStorageUri: URI.file(DATA_DIR),
globalStoragePath: DATA_DIR,
// Logs are global per extension, not per workspace.
logUri: URI.file(DATA_DIR),
logPath: DATA_DIR,
extensionUri: URI.file(EXTENSION_DIR),
extensionPath: EXTENSION_DIR,
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
subscriptions: [],
environmentVariableCollection: new EnvironmentVariableCollection(),
// Workspace state is per project/workspace when WORKSPACE_STORAGE_DIR is provided by the host.
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
}
return extensionContext
}
console.log("Finished loading vscode context...")
export { extensionContext }
export { createExtensionContext }
+29
View File
@@ -0,0 +1,29 @@
/**
* TypeScript equivalent of the Go common.RetryOperation utility
* Performs an operation with retry logic and timeout handling
*/
export async function retryOperation<T>(maxRetries: number, timeoutPerAttempt: number, operation: () => Promise<T>): Promise<T> {
let lastError: Error | undefined
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Create a timeout promise
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Operation timeout")), timeoutPerAttempt),
)
// Race the operation against timeout
const result = await Promise.race([operation(), timeoutPromise])
return result // Success - return result
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (attempt < maxRetries) {
// Brief delay before retry
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
}
throw new Error(`Operation failed after ${maxRetries} attempts: ${lastError?.message}`)
}