mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
wip(jetbrains): add generated API client, dual HTTP clients, and project-scoped RPC
- Generate Kotlin API client from the same OpenAPI spec as the VS Code extension using openapi-generator with jvm-okhttp4 + Moshi (bundled, no platform deps) - Separate health checks (3s timeout) from API/SSE calls (no timeout) using two OkHttp clients, matching the VS Code extension's connection architecture - Use ProjectId for all frontend↔backend RPC calls so the backend resolves the correct project service without scanning ProjectManager (JetBrains template pattern) - Add KiloProjectService as project-level backend service scoping CLI API calls via the project's working directory - Log CLI binary extraction path and startup command for easier debugging
This commit is contained in:
@@ -2,18 +2,88 @@ plugins {
|
||||
alias(libs.plugins.rpc)
|
||||
alias(libs.plugins.kotlin)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.openapi.generator)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin")
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
resources.srcDir(layout.buildDirectory.dir("generated/cli"))
|
||||
kotlin.srcDir(generatedApi)
|
||||
}
|
||||
}
|
||||
|
||||
openApiGenerate {
|
||||
generatorName.set("kotlin")
|
||||
library.set("jvm-okhttp4")
|
||||
inputSpec.set("${rootDir}/../sdk/openapi.json")
|
||||
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.absolutePath)
|
||||
packageName.set("ai.kilocode.jetbrains.api")
|
||||
apiPackage.set("ai.kilocode.jetbrains.api.client")
|
||||
modelPackage.set("ai.kilocode.jetbrains.api.model")
|
||||
configOptions.set(mapOf(
|
||||
"serializationLibrary" to "moshi",
|
||||
"omitGradleWrapper" to "true",
|
||||
"omitGradlePluginVersions" to "true",
|
||||
"useCoroutines" to "false",
|
||||
"sourceFolder" to "src/main/kotlin",
|
||||
"enumPropertyNaming" to "UPPERCASE",
|
||||
))
|
||||
// Remap schema "File" so the generated class is not named java.io.File
|
||||
modelNameMappings.set(mapOf(
|
||||
"File" to "DiffFileInfo",
|
||||
))
|
||||
// Map empty anyOf references to kotlin.Any
|
||||
typeMappings.set(mapOf(
|
||||
"AnyOfLessThanGreaterThan" to "kotlin.Any",
|
||||
"anyOf<>" to "kotlin.Any",
|
||||
))
|
||||
// Normalise OpenAPI 3.1 → 3.0-compatible patterns
|
||||
openapiNormalizer.set(mapOf(
|
||||
"SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true",
|
||||
"SIMPLIFY_ONEOF_ANYOF" to "true",
|
||||
))
|
||||
generateApiTests.set(false)
|
||||
generateModelTests.set(false)
|
||||
generateApiDocumentation.set(false)
|
||||
generateModelDocumentation.set(false)
|
||||
}
|
||||
|
||||
// Fix openapi-generator 3.1.1 codegen bugs in generated Kotlin sources.
|
||||
// - Boolean const enums: `enum class Foo(val value: kotlin.Boolean) { TRUE("true") }` → fix string→boolean
|
||||
val fixGeneratedApi by tasks.registering {
|
||||
dependsOn("openApiGenerate")
|
||||
val dir = generatedApi
|
||||
doLast {
|
||||
dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
|
||||
var text = file.readText()
|
||||
var changed = false
|
||||
// Fix: enum Xxx(val value: kotlin.Boolean) { @Json(name = "true") TRUE("true") }
|
||||
// → enum Xxx(val value: kotlin.Boolean) { @Json(name = "true") TRUE(true) }
|
||||
val boolEnum = Regex(
|
||||
"""(enum class \w+\(val value: kotlin\.Boolean\) \{[^}]*?@Json\(name = ")(true|false)("\) \w+\()"(true|false)"(\))"""
|
||||
)
|
||||
val replaced = boolEnum.replace(text) { m ->
|
||||
"${m.groupValues[1]}${m.groupValues[2]}${m.groupValues[3]}${m.groupValues[4]}${m.groupValues[5]}"
|
||||
}
|
||||
if (replaced != text) {
|
||||
text = replaced
|
||||
changed = true
|
||||
}
|
||||
if (changed) file.writeText(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("compileKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
}
|
||||
|
||||
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
|
||||
val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false)
|
||||
|
||||
@@ -71,4 +141,6 @@ dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.sse)
|
||||
implementation(libs.moshi)
|
||||
implementation(libs.moshi.kotlin)
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.server.KiloConnectionService
|
||||
import com.intellij.openapi.components.service
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class KiloBackendRpcApi : KiloRpcApi {
|
||||
override suspend fun connect() {
|
||||
service<KiloConnectionService>().connect()
|
||||
}
|
||||
|
||||
override suspend fun state(): Flow<ConnectionStateDto> {
|
||||
return service<KiloConnectionService>().stream()
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import ai.kilocode.server.KiloProjectService
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.platform.project.ProjectId
|
||||
import com.intellij.platform.project.findProjectOrNull
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
/**
|
||||
* Backend implementation of [KiloProjectRpcApi].
|
||||
*
|
||||
* Resolves the project from the [ProjectId] passed by the frontend
|
||||
* and delegates to the project-level [KiloProjectService].
|
||||
*/
|
||||
class KiloProjectRpcApiImpl : KiloProjectRpcApi {
|
||||
|
||||
private fun resolve(id: ProjectId): KiloProjectService {
|
||||
val project = id.findProjectOrNull()
|
||||
?: throw IllegalStateException("Project not found for id: $id")
|
||||
return project.service()
|
||||
}
|
||||
|
||||
override suspend fun connect(projectId: ProjectId) =
|
||||
resolve(projectId).connect()
|
||||
|
||||
override suspend fun state(projectId: ProjectId): Flow<ConnectionStateDto> =
|
||||
resolve(projectId).stream()
|
||||
|
||||
override suspend fun health(projectId: ProjectId): HealthDto =
|
||||
resolve(projectId).health()
|
||||
}
|
||||
+3
-3
@@ -5,10 +5,10 @@ package ai.kilocode.rpc
|
||||
import com.intellij.platform.rpc.backend.RemoteApiProvider
|
||||
import fleet.rpc.remoteApiDescriptor
|
||||
|
||||
internal class KiloBackendRpcApiProvider : RemoteApiProvider {
|
||||
internal class KiloProjectRpcApiProvider : RemoteApiProvider {
|
||||
override fun RemoteApiProvider.Sink.remoteApis() {
|
||||
remoteApi(remoteApiDescriptor<KiloRpcApi>()) {
|
||||
KiloBackendRpcApi()
|
||||
remoteApi(remoteApiDescriptor<KiloProjectRpcApi>()) {
|
||||
KiloProjectRpcApiImpl()
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-33
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.server
|
||||
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import com.intellij.openapi.Disposable
|
||||
@@ -17,16 +18,15 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
@@ -40,6 +40,16 @@ sealed class ConnectionState {
|
||||
|
||||
data class SseEvent(val type: String, val data: String)
|
||||
|
||||
/**
|
||||
* App-level service managing the CLI server connection.
|
||||
*
|
||||
* Uses two separate OkHttp clients mirroring the VS Code architecture:
|
||||
* - [apiClient]: no call/read timeout — used for the generated API client and SSE
|
||||
* - [healthClient]: 3 s timeout — used only for `/global/health` polling
|
||||
*
|
||||
* The generated [DefaultApi] is configured with [apiClient] and exposed via [api]
|
||||
* for typed access to all CLI server endpoints.
|
||||
*/
|
||||
@Service(Service.Level.APP)
|
||||
class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
@@ -47,7 +57,6 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
private val LOG = Logger.getInstance(KiloConnectionService::class.java)
|
||||
private const val HEARTBEAT_TIMEOUT_MS = 15_000L
|
||||
private const val HEALTH_POLL_INTERVAL_MS = 10_000L
|
||||
private const val HEALTH_TIMEOUT_MS = 3_000L
|
||||
private const val RECONNECT_DELAY_MS = 250L
|
||||
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
|
||||
}
|
||||
@@ -58,7 +67,12 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
private val _events = MutableSharedFlow<SseEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<SseEvent> = _events.asSharedFlow()
|
||||
|
||||
private var client: OkHttpClient? = null
|
||||
/** Generated API client — null when disconnected. */
|
||||
var api: DefaultApi? = null
|
||||
private set
|
||||
|
||||
private var apiClient: OkHttpClient? = null
|
||||
private var healthClient: OkHttpClient? = null
|
||||
private var port = 0
|
||||
private var password = ""
|
||||
|
||||
@@ -73,7 +87,6 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
suspend fun connect() {
|
||||
if (_state.value is ConnectionState.Connected || _state.value is ConnectionState.Connecting) return
|
||||
|
||||
open()
|
||||
}
|
||||
|
||||
@@ -82,32 +95,29 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
close()
|
||||
processJob?.cancel()
|
||||
healthJob?.cancel()
|
||||
|
||||
|
||||
setState(ConnectionState.Connecting)
|
||||
|
||||
val cli = service<ServerManager>()
|
||||
val processState = cli.init()
|
||||
val result = cli.init()
|
||||
|
||||
if (processState is ServerManager.ServerState.Error) {
|
||||
setState(ConnectionState.Error(processState.message))
|
||||
if (result is ServerManager.ServerState.Error) {
|
||||
setState(ConnectionState.Error(result.message))
|
||||
return
|
||||
}
|
||||
|
||||
val ready = processState as ServerManager.ServerState.Ready
|
||||
val ready = result as ServerManager.ServerState.Ready
|
||||
port = ready.port
|
||||
password = ready.password
|
||||
|
||||
client = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val auth = Base64.getEncoder().encodeToString("kilo:$password".toByteArray())
|
||||
chain.proceed(
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", "Basic $auth")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.callTimeout(HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
// Create dual OkHttp clients (bundled — no IntelliJ platform deps)
|
||||
val ac = KiloHttpClients.api(password)
|
||||
val hc = KiloHttpClients.health(password)
|
||||
apiClient = ac
|
||||
healthClient = hc
|
||||
|
||||
// Configure generated API client with the no-timeout api client
|
||||
api = DefaultApi(basePath = "http://127.0.0.1:$port", client = ac)
|
||||
|
||||
startSse()
|
||||
startHeartbeatWatcher()
|
||||
@@ -118,7 +128,7 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
}
|
||||
|
||||
private fun startSse() {
|
||||
val http = client ?: return
|
||||
val http = apiClient ?: return
|
||||
val request = Request.Builder()
|
||||
.url("http://127.0.0.1:$port/global/event")
|
||||
.header("Accept", "text/event-stream")
|
||||
@@ -143,8 +153,8 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
override fun onEvent(src: EventSource, id: String?, type: String?, data: String) {
|
||||
lastEvent.set(System.currentTimeMillis())
|
||||
val eventType = type ?: extractType(data)
|
||||
cs.launch { _events.emit(SseEvent(type = eventType, data = data)) }
|
||||
val kind = type ?: extractType(data)
|
||||
cs.launch { _events.emit(SseEvent(type = kind, data = data)) }
|
||||
}
|
||||
|
||||
override fun onClosed(src: EventSource) {
|
||||
@@ -215,7 +225,7 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
}
|
||||
|
||||
private fun checkHealth(): Boolean {
|
||||
val http = client ?: return false
|
||||
val http = healthClient ?: return false
|
||||
return try {
|
||||
val req = Request.Builder()
|
||||
.url("http://127.0.0.1:$port/global/health")
|
||||
@@ -238,25 +248,24 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
|
||||
}
|
||||
|
||||
private fun close() {
|
||||
client?.let { http ->
|
||||
http.dispatcher.executorService.shutdown()
|
||||
http.connectionPool.evictAll()
|
||||
}
|
||||
client = null
|
||||
api = null
|
||||
apiClient?.let { KiloHttpClients.shutdown(it) }
|
||||
apiClient = null
|
||||
healthClient?.let { KiloHttpClients.shutdown(it) }
|
||||
healthClient = null
|
||||
}
|
||||
|
||||
private fun setState(next: ConnectionState) {
|
||||
_state.value = next
|
||||
}
|
||||
|
||||
private fun dto(state: ConnectionState): ConnectionStateDto {
|
||||
return when (state) {
|
||||
private fun dto(state: ConnectionState): ConnectionStateDto =
|
||||
when (state) {
|
||||
ConnectionState.Disconnected -> ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
|
||||
ConnectionState.Connecting -> ConnectionStateDto(ConnectionStatusDto.CONNECTING)
|
||||
is ConnectionState.Connected -> ConnectionStateDto(ConnectionStatusDto.CONNECTED)
|
||||
is ConnectionState.Error -> ConnectionStateDto(ConnectionStatusDto.ERROR, state.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractType(data: String): String =
|
||||
TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package ai.kilocode.server
|
||||
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Factory for the two OkHttp clients used by the plugin.
|
||||
*
|
||||
* Mirrors the VS Code architecture:
|
||||
* - [api] client has no call/read timeout (streaming ops like prompt/SSE can run long)
|
||||
* - [health] client has a short 3 s timeout and a small dedicated connection pool
|
||||
*
|
||||
* Both clients bundle Basic Auth via an interceptor and are fully independent
|
||||
* of any IntelliJ-platform-provided HTTP stack.
|
||||
*/
|
||||
object KiloHttpClients {
|
||||
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000L
|
||||
private const val HEALTH_TIMEOUT_MS = 3_000L
|
||||
|
||||
/** API client — no call/read timeout (SSE and long-running ops). */
|
||||
fun api(password: String): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(auth(password))
|
||||
.connectTimeout(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Health client — short timeout, dedicated connection pool. */
|
||||
fun health(password: String): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(auth(password))
|
||||
.connectTimeout(HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.connectionPool(ConnectionPool(1, 30, TimeUnit.SECONDS))
|
||||
.build()
|
||||
|
||||
/** Shut down both dispatcher and connection pool for the given client. */
|
||||
fun shutdown(client: OkHttpClient) {
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
private fun auth(password: String): Interceptor {
|
||||
val header = "Basic ${Base64.getEncoder().encodeToString("kilo:$password".toByteArray())}"
|
||||
return Interceptor { chain ->
|
||||
chain.proceed(
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", header)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package ai.kilocode.server
|
||||
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Project-level backend service that delegates to the app-level
|
||||
* [KiloConnectionService] and scopes CLI API calls to this project's
|
||||
* working directory.
|
||||
*
|
||||
* The VS Code extension likewise scopes calls via `x-kilo-directory`.
|
||||
* In the JetBrains plugin this is achieved by passing the directory
|
||||
* parameter to each generated API method.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
class KiloProjectService(
|
||||
private val project: Project,
|
||||
private val cs: CoroutineScope,
|
||||
) {
|
||||
private val connection: KiloConnectionService
|
||||
get() = service()
|
||||
|
||||
/** Project working directory sent as the `directory` parameter. */
|
||||
val directory: String
|
||||
get() = project.basePath ?: ""
|
||||
|
||||
/** Connection state (delegates to app-level service). */
|
||||
val state: StateFlow<ConnectionState>
|
||||
get() = connection.state
|
||||
|
||||
/** Connection state mapped to DTO for RPC transport. */
|
||||
fun stream() = connection.stream()
|
||||
|
||||
/** Ensure the CLI backend is running and connected. */
|
||||
suspend fun connect() = connection.connect()
|
||||
|
||||
/**
|
||||
* The generated API client, or null when disconnected.
|
||||
*
|
||||
* Callers should pass [directory] to each API method's `directory`
|
||||
* parameter to scope requests to this project.
|
||||
*/
|
||||
val api: DefaultApi?
|
||||
get() = connection.api
|
||||
|
||||
/**
|
||||
* One-shot health check via the generated API client.
|
||||
* Returns [HealthDto] or throws if not connected / server unreachable.
|
||||
*/
|
||||
suspend fun health(): HealthDto {
|
||||
val client = api ?: throw IllegalStateException("Not connected")
|
||||
val response = client.globalHealth()
|
||||
return HealthDto(healthy = true, version = response.version)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ class ServerManager(private val cs: CoroutineScope) : Disposable {
|
||||
private suspend fun start(): ServerState {
|
||||
return try {
|
||||
val path = extractCli()
|
||||
LOG.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
|
||||
withTimeout(STARTUP_TIMEOUT_MS) {
|
||||
spawn(path)
|
||||
}
|
||||
@@ -136,13 +137,16 @@ class ServerManager(private val cs: CoroutineScope) : Disposable {
|
||||
put("KILO_APP_NAME", "kilo-code")
|
||||
}
|
||||
|
||||
val builder = ProcessBuilder(cli.absolutePath, "serve", "--port", "0")
|
||||
val cmd = listOf(cli.absolutePath, "serve", "--port", "0")
|
||||
val builder = ProcessBuilder(cmd)
|
||||
builder.environment().clear()
|
||||
builder.environment().putAll(env)
|
||||
builder.redirectErrorStream(false)
|
||||
|
||||
LOG.info("Spawning: ${cli.absolutePath} serve --port 0")
|
||||
LOG.info("Starting CLI: ${cmd.joinToString(" ")}")
|
||||
LOG.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
|
||||
val proc = builder.start()
|
||||
LOG.info("CLI process started (pid=${proc.pid()})")
|
||||
process = proc
|
||||
install(proc)
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
</dependencies>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.rpc.KiloBackendRpcApiProvider"/>
|
||||
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.rpc.KiloProjectRpcApiProvider"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
package ai.kilocode
|
||||
|
||||
import ai.kilocode.rpc.KiloRpcApi
|
||||
import ai.kilocode.rpc.KiloProjectRpcApi
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
import com.intellij.platform.project.projectId
|
||||
import fleet.rpc.client.durable
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -18,6 +20,13 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Frontend project-level service for Kilo CLI interaction.
|
||||
*
|
||||
* Communicates with the backend via [KiloProjectRpcApi], passing
|
||||
* [project.projectId] on every call so the backend can resolve the
|
||||
* correct project-level service without scanning ProjectManager.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
class KiloApiService(
|
||||
private val project: Project,
|
||||
@@ -31,7 +40,9 @@ class KiloApiService(
|
||||
|
||||
val state: StateFlow<ConnectionStateDto> = flow {
|
||||
durable {
|
||||
KiloRpcApi.getInstance().state().collect { emit(it) }
|
||||
KiloProjectRpcApi.getInstance()
|
||||
.state(project.projectId())
|
||||
.collect { emit(it) }
|
||||
}
|
||||
}.stateIn(cs, SharingStarted.Eagerly, init)
|
||||
|
||||
@@ -39,11 +50,18 @@ class KiloApiService(
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
cs.launch {
|
||||
durable {
|
||||
KiloRpcApi.getInstance().connect()
|
||||
KiloProjectRpcApi.getInstance().connect(project.projectId())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot health check. Returns null on failure. */
|
||||
suspend fun health(): HealthDto? = try {
|
||||
durable { KiloProjectRpcApi.getInstance().health(project.projectId()) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun watch(fn: (String) -> Unit): Job {
|
||||
val mgr = ToolWindowManager.getInstance(project)
|
||||
return cs.launch {
|
||||
@@ -55,8 +73,8 @@ class KiloApiService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun text(state: ConnectionStateDto): String {
|
||||
return when (state.status) {
|
||||
private fun text(state: ConnectionStateDto): String =
|
||||
when (state.status) {
|
||||
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
|
||||
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
|
||||
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected")
|
||||
@@ -65,5 +83,4 @@ class KiloApiService(
|
||||
state.error ?: KiloBundle.message("toolwindow.error.unknown"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ kotlin-jvm-plugin = "2.1.20"
|
||||
kotlin-serialization-plugin = "2.1.20"
|
||||
kotlin-serialization = "1.7.3"
|
||||
okhttp = "4.12.0"
|
||||
moshi = "1.15.1"
|
||||
openapi-generator = "7.12.0"
|
||||
|
||||
[libraries]
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
|
||||
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
|
||||
|
||||
[plugins]
|
||||
intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" }
|
||||
@@ -17,3 +21,4 @@ rpc = { id = "rpc", version.ref = "intellij-rpc-plugin" }
|
||||
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-jvm-plugin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization-plugin" }
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-jvm-plugin" }
|
||||
openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import com.intellij.platform.project.ProjectId
|
||||
import com.intellij.platform.rpc.RemoteApiProviderService
|
||||
import fleet.rpc.RemoteApi
|
||||
import fleet.rpc.Rpc
|
||||
import fleet.rpc.remoteApiDescriptor
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Project-scoped RPC API exposed from backend to frontend.
|
||||
*
|
||||
* Every method takes a [ProjectId] as its first parameter, following the
|
||||
* JetBrains modular plugin template pattern. The frontend obtains the ID
|
||||
* via `project.projectId()` and the backend resolves the project via
|
||||
* `projectId.findProjectOrNull()`.
|
||||
*/
|
||||
@Rpc
|
||||
interface KiloProjectRpcApi : RemoteApi<Unit> {
|
||||
companion object {
|
||||
suspend fun getInstance(): KiloProjectRpcApi {
|
||||
return RemoteApiProviderService.resolve(remoteApiDescriptor<KiloProjectRpcApi>())
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the CLI backend is running and connected. */
|
||||
suspend fun connect(projectId: ProjectId)
|
||||
|
||||
/** Observe connection state changes. */
|
||||
suspend fun state(projectId: ProjectId): Flow<ConnectionStateDto>
|
||||
|
||||
/** One-shot health check against /global/health. */
|
||||
suspend fun health(projectId: ProjectId): HealthDto
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import com.intellij.platform.rpc.RemoteApiProviderService
|
||||
import fleet.rpc.RemoteApi
|
||||
import fleet.rpc.Rpc
|
||||
import fleet.rpc.remoteApiDescriptor
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Rpc
|
||||
interface KiloRpcApi : RemoteApi<Unit> {
|
||||
companion object {
|
||||
suspend fun getInstance(): KiloRpcApi {
|
||||
return RemoteApiProviderService.resolve(remoteApiDescriptor<KiloRpcApi>())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connect()
|
||||
|
||||
suspend fun state(): Flow<ConnectionStateDto>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ai.kilocode.rpc.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class HealthDto(
|
||||
val healthy: Boolean,
|
||||
val version: String,
|
||||
)
|
||||
Reference in New Issue
Block a user