diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendProjectService.kt deleted file mode 100644 index c4e2a4ea713..00000000000 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendProjectService.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ai.kilocode.backend - -import com.intellij.openapi.components.Service -import com.intellij.openapi.project.Project -import kotlinx.coroutines.CoroutineScope - -/** - * Project-level backend service that provides the project's working - * directory for scoping CLI API calls. - * - * Currently a thin shell — will hold project-scoped data loading - * (providers, agents, config, etc.) in the future. - */ -@Service(Service.Level.PROJECT) -class KiloBackendProjectService( - private val project: Project, - private val cs: CoroutineScope, -) { - /** Project working directory sent as the `directory` parameter. */ - val directory: String - get() = project.basePath ?: "" -} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 93fcb6f104c..66a80d3c354 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.app import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog +import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.infrastructure.ClientError import ai.kilocode.jetbrains.api.infrastructure.ClientException @@ -18,6 +19,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -27,6 +29,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.net.ConnectException import java.net.SocketTimeoutException +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicReference /** @@ -76,8 +79,10 @@ class KiloBackendAppService private constructor( cs.launch { reconnect() } }, log = log) - private var router: Job? = null + private var watcher: Job? = null + private var eventWatcher: Job? = null private var loader: Job? = null + private val loadLock = Any() private val _appState = MutableStateFlow(KiloAppState.Disconnected) val appState: StateFlow = _appState.asStateFlow() @@ -85,6 +90,9 @@ class KiloBackendAppService private constructor( val events: SharedFlow get() = connection.events val api: DefaultApi? get() = connection.api + val sessions = KiloBackendSessionManager(cs, log) + val workspaces = KiloBackendWorkspaceManager(cs, sessions, log) + @Volatile var profile: KiloProfile200Response? = null private set @@ -98,6 +106,7 @@ class KiloBackendAppService private constructor( mutex.withLock { val current = _appState.value if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) return + ensureWatcher() connection.connect() } } @@ -135,8 +144,9 @@ class KiloBackendAppService private constructor( } } - init { - cs.launch { + private fun ensureWatcher() { + if (watcher?.isActive == true) return + watcher = cs.launch { connection.state.collect { next -> when (next) { ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected @@ -159,98 +169,115 @@ class KiloBackendAppService private constructor( * On failure of required data, transitions to [KiloAppState.Error]. */ private fun load() { - loader?.cancel() - loader = cs.launch { - log.info("Loading global data") - val progress = AtomicReference(LoadProgress()) - _appState.value = KiloAppState.Loading(progress.get()) + synchronized(loadLock) { + loader?.cancel() + eventWatcher?.cancel() + loader = cs.launch { + log.info("Application starting — loading config, profile, notifications") + val progress = AtomicReference(LoadProgress()) + _appState.value = KiloAppState.Loading(progress.get()) - val errors = mutableListOf() + val errors = CopyOnWriteArrayList() + var cfg: Config? = null + var prof: KiloProfile200Response? = null + var notifs: List = emptyList() - try { - coroutineScope { - launch { - try { + try { + coroutineScope { + launch { val result = fetchProfile() - progress.updateAndGet { it.copy(profile = result) } + val status = when { + result.error != null -> { + errors.add(result.error) + throw LoadFailure(result.error) + } + result.value != null -> { + prof = result.value + ProfileResult.LOADED + } + else -> ProfileResult.NOT_LOGGED_IN + } + progress.updateAndGet { it.copy(profile = status) } .also { _appState.value = KiloAppState.Loading(it) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - val err = LoadError( - resource = "profile", - status = (e as? ClientException)?.statusCode - ?: (e as? ServerException)?.statusCode, - detail = e.message, - ) - synchronized(errors) { errors.add(err) } - throw LoadFailure(err) + } + launch { + val result = fetchWithRetry("config") { fetchConfig() } + if (result.value != null) { + cfg = result.value + progress.updateAndGet { it.copy(config = true) } + .also { _appState.value = KiloAppState.Loading(it) } + } else { + val err = result.error!! + errors.add(err) + throw LoadFailure(err) + } + } + launch { + val result = fetchWithRetry("notifications") { fetchNotifications() } + if (result.value != null) { + notifs = result.value + progress.updateAndGet { it.copy(notifications = true) } + .also { _appState.value = KiloAppState.Loading(it) } + } else { + val err = result.error!! + errors.add(err) + throw LoadFailure(err) + } } } - launch { - val result = fetchWithRetry("config") { fetchConfig() } - if (result.value != null) { - config = result.value - progress.updateAndGet { it.copy(config = true) } - .also { _appState.value = KiloAppState.Loading(it) } - } else { - val err = result.error!! - synchronized(errors) { errors.add(err) } - throw LoadFailure(err) - } - } - launch { - val result = fetchWithRetry("notifications") { fetchNotifications() } - if (result.value != null) { - notifications = result.value - progress.updateAndGet { it.copy(notifications = true) } - .also { _appState.value = KiloAppState.Loading(it) } - } else { - val err = result.error!! - synchronized(errors) { errors.add(err) } - throw LoadFailure(err) - } - } - } - _appState.value = KiloAppState.Ready( - AppData( - profile = profile, - config = config!!, - notifications = notifications, + ensureActive() + profile = prof + config = cfg + notifications = notifs + sessions.start(connection.api!!, connection.events) + workspaces.start(connection.api!!, connection.events) + _appState.value = KiloAppState.Ready( + AppData( + profile = prof, + config = cfg!!, + notifications = notifs, + ) ) - ) - log.info("Global data loaded — app is Ready") - ensureRouter() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.warn("Global data load failed: ${e.message}") - _appState.value = KiloAppState.Error( - message = "Failed to load required data", - errors = synchronized(errors) { errors.toList() }, - ) + log.info("Application started — config, profile, notifications loaded") + startWatchingGlobalSseEvents() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Application start failed: ${e.message}") + _appState.value = KiloAppState.Error( + message = "Failed to load required data", + errors = errors.toList(), + ) + } } } } - private suspend fun fetchProfile(): ProfileResult { - val client = connection.api ?: return ProfileResult.NOT_LOGGED_IN + /** + * Fetch the user profile. Returns [FetchResult.ok] with the response + * on success, [FetchResult.ok] with `null` when not logged in (401), + * or [FetchResult.fail] on other errors. Never throws. + */ + private suspend fun fetchProfile(): FetchResult { + val client = connection.api + ?: return FetchResult.ok(null) return try { val response = client.kiloProfile() - profile = response log.info("Profile: ${response.profile.email}") - ProfileResult.LOADED + FetchResult.ok(response) } catch (e: ClientException) { if (e.statusCode == 401) { log.info("Profile: not logged in (401)") - return ProfileResult.NOT_LOGGED_IN + return FetchResult.ok(null) } log.warn("Profile fetch failed: HTTP ${e.statusCode}", e) - throw e + logResponseBody("profile", e) + FetchResult.fail("profile", e) } catch (e: Exception) { log.warn("Profile fetch failed: ${e.message}", e) - throw e + logResponseBody("profile", e) + FetchResult.fail("profile", e) } } @@ -312,22 +339,54 @@ class KiloBackendAppService private constructor( return last } - private fun ensureRouter() { - if (router?.isActive == true) return - router = cs.launch { - connection.events.collect { event -> - when (event.type) { - "global.config.updated" -> launch { - val result = fetchConfig() - if (result.value != null) { - config = result.value - val current = _appState.value - if (current is KiloAppState.Ready) { - _appState.value = current.copy( - data = current.data.copy(config = result.value) - ) + /** + * Watch global SSE events to keep app state in sync with the CLI server. + * + * - `global.config.updated` — the project config changed on disk or via CLI. + * Re-fetches config and updates [KiloAppState.Ready] data in-place. + * + * - `global.disposed` — the CLI server's global context was torn down + * (e.g. during a restart). Triggers a full reload to re-populate all data. + * + * - `server.instance.disposed` — a specific server instance was disposed. + * Same effect as `global.disposed` — triggers a full reload so downstream + * project services pick up the new state. + * + * Idempotent — only one watcher runs at a time. + */ + private fun startWatchingGlobalSseEvents() { + synchronized(loadLock) { + if (eventWatcher?.isActive == true) return + log.info("Started watching global SSE events (config.updated, disposed)") + eventWatcher = cs.launch { + connection.events.collect { event -> + when (event.type) { + "global.config.updated" -> { + log.info("SSE global.config.updated — reloading config") + launch { + val result = fetchConfig() + if (result.value != null) { + config = result.value + val current = _appState.value + if (current is KiloAppState.Ready) { + _appState.value = current.copy( + data = current.data.copy(config = result.value) + ) + } + log.info("Config reloaded successfully") + } } } + "global.disposed" -> { + log.info("SSE global.disposed — triggering full application reload") + val current = _appState.value + if (current is KiloAppState.Ready) load() + } + "server.instance.disposed" -> { + log.info("SSE server.instance.disposed — triggering full application reload") + val current = _appState.value + if (current is KiloAppState.Ready) load() + } } } } @@ -335,8 +394,12 @@ class KiloBackendAppService private constructor( } private fun clear() { - loader?.cancel() - router?.cancel() + synchronized(loadLock) { + loader?.cancel() + eventWatcher?.cancel() + } + workspaces.stop() + sessions.stop() profile = null config = null notifications = emptyList() @@ -344,6 +407,8 @@ class KiloBackendAppService private constructor( } override fun dispose() { + watcher?.cancel() + watcher = null clear() connection.dispose() server.dispose() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt index 83890ed30c4..c80ab7612b4 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt @@ -2,7 +2,10 @@ package ai.kilocode.backend.app import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager +import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.util.SystemInfo import com.intellij.util.system.CpuArch import kotlinx.coroutines.Dispatchers @@ -12,6 +15,7 @@ import java.io.BufferedReader import java.io.File import java.io.InputStreamReader import java.security.SecureRandom +import java.util.UUID import java.util.concurrent.TimeUnit /** @@ -21,7 +25,9 @@ import java.util.concurrent.TimeUnit * spawns `kilo serve --port 0`, and exposes the result as [State]. * * Concurrency is handled by the owning [KiloBackendAppService] — all public - * methods are called under its mutex so no internal synchronization is needed. + * methods except [exited] are called under its mutex. [exited] is called from + * [KiloConnectionService]'s IO dispatcher and is thread-safe via the stale-ref + * guard and volatile [process] field. */ class KiloBackendCliManager( private val log: KiloLog = IntellijLog(KiloBackendCliManager::class.java), @@ -33,6 +39,7 @@ class KiloBackendCliManager( private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""") } + @Volatile private var process: Process? = null private var hook: Thread? = null @@ -145,6 +152,9 @@ class KiloBackendCliManager( put("KILO_ENABLE_QUESTION_TOOL", "true") put("KILO_PLATFORM", "jetbrains") put("KILO_APP_NAME", "kilo-code") + put("KILO_DISABLE_CLAUDE_CODE", "true") + put("KILOCODE_FEATURE", "jetbrains-plugin") + ideEnv().forEach { (k, v) -> put(k, v) } } val cmd = listOf(cli.absolutePath, "serve", "--port", "0") @@ -275,6 +285,44 @@ class KiloBackendCliManager( return "$os-$arch" } + /** + * Collect IDE-specific env vars for telemetry and gateway attribution. + * Catches all exceptions since these are best-effort — missing values + * won't prevent the CLI from starting. + */ + private fun ideEnv(): Map = buildMap { + runCatching { + val info = ApplicationInfo.getInstance() + val name = info.fullApplicationName + val build = info.build.asString() + put("KILO_EDITOR_NAME", name) + put("KILOCODE_EDITOR_NAME", "$name $build") + }.onFailure { log.info("Could not read ApplicationInfo: ${it.message}") } + + runCatching { + val version = PluginManagerCore + .getPlugin(PluginId.getId("ai.kilocode"))?.version + if (version != null) put("KILO_APP_VERSION", version) + }.onFailure { log.info("Could not read plugin version: ${it.message}") } + + runCatching { + put("KILO_MACHINE_ID", machineId()) + }.onFailure { log.info("Could not read machine ID: ${it.message}") } + } + + /** + * Persistent machine ID stored in the IntelliJ system directory. + * Generated once and reused across restarts. + */ + private fun machineId(): String { + val file = File(PathManager.getSystemPath(), "kilo/machine-id") + if (file.exists()) return file.readText().trim() + val id = UUID.randomUUID().toString() + file.parentFile.mkdirs() + file.writeText(id) + return id + } + private fun generatePassword(): String { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index 23bd1bc10db..a2c4530317a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -1,11 +1,12 @@ package ai.kilocode.backend.app import ai.kilocode.backend.util.IntellijLog -import ai.kilocode.backend.KiloBackendHttpClients +import ai.kilocode.backend.util.KiloBackendHttpClients import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -197,6 +198,9 @@ class KiloConnectionService( .readTimeout(0, TimeUnit.MILLISECONDS) .build() ) + // Reset heartbeat timestamp before connecting so the watcher + // doesn't fire against a stale timestamp from the old connection. + lastEvent.set(System.currentTimeMillis()) source.set(factory.newEventSource(request, listener)) log.info("SSE: connecting to port $port") } @@ -301,6 +305,7 @@ class KiloConnectionService( private fun monitorProcess(proc: Process) = cs.launch(Dispatchers.IO) { proc.waitFor() + ensureActive() server.exited(proc) val code = proc.exitValue() log.warn("CLI process exited with code $code") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt new file mode 100644 index 00000000000..bf9ff224422 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -0,0 +1,188 @@ +package ai.kilocode.backend.app + +import ai.kilocode.backend.util.KiloLog +import ai.kilocode.jetbrains.api.client.DefaultApi +import ai.kilocode.jetbrains.api.model.SessionStatus +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.SessionSummaryDto +import ai.kilocode.rpc.dto.SessionTimeDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Session gateway that handles session CRUD and live status tracking + * across all directories (workspace roots and worktrees). + * + * **Not an IntelliJ service** — owned by [KiloBackendAppService] which + * calls [start] after the CLI server reaches [KiloAppState.Ready] and + * [stop] on disconnect. The API client is guaranteed non-null between + * start/stop — no defensive null checks in CRUD methods. + * + * SSE `session.status` events are consumed directly from the events + * flow passed to [start], keeping the live [statuses] map current. + */ +class KiloBackendSessionManager( + private val cs: CoroutineScope, + private val log: KiloLog, +) { + companion object { + private val FIELD_RE = ConcurrentHashMap() + + /** Extract a top-level string field from JSON without a full parser. */ + internal fun extractField(json: String, field: String): String? { + val re = FIELD_RE.getOrPut(field) { + Regex(""""$field"\s*:\s*"([^"]+)"""") + } + return re.find(json)?.groupValues?.get(1) + } + + /** Extract a string field nested one level deep. */ + internal fun extractNested(json: String, outer: String, inner: String): String? { + val block = Regex(""""$outer"\s*:\s*\{([^}]+)}""") + .find(json)?.groupValues?.get(1) ?: return null + return extractField("{$block}", inner) + } + } + + /** Per-session directory overrides (sessionId → worktree path). */ + private val directories = ConcurrentHashMap() + + private val _statuses = MutableStateFlow>(emptyMap()) + val statuses: StateFlow> = _statuses.asStateFlow() + + private var client: DefaultApi? = null + private var watcher: Job? = null + + /** + * Activate the session manager with a connected API client and SSE stream. + * Called by [KiloBackendAppService] after [KiloAppState.Ready]. + */ + fun start(api: DefaultApi, events: SharedFlow) { + client = api + if (watcher?.isActive == true) return + watcher = cs.launch { + events.collect { event -> + if (event.type == "session.status") { + handleStatus(event.data) + } + } + } + log.info("Session manager started") + } + + /** + * Deactivate the session manager. Called by [KiloBackendAppService] on disconnect. + */ + fun stop() { + watcher?.cancel() + watcher = null + client = null + _statuses.value = emptyMap() + log.info("Session manager stopped") + } + + private fun requireClient(): DefaultApi = + client ?: throw IllegalStateException("Session manager not started") + + // ------ session CRUD ------ + + /** List root sessions for a directory and include current statuses. */ + fun list(dir: String): SessionListDto { + seed(dir) + val raw = requireClient().sessionList(directory = dir, roots = true) + val mapped = raw.map(::dto) + val ids = mapped.map { it.id }.toSet() + val relevant = _statuses.value.filterKeys { it in ids } + return SessionListDto(mapped, relevant) + } + + /** Create a new session in the given directory. */ + fun create(dir: String): SessionDto = + dto(requireClient().sessionCreate(directory = dir)) + + /** + * Get a single session by ID. + * + * Uses the session list endpoint and filters by ID since + * [DefaultApi] does not expose the single-session GET. + */ + fun get(id: String, dir: String): SessionDto { + val all = requireClient().sessionList(directory = dir) + val raw = all.firstOrNull { it.id == id } + ?: throw IllegalArgumentException("Session $id not found") + return dto(raw) + } + + /** Delete a session. */ + fun delete(id: String, dir: String) { + requireClient().sessionDelete(sessionID = id, directory = dir) + directories.remove(id) + } + + /** Seed status map from the server for a specific directory. */ + fun seed(dir: String) { + try { + val raw = requireClient().sessionStatus(directory = dir) + val mapped = raw.mapValues { (_, v) -> statusDto(v) } + _statuses.update { it + mapped } + log.info("Seeded ${mapped.size} session statuses for $dir") + } catch (e: Exception) { + log.warn("Session status seed failed: ${e.message}", e) + } + } + + // ------ worktree directory management ------ + + fun setDirectory(id: String, dir: String) { + directories[id] = dir + } + + fun getDirectory(id: String, fallback: String): String = + directories[id] ?: fallback + + // ------ SSE event handling ------ + + private fun handleStatus(data: String) { + val id = extractField(data, "sessionID") ?: return + val type = extractNested(data, "status", "type") ?: "idle" + val msg = extractNested(data, "status", "message") + _statuses.update { it + (id to SessionStatusDto(type, msg)) } + } + + // ------ mapping ------ + + private fun dto(s: ai.kilocode.jetbrains.api.model.Session) = SessionDto( + id = s.id, + projectID = s.projectID, + directory = s.directory, + parentID = s.parentID, + title = s.title, + version = s.version, + time = SessionTimeDto( + created = s.time.created, + updated = s.time.updated, + archived = s.time.archived, + ), + summary = s.summary?.let { + SessionSummaryDto( + additions = it.additions.toInt(), + deletions = it.deletions.toInt(), + files = it.files.toInt(), + ) + }, + ) + + private fun statusDto(s: SessionStatus) = SessionStatusDto( + type = s.type.value, + message = s.message.ifBlank { null }, + ) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 00280d47937..e11f315a3fd 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -48,7 +48,15 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { status = KiloAppStatusDto.LOADING, progress = progress(state.progress), ) - is KiloAppState.Ready -> KiloAppStateDto(KiloAppStatusDto.READY) + is KiloAppState.Ready -> KiloAppStateDto( + status = KiloAppStatusDto.READY, + progress = LoadProgressDto( + config = true, + notifications = true, + profile = if (state.data.profile != null) ProfileStatusDto.LOADED + else ProfileStatusDto.NOT_LOGGED_IN, + ), + ) is KiloAppState.Error -> KiloAppStateDto( status = KiloAppStatusDto.ERROR, error = state.message, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt new file mode 100644 index 00000000000..69335928b28 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt @@ -0,0 +1,156 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.workspace.AgentData +import ai.kilocode.backend.workspace.AgentInfo +import ai.kilocode.backend.workspace.CommandInfo +import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager +import ai.kilocode.backend.workspace.KiloWorkspaceLoadProgress +import ai.kilocode.backend.workspace.KiloWorkspaceState +import ai.kilocode.backend.workspace.ModelInfo +import ai.kilocode.backend.workspace.ProviderData +import ai.kilocode.backend.workspace.ProviderInfo +import ai.kilocode.backend.workspace.SkillInfo +import ai.kilocode.rpc.KiloProjectRpcApi +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.AgentsDto +import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.ProvidersDto +import ai.kilocode.rpc.dto.SkillDto +import com.intellij.openapi.components.service +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** + * Backend implementation of [KiloProjectRpcApi]. + * + * Routes through the [KiloBackendWorkspaceManager] to get a workspace + * for the given directory. No [ProjectManager] dependency — any + * directory (including worktrees) can get a workspace. + */ +class KiloProjectRpcApiImpl : KiloProjectRpcApi { + + private val app: KiloBackendAppService get() = service() + + private val manager: KiloBackendWorkspaceManager + get() = app.workspaces + + /** + * Emits workspace state for [directory]. Waits for the app to + * reach [KiloAppState.Ready] before creating the workspace — + * until then, emits [KiloWorkspaceStatusDto.PENDING]. + * + * When the app leaves Ready (e.g. during restart/reconnect), + * the flow falls back to PENDING again and re-subscribes to + * the new workspace once Ready returns. + */ + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun state(directory: String): Flow = + app.appState.flatMapLatest { state -> + if (state is KiloAppState.Ready) { + manager.get(directory).state.map(::dto) + } else { + flowOf(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) + } + }.distinctUntilChanged() + + override suspend fun reload(directory: String) { + if (app.appState.value !is KiloAppState.Ready) return + manager.get(directory).reload() + } + + // ------ mapping: domain model → DTO ------ + + private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto = + when (state) { + KiloWorkspaceState.Pending -> KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + is KiloWorkspaceState.Loading -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.LOADING, + progress = progress(state.progress), + ) + is KiloWorkspaceState.Ready -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.READY, + providers = providers(state.providers), + agents = agents(state.agents), + commands = state.commands.map(::command), + skills = state.skills.map(::skill), + ) + is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = state.message, + ) + } + + private fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( + providers = p.providers, + agents = p.agents, + commands = p.commands, + skills = p.skills, + ) + + private fun providers(d: ProviderData) = ProvidersDto( + providers = d.providers.map(::provider), + connected = d.connected, + defaults = d.defaults, + ) + + private fun provider(p: ProviderInfo) = ProviderDto( + id = p.id, + name = p.name, + source = p.source, + models = p.models.mapValues { (_, m) -> model(m) }, + ) + + private fun model(m: ModelInfo) = ModelDto( + id = m.id, + name = m.name, + attachment = m.attachment, + reasoning = m.reasoning, + temperature = m.temperature, + toolCall = m.toolCall, + free = m.free, + status = m.status, + ) + + private fun agents(d: AgentData) = AgentsDto( + agents = d.agents.map(::agent), + all = d.all.map(::agent), + default = d.default, + ) + + private fun agent(a: AgentInfo) = AgentDto( + name = a.name, + displayName = a.displayName, + description = a.description, + mode = a.mode, + native = a.native, + hidden = a.hidden, + color = a.color, + deprecated = a.deprecated, + ) + + private fun command(c: CommandInfo) = CommandDto( + name = c.name, + description = c.description, + source = c.source, + hints = c.hints, + ) + + private fun skill(s: SkillInfo) = SkillDto( + name = s.name, + description = s.description, + location = s.location, + ) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt new file mode 100644 index 00000000000..ae30ed6887c --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt @@ -0,0 +1,15 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.KiloProjectRpcApi +import com.intellij.platform.rpc.backend.RemoteApiProvider +import fleet.rpc.remoteApiDescriptor + +internal class KiloProjectRpcApiProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + KiloProjectRpcApiImpl() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt new file mode 100644 index 00000000000..e2dcf4025d5 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -0,0 +1,55 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager +import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.components.service +import kotlinx.coroutines.flow.Flow + +/** + * Backend implementation of [KiloSessionRpcApi]. + * + * Session CRUD routes through the [KiloBackendWorkspaceManager] to + * get the correct workspace for a directory. Status tracking and + * worktree directory management go directly to the + * [KiloBackendSessionManager]. + */ +class KiloSessionRpcApiImpl : KiloSessionRpcApi { + + private val workspaces: KiloBackendWorkspaceManager + get() = service().workspaces + + private val sessions: KiloBackendSessionManager + get() = service().sessions + + override suspend fun list(directory: String): SessionListDto = + workspaces.get(directory).sessions() + + override suspend fun create(directory: String): SessionDto = + workspaces.get(directory).createSession() + + override suspend fun get(id: String, directory: String): SessionDto { + val dir = sessions.getDirectory(id, directory) + return sessions.get(id, dir) + } + + override suspend fun delete(id: String, directory: String) { + val dir = sessions.getDirectory(id, directory) + workspaces.get(dir).deleteSession(id) + } + + override suspend fun statuses(): Flow> = + sessions.statuses + + override suspend fun setDirectory(id: String, directory: String) = + sessions.setDirectory(id, directory) + + override suspend fun getDirectory(id: String, fallback: String): String = + sessions.getDirectory(id, fallback) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiProvider.kt new file mode 100644 index 00000000000..404162cf088 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiProvider.kt @@ -0,0 +1,15 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.KiloSessionRpcApi +import com.intellij.platform.rpc.backend.RemoteApiProvider +import fleet.rpc.remoteApiDescriptor + +internal class KiloSessionRpcApiProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + KiloSessionRpcApiImpl() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendHttpClients.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt similarity index 98% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendHttpClients.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt index dc997ef302e..bfb3397aaaf 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendHttpClients.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.util import okhttp3.ConnectionPool import okhttp3.Interceptor diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendProjectService.kt new file mode 100644 index 00000000000..173c35f9d5f --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendProjectService.kt @@ -0,0 +1,27 @@ +package ai.kilocode.backend.workspace + +import ai.kilocode.backend.app.KiloBackendAppService +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope + +/** + * Per-IntelliJ-Project adapter that maps [Project.getBasePath] to a + * [KiloBackendWorkspace] from the app-level workspace manager. + * + * This is a thin accessor — all data loading, SSE watching, session + * access, and retry logic live in [KiloBackendWorkspace]. The frontend + * uses this service to get the workspace for the current IDE project. + */ +@Service(Service.Level.PROJECT) +class KiloBackendProjectService( + private val project: Project, + @Suppress("unused") private val cs: CoroutineScope, +) { + val directory: String get() = project.basePath ?: "" + + /** The workspace for this project's directory. */ + val workspace: KiloBackendWorkspace + get() = service().workspaces.get(directory) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt new file mode 100644 index 00000000000..9f69228a851 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -0,0 +1,299 @@ +package ai.kilocode.backend.workspace + +import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.app.SseEvent +import ai.kilocode.backend.util.KiloLog +import ai.kilocode.jetbrains.api.client.DefaultApi +import ai.kilocode.jetbrains.api.model.Agent +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicReference + +/** + * Single entry point for all directory-scoped data: project catalog + * (providers, agents, commands, skills) and session access. + * + * **Not an IntelliJ service** — a plain class created by + * [KiloBackendWorkspaceManager] for each directory. Receives a + * pre-connected [DefaultApi] — no null checks needed. + * + * Session operations delegate to [KiloBackendSessionManager] with + * this workspace's [directory], so the frontend only needs one + * object per directory. + */ +class KiloBackendWorkspace( + val directory: String, + private val cs: CoroutineScope, + private val api: DefaultApi, + private val events: SharedFlow, + private val sessions: KiloBackendSessionManager, + private val log: KiloLog, +) { + companion object { + private const val MAX_RETRIES = 3 + private const val RETRY_DELAY_MS = 1000L + } + + private val _state = MutableStateFlow(KiloWorkspaceState.Pending) + val state: StateFlow = _state.asStateFlow() + + private var loader: Job? = null + private var eventWatcher: Job? = null + private val loadLock = Any() + + /** Load project data (providers, agents, commands, skills). */ + fun load() { + synchronized(loadLock) { + loader?.cancel() + eventWatcher?.cancel() + loader = cs.launch { + log.info("Loading workspace data for $directory") + val progress = AtomicReference(KiloWorkspaceLoadProgress()) + _state.value = KiloWorkspaceState.Loading(progress.get()) + + var prov: ProviderData? = null + var ag: AgentData? = null + var cmd: List? = null + var sk: List? = null + val errors = mutableListOf() + + try { + coroutineScope { + launch { + val result = fetchWithRetry("providers") { fetchProviders() } + if (result != null) { + prov = result + progress.updateAndGet { it.copy(providers = true) } + .also { _state.value = KiloWorkspaceState.Loading(it) } + } else { + synchronized(errors) { errors.add("providers") } + throw LoadFailure("providers") + } + } + launch { + val result = fetchWithRetry("agents") { fetchAgents() } + if (result != null) { + ag = result + progress.updateAndGet { it.copy(agents = true) } + .also { _state.value = KiloWorkspaceState.Loading(it) } + } else { + synchronized(errors) { errors.add("agents") } + throw LoadFailure("agents") + } + } + launch { + val result = fetchWithRetry("commands") { fetchCommands() } + if (result != null) { + cmd = result + progress.updateAndGet { it.copy(commands = true) } + .also { _state.value = KiloWorkspaceState.Loading(it) } + } else { + synchronized(errors) { errors.add("commands") } + throw LoadFailure("commands") + } + } + launch { + val result = fetchWithRetry("skills") { fetchSkills() } + if (result != null) { + sk = result + progress.updateAndGet { it.copy(skills = true) } + .also { _state.value = KiloWorkspaceState.Loading(it) } + } else { + synchronized(errors) { errors.add("skills") } + throw LoadFailure("skills") + } + } + } + + _state.value = KiloWorkspaceState.Ready( + providers = prov!!, + agents = ag!!, + commands = cmd!!, + skills = sk!!, + ) + log.info("Workspace data loaded for $directory") + ensureActive() + startWatchingGlobalSseEvents() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Workspace data load failed for $directory: ${e.message}") + _state.value = KiloWorkspaceState.Error( + "Failed to load: ${synchronized(errors) { errors.joinToString() }}" + ) + } + } + } + } + + /** Force a full reload of workspace data. */ + fun reload() { + load() + } + + /** Stop all background work. */ + fun stop() { + synchronized(loadLock) { + loader?.cancel() + eventWatcher?.cancel() + } + _state.value = KiloWorkspaceState.Pending + } + + // ------ session access (delegates to session manager) ------ + + fun sessions(): SessionListDto = sessions.list(directory) + fun createSession(): SessionDto = sessions.create(directory) + fun deleteSession(id: String) = sessions.delete(id, directory) + fun seedStatuses() = sessions.seed(directory) + + // ------ SSE watching ------ + + /** + * Watch global SSE events that invalidate workspace data. + * + * - `global.disposed` — CLI server context torn down, all data stale. + * - `server.instance.disposed` — server instance disposed, reload. + * + * Idempotent — only one watcher runs at a time. + */ + private fun startWatchingGlobalSseEvents() { + synchronized(loadLock) { + if (eventWatcher?.isActive == true) return + log.info("Started watching global SSE events for workspace $directory") + eventWatcher = cs.launch { + events.collect { event -> + when (event.type) { + "global.disposed" -> { + log.info("SSE global.disposed — reloading workspace data for $directory") + load() + } + "server.instance.disposed" -> { + log.info("SSE server.instance.disposed — reloading workspace data for $directory") + load() + } + } + } + } + } + } + + // ------ fetch methods ------ + + private fun fetchProviders(): ProviderData? = + try { + val response = api.providerList(directory = directory) + ProviderData( + providers = response.all.map { p -> + ProviderInfo( + id = p.id, + name = p.name, + source = p.api, + models = p.models.mapValues { (_, m) -> + ModelInfo( + id = m.id, + name = m.name, + attachment = m.attachment, + reasoning = m.reasoning, + temperature = m.temperature, + toolCall = m.toolCall, + free = m.isFree ?: false, + status = m.status?.value, + ) + }, + ) + }, + connected = response.connected, + defaults = response.default, + ) + } catch (e: Exception) { + log.warn("Providers fetch failed: ${e.message}", e) + null + } + + private fun fetchAgents(): AgentData? = + try { + val response = api.appAgents(directory = directory) + val mapped = response.map(::mapAgent) + val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } + AgentData( + agents = visible.map(::mapAgent), + all = mapped, + default = visible.firstOrNull()?.name ?: "code", + ) + } catch (e: Exception) { + log.warn("Agents fetch failed: ${e.message}", e) + null + } + + private fun fetchCommands(): List? = + try { + api.commandList(directory = directory).map { c -> + CommandInfo( + name = c.name, + description = c.description, + source = c.source?.value, + hints = c.hints, + ) + } + } catch (e: Exception) { + log.warn("Commands fetch failed: ${e.message}", e) + null + } + + private fun fetchSkills(): List? = + try { + api.appSkills(directory = directory).map { s -> + SkillInfo( + name = s.name, + description = s.description, + location = s.location, + ) + } + } catch (e: Exception) { + log.warn("Skills fetch failed: ${e.message}", e) + null + } + + // ------ helpers ------ + + private fun mapAgent(a: Agent) = AgentInfo( + name = a.name, + displayName = a.displayName, + description = a.description, + mode = a.mode.value, + native = a.native, + hidden = a.hidden, + color = a.color, + deprecated = a.deprecated, + ) + + private suspend fun fetchWithRetry( + name: String, + block: () -> T?, + ): T? { + repeat(MAX_RETRIES) { attempt -> + val result = block() + if (result != null) return result + if (attempt < MAX_RETRIES - 1) { + log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") + delay(RETRY_DELAY_MS) + } + } + log.error("$name: all $MAX_RETRIES attempts failed") + return null + } + + private class LoadFailure(resource: String) : Exception("Failed to load $resource") +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt new file mode 100644 index 00000000000..c69dd734539 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -0,0 +1,73 @@ +package ai.kilocode.backend.workspace + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.app.SseEvent +import ai.kilocode.backend.util.KiloLog +import ai.kilocode.jetbrains.api.client.DefaultApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharedFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Manages [KiloBackendWorkspace] instances by directory path. + * + * **Not an IntelliJ service** — owned by [KiloBackendAppService] which + * calls [start] after [KiloAppState.Ready] and [stop] on disconnect. + * + * Workspaces are created on demand via [get] — the first call for a + * directory creates the workspace and triggers data loading. Subsequent + * calls return the cached instance. Worktree directories are just + * another path — no special handling needed. + */ +class KiloBackendWorkspaceManager( + private val cs: CoroutineScope, + private val sessions: KiloBackendSessionManager, + private val log: KiloLog, +) { + private val workspaces = ConcurrentHashMap() + + private var api: DefaultApi? = null + private var events: SharedFlow? = null + + /** + * Activate with a connected API client and SSE stream. + * Called by [KiloBackendAppService] after [KiloAppState.Ready]. + * Clears any stale workspaces from a previous connection. + */ + fun start(api: DefaultApi, events: SharedFlow) { + stop() + this.api = api + this.events = events + log.info("Workspace manager started") + } + + /** + * Deactivate all workspaces. Called by [KiloBackendAppService] on disconnect. + */ + fun stop() { + workspaces.values.forEach { it.stop() } + workspaces.clear() + api = null + events = null + log.info("Workspace manager stopped") + } + + /** + * Get or create a workspace for a directory. + * The workspace loads data immediately upon creation. + */ + fun get(dir: String): KiloBackendWorkspace { + val client = api ?: throw IllegalStateException("Workspace manager not started") + val ev = events!! + return workspaces.computeIfAbsent(dir) { d -> + log.info("Creating workspace for $d") + KiloBackendWorkspace(d, cs, client, ev, sessions, log).also { it.load() } + } + } + + /** Remove a workspace (e.g. when a worktree is deleted). */ + fun remove(dir: String) { + workspaces.remove(dir)?.stop() + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt new file mode 100644 index 00000000000..7a4ad30b029 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -0,0 +1,86 @@ +package ai.kilocode.backend.workspace + +/** + * Workspace data lifecycle state, combining connection readiness + * with directory-scoped data loading progress. + * + * Only populated after [KiloAppState.Ready][ai.kilocode.backend.app.KiloAppState.Ready] + * — the CLI server must be connected and global data loaded before + * workspace data can be fetched. + */ +sealed class KiloWorkspaceState { + data object Pending : KiloWorkspaceState() + data class Loading(val progress: KiloWorkspaceLoadProgress) : KiloWorkspaceState() + data class Ready( + val providers: ProviderData, + val agents: AgentData, + val commands: List, + val skills: List, + ) : KiloWorkspaceState() + data class Error(val message: String) : KiloWorkspaceState() +} + +/** + * Tracks which workspace data fetches have completed during + * the [KiloWorkspaceState.Loading] phase. + */ +data class KiloWorkspaceLoadProgress( + val providers: Boolean = false, + val agents: Boolean = false, + val commands: Boolean = false, + val skills: Boolean = false, +) + +data class ProviderData( + val providers: List, + val connected: List, + val defaults: Map, +) + +data class ProviderInfo( + val id: String, + val name: String, + val source: String?, + val models: Map, +) + +data class ModelInfo( + val id: String, + val name: String, + val attachment: Boolean, + val reasoning: Boolean, + val temperature: Boolean, + val toolCall: Boolean, + val free: Boolean, + val status: String?, +) + +data class AgentData( + val agents: List, + val all: List, + val default: String, +) + +data class AgentInfo( + val name: String, + val displayName: String?, + val description: String?, + val mode: String, + val native: Boolean?, + val hidden: Boolean?, + val color: String?, + val deprecated: Boolean?, +) + +data class CommandInfo( + val name: String, + val description: String?, + val source: String?, + val hints: List, +) + +data class SkillInfo( + val name: String, + val description: String, + val location: String, +) diff --git a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml index a9d0c8f5e9b..1a88a88350a 100644 --- a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml +++ b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml @@ -7,5 +7,7 @@ + + diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt index 23c09ec4482..620420241cb 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt @@ -246,4 +246,84 @@ class KiloBackendAppServiceTest { assertEquals("updated", svc.config?.model) } + + // ------ Concurrency & lifecycle tests ------ + + @Test + fun `rapid disposed events produce single valid Ready`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + mock.awaitSseConnection() + + // Fire rapid global.disposed events to trigger concurrent load() calls + repeat(5) { + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + } + + // Wait for the app to settle back to Ready + withTimeout(15_000) { + // Allow transient Loading states, wait for final Ready + while (true) { + val state = svc.appState.value + if (state is KiloAppState.Ready) { + // Verify it's stable + delay(500) + if (svc.appState.value is KiloAppState.Ready) break + } + delay(100) + } + } + + assertIs(svc.appState.value) + assertNotNull(svc.config) + } + + @Test + fun `restart lifecycle transitions correctly`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + // Restart should tear down and reconnect + svc.restart() + + // Should transition back to Ready after restart + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertIs(svc.appState.value) + assertNotNull(svc.config) + } + + @Test + fun `reconnect after SSE close restores Ready state`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + // Close SSE to trigger reconnect path + mock.closeSse() + + // Should eventually recover to Connected/Ready through reconnect + // (connection service reconnects SSE if process is alive — but + // FakeCliServer returns no process, so it delegates to onReconnect + // which calls reconnect() under mutex) + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertIs(svc.appState.value) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt index b4e1d84f82e..a9ed85d2a64 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.backend +import ai.kilocode.backend.util.KiloBackendHttpClients import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import java.util.Base64 diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt new file mode 100644 index 00000000000..506a2b2e424 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt @@ -0,0 +1,337 @@ +package ai.kilocode.backend + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KiloBackendSessionManagerTest { + + private val mock = MockCliServer() + private val log = TestLog() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() { + scope.cancel() + mock.close() + } + + private fun setup(): KiloBackendAppService { + return KiloBackendAppService.create(scope, FakeCliServer(mock), log) + } + + private suspend fun ready(app: KiloBackendAppService) { + app.connect() + withTimeout(10_000) { + app.appState.first { it is KiloAppState.Ready } + } + } + + // ------ Lifecycle ------ + + @Test + fun `session manager starts when app reaches Ready`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s","projectID":"p","directory":"/d","title":"T","version":"1","time":{"created":1,"updated":1}} + ]""" + val app = setup() + ready(app) + + // Manager was started by app service — CRUD works without manual start() + val result = app.sessions.list("/d") + assertEquals(1, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + } + + @Test + fun `session manager throws when not started`() = runBlocking { + val app = setup() + // Don't connect — manager is not started + + assertFailsWith { + app.sessions.list("/test") + } + } + + @Test + fun `session manager stops on app disconnect`() = runBlocking { + val app = setup() + ready(app) + + // Verify it works + app.sessions.list("/test") + + // Dispose triggers clear() which calls sessions.stop() + app.dispose() + + assertFailsWith { + app.sessions.list("/test") + } + } + + // ------ Session list ------ + + @Test + fun `list returns sessions from server`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/test","title":"Session 1","version":"1","time":{"created":1000,"updated":2000}}, + {"id":"ses_2","slug":"s2","projectID":"prj","directory":"/test","title":"Session 2","version":"1","time":{"created":3000,"updated":4000}} + ]""" + val app = setup() + ready(app) + + val result = app.sessions.list("/test") + assertEquals(2, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + assertEquals("Session 1", result.sessions[0].title) + assertEquals(1000.0, result.sessions[0].time.created) + assertEquals("ses_2", result.sessions[1].id) + } + + @Test + fun `list includes session statuses`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s","projectID":"p","directory":"/d","title":"T","version":"1","time":{"created":1,"updated":1}} + ]""" + mock.sessionStatuses = """{"ses_1":{"type":"busy","attempt":0,"message":"","next":0,"requestID":""}}""" + val app = setup() + ready(app) + + app.sessions.seed("/d") + val result = app.sessions.list("/d") + assertEquals(1, result.sessions.size) + assertEquals("busy", result.statuses["ses_1"]?.type) + } + + // ------ Session create ------ + + @Test + fun `create returns new session`() = runBlocking { + mock.sessionCreate = """{ + "id": "ses_new", + "slug": "new", + "projectID": "prj_test", + "directory": "/test", + "title": "New Session", + "version": "1.0.0", + "time": {"created": 5000, "updated": 5000} + }""" + val app = setup() + ready(app) + + val session = app.sessions.create("/test") + assertEquals("ses_new", session.id) + assertEquals("New Session", session.title) + assertEquals("/test", session.directory) + } + + // ------ Session delete ------ + + @Test + fun `delete removes directory override`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.setDirectory("ses_1", "/worktree/path") + assertEquals("/worktree/path", app.sessions.getDirectory("ses_1", "/default")) + + app.sessions.delete("ses_1", "/test") + assertEquals("/default", app.sessions.getDirectory("ses_1", "/default")) + } + + // ------ Worktree directory management ------ + + @Test + fun `directory override returns worktree path`() = runBlocking { + val app = setup() + app.sessions.setDirectory("ses_abc", "/worktree/feature") + assertEquals("/worktree/feature", app.sessions.getDirectory("ses_abc", "/workspace")) + } + + @Test + fun `directory without override returns fallback`() = runBlocking { + val app = setup() + assertEquals("/workspace", app.sessions.getDirectory("ses_unknown", "/workspace")) + } + + // ------ SSE status tracking ------ + + @Test + fun `SSE session status events update status map`() = runBlocking { + val app = setup() + ready(app) + + mock.awaitSseConnection() + mock.pushEvent( + "session.status", + """{"type":"session.status","properties":{"sessionID":"ses_live","status":{"type":"busy","attempt":0,"message":"processing","next":0,"requestID":""}}}""", + ) + + withTimeout(5_000) { + app.sessions.statuses.first { it.containsKey("ses_live") } + } + + val status = app.sessions.statuses.value["ses_live"] + assertNotNull(status) + assertEquals("busy", status.type) + } + + @Test + fun `SSE status updates replace previous status`() = runBlocking { + val app = setup() + ready(app) + + mock.awaitSseConnection() + mock.pushEvent( + "session.status", + """{"type":"session.status","properties":{"sessionID":"ses_x","status":{"type":"busy"}}}""", + ) + + withTimeout(5_000) { + app.sessions.statuses.first { it["ses_x"]?.type == "busy" } + } + + mock.pushEvent( + "session.status", + """{"type":"session.status","properties":{"sessionID":"ses_x","status":{"type":"idle"}}}""", + ) + + withTimeout(5_000) { + app.sessions.statuses.first { it["ses_x"]?.type == "idle" } + } + + assertEquals("idle", app.sessions.statuses.value["ses_x"]?.type) + } + + @Test + fun `seed populates status map from server`() = runBlocking { + mock.sessionStatuses = """{ + "ses_a": {"type":"idle","attempt":0,"message":"","next":0,"requestID":""}, + "ses_b": {"type":"busy","attempt":0,"message":"","next":0,"requestID":""} + }""" + val app = setup() + ready(app) + + app.sessions.seed("/test") + assertEquals("idle", app.sessions.statuses.value["ses_a"]?.type) + assertEquals("busy", app.sessions.statuses.value["ses_b"]?.type) + } + + @Test + fun `statuses cleared on stop`() = runBlocking { + mock.sessionStatuses = """{"ses_1":{"type":"busy","attempt":0,"message":"","next":0,"requestID":""}}""" + val app = setup() + ready(app) + + app.sessions.seed("/test") + assertTrue(app.sessions.statuses.value.isNotEmpty()) + + app.sessions.stop() + assertTrue(app.sessions.statuses.value.isEmpty()) + } + + // ------ Concurrency ------ + + @Test + fun `concurrent status updates are not lost`() = runBlocking { + mock.sessionStatuses = "{}" + val app = setup() + ready(app) + + // Seed statuses from multiple coroutines while SSE events arrive + val ids = (1..20).map { "ses_concurrent_$it" } + val half = ids.size / 2 + + // First half: seed via SSE events + mock.awaitSseConnection() + ids.take(half).forEach { id -> + mock.pushEvent( + "session.status", + """{"type":"session.status","properties":{"sessionID":"$id","status":{"type":"busy"}}}""", + ) + } + + // Second half: seed via server-side status endpoint + val statusJson = ids.drop(half).joinToString(",") { id -> + """"$id":{"type":"idle","attempt":0,"message":"","next":0,"requestID":""}""" + } + mock.sessionStatuses = "{$statusJson}" + app.sessions.seed("/test") + + // Wait for all statuses to be present + withTimeout(10_000) { + while (true) { + val statuses = app.sessions.statuses.value + val found = ids.count { it in statuses } + if (found == ids.size) break + delay(100) + } + } + + val statuses = app.sessions.statuses.value + ids.forEach { id -> assertTrue(id in statuses, "Missing status for $id") } + } + + @Test + fun `start after stop re-activates`() = runBlocking { + val app = setup() + ready(app) + + // Verify it works + app.sessions.list("/test") + + // Stop and restart + app.sessions.stop() + assertFailsWith { app.sessions.list("/test") } + + // Re-start (simulate what app service does on reconnect) + app.sessions.start(app.api!!, app.events) + + // CRUD should work again + val result = app.sessions.list("/test") + assertNotNull(result) + } + + // ------ Session with summary ------ + + @Test + fun `session summary maps correctly`() = runBlocking { + mock.sessions = """[{ + "id": "ses_sum", + "slug": "sum", + "projectID": "prj", + "directory": "/d", + "title": "With Summary", + "version": "1", + "time": {"created": 1, "updated": 1}, + "summary": {"additions": 42, "deletions": 7, "files": 3} + }]""" + val app = setup() + ready(app) + + val result = app.sessions.list("/d") + val session = result.sessions[0] + assertNotNull(session.summary) + assertEquals(42, session.summary!!.additions) + assertEquals(7, session.summary!!.deletions) + assertEquals(3, session.summary!!.files) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt new file mode 100644 index 00000000000..12147a8997f --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt @@ -0,0 +1,506 @@ +package ai.kilocode.backend + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.workspace.KiloBackendWorkspace +import ai.kilocode.backend.workspace.KiloWorkspaceState +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KiloBackendWorkspaceTest { + + private val mock = MockCliServer() + private val log = TestLog() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() { + scope.cancel() + mock.close() + } + + private fun setup(): KiloBackendAppService = + KiloBackendAppService.create(scope, FakeCliServer(mock), log) + + private suspend fun ready(app: KiloBackendAppService): KiloBackendWorkspace { + app.connect() + withTimeout(10_000) { + app.appState.first { it is KiloAppState.Ready } + } + return app.workspaces.get("/test/project") + } + + // ------ Workspace manager lifecycle ------ + + @Test + fun `workspace manager throws when not started`() = runBlocking { + val app = setup() + assertFailsWith { + app.workspaces.get("/test") + } + } + + @Test + fun `get creates workspace on demand after Ready`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(1, state.providers.providers.size) + assertEquals("anthropic", state.providers.providers[0].id) + } + + @Test + fun `same directory returns same workspace instance`() = runBlocking { + val app = setup() + app.connect() + withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } } + + val ws1 = app.workspaces.get("/test") + val ws2 = app.workspaces.get("/test") + assertTrue(ws1 === ws2) + } + + @Test + fun `different directories return different workspaces`() = runBlocking { + val app = setup() + app.connect() + withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } } + + val ws1 = app.workspaces.get("/project-a") + val ws2 = app.workspaces.get("/project-b") + assertTrue(ws1 !== ws2) + assertEquals("/project-a", ws1.directory) + assertEquals("/project-b", ws2.directory) + } + + @Test + fun `workspaces stopped on app disconnect`() = runBlocking { + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + app.dispose() + + // Workspace state should be Pending (stopped) + assertIs(ws.state.value) + + // Manager should throw since app is disconnected + assertFailsWith { + app.workspaces.get("/test/project") + } + } + + // ------ Workspace data loading ------ + + @Test + fun `full lifecycle reaches Ready`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(1, state.providers.providers.size) + assertEquals(listOf("anthropic"), state.providers.connected) + assertEquals(1, state.agents.agents.size) + assertEquals("code", state.agents.default) + assertEquals(1, state.commands.size) + assertEquals("clear", state.commands[0].name) + assertEquals(1, state.skills.size) + assertEquals("test-skill", state.skills[0].name) + } + + @Test + fun `workspace reaches Ready after creation`() = runBlocking { + val app = setup() + app.connect() + withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } } + + // get() creates workspace and starts loading immediately + val ws = app.workspaces.get("/test") + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + assertIs(ws.state.value) + } + + // ------ Error handling ------ + + @Test + fun `providers failure retries then transitions to Error`() = runBlocking { + mock.providersStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("providers")) + } + + @Test + fun `agents failure retries then transitions to Error`() = runBlocking { + mock.agentsStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("agents")) + } + + @Test + fun `commands failure transitions to Error`() = runBlocking { + mock.commandsStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("commands")) + } + + @Test + fun `skills failure transitions to Error`() = runBlocking { + mock.skillsStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("skills")) + } + + @Test + fun `partial failure reports failed resources`() = runBlocking { + mock.providersStatus = 500 + mock.skillsStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("providers") || err.message.contains("skills")) + } + + // ------ Reload ------ + + @Test + fun `reload during load produces valid final state`() = runBlocking { + val app = setup() + val ws = ready(app) + + ws.reload() + ws.reload() + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + assertIs(ws.state.value) + } + + // ------ Data mapping ------ + + @Test + fun `providers response maps models correctly`() = runBlocking { + mock.providers = PROVIDERS_JSON + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + val provider = state.providers.providers[0] + assertEquals("anthropic", provider.id) + assertEquals("Anthropic", provider.name) + val model = provider.models["claude-4"] + assertNotNull(model) + assertEquals("Claude 4", model.name) + assertTrue(model.attachment) + assertTrue(model.reasoning) + assertTrue(model.toolCall) + } + + @Test + fun `agents response filters hidden and subagent`() = runBlocking { + mock.agents = """[ + {"name":"code","mode":"primary","permission":[],"options":{}}, + {"name":"helper","mode":"subagent","permission":[],"options":{}}, + {"name":"secret","mode":"primary","hidden":true,"permission":[],"options":{}} + ]""" + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(1, state.agents.agents.size) + assertEquals("code", state.agents.agents[0].name) + assertEquals(3, state.agents.all.size) + assertEquals("code", state.agents.default) + } + + @Test + fun `commands response maps source`() = runBlocking { + mock.commands = """[ + {"name":"clear","template":"","hints":[],"source":"command"}, + {"name":"mcp-tool","template":"","hints":["tool"],"source":"mcp"} + ]""" + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(2, state.commands.size) + assertEquals("command", state.commands[0].source) + assertEquals("mcp", state.commands[1].source) + assertEquals(listOf("tool"), state.commands[1].hints) + } + + @Test + fun `empty responses produce empty Ready`() = runBlocking { + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertTrue(state.providers.providers.isEmpty()) + assertTrue(state.agents.all.isEmpty()) + assertTrue(state.commands.isEmpty()) + assertTrue(state.skills.isEmpty()) + assertEquals("code", state.agents.default) + } + + // ------ Session access through workspace ------ + + @Test + fun `workspace exposes sessions for its directory`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":1,"updated":1}} + ]""" + val app = setup() + val ws = ready(app) + + val result = ws.sessions() + assertEquals(1, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + } + + @Test + fun `workspace creates session in its directory`() = runBlocking { + mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}""" + val app = setup() + val ws = ready(app) + + val session = ws.createSession() + assertEquals("ses_new", session.id) + assertEquals("/test/project", session.directory) + } + + // ------ Concurrency tests ------ + + @Test + fun `concurrent get for same directory returns same instance`() = runBlocking { + val app = setup() + app.connect() + withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } } + + // Launch many concurrent get() calls for the same directory + val results = (1..10).map { + async(Dispatchers.Default) { + app.workspaces.get("/same/dir") + } + }.awaitAll() + + // All must return the exact same instance + val first = results[0] + results.forEach { assertTrue(it === first) } + } + + @Test + fun `concurrent load calls on workspace produce valid final state`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + // Fire rapid reloads — simulates rapid SSE disposed events + repeat(5) { ws.reload() } + + // Final state must be valid Ready + withTimeout(15_000) { + while (true) { + val state = ws.state.value + if (state is KiloWorkspaceState.Ready) { + delay(300) + if (ws.state.value is KiloWorkspaceState.Ready) break + } + delay(100) + } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(1, state.providers.providers.size) + assertEquals(1, state.agents.agents.size) + } + + @Test + fun `SSE global disposed triggers full app reload with new data`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val app = setup() + ready(app) + + // Change providers response then fire disposed event + mock.providers = """{ + "all": [{ + "id": "openai", + "name": "OpenAI", + "env": [], + "models": {} + }], + "default": {}, + "connected": ["openai"] + }""" + + mock.awaitSseConnection() + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + + // global.disposed triggers full app reload which restarts the + // workspace manager (stop + start), clearing all cached workspaces. + // Wait for app to reach Ready again after reload. + withTimeout(15_000) { + // App may briefly leave Ready during reload + while (true) { + val state = app.appState.value + if (state is KiloAppState.Ready) { + delay(300) + if (app.appState.value is KiloAppState.Ready) break + } + delay(100) + } + } + + // Get a fresh workspace — old one was stopped during reload + val ws = app.workspaces.get("/test/project") + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals("openai", state.providers.providers[0].id) + } + + companion object { + private val PROVIDERS_JSON = """{ + "all": [{ + "id": "anthropic", + "name": "Anthropic", + "env": ["ANTHROPIC_API_KEY"], + "models": { + "claude-4": { + "id": "claude-4", + "name": "Claude 4", + "release_date": "2025-05-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": {"context": 200000, "output": 16000}, + "options": {} + } + } + }], + "default": {"code": "anthropic/claude-4"}, + "connected": ["anthropic"] + }""".trimIndent() + + private val AGENTS_JSON = """[ + {"name":"code","displayName":"Code","mode":"primary","permission":[],"options":{}} + ]""".trimIndent() + + private val COMMANDS_JSON = """[ + {"name":"clear","description":"Clear conversation","template":"","hints":[],"source":"command"} + ]""".trimIndent() + + private val SKILLS_JSON = """[ + {"name":"test-skill","description":"A test skill","location":"file:///test","content":"# Test"} + ]""".trimIndent() + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt index 9f51fa364f0..92a3aa7147b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt @@ -173,4 +173,49 @@ class KiloConnectionServiceTest { svc.dispose() assertEquals(ConnectionState.Disconnected, svc.state.value) } + + // ------ Reconnect & health ------ + + @Test + fun `restart tears down and reconnects`() = runBlocking { + val svc = KiloConnectionService(scope, fake, {}, log) + svc.connect() + mock.awaitSseConnection() + + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + + // Restart should tear down and re-open + svc.restart() + + // Wait for reconnection + withTimeout(10_000) { + svc.state.first { it is ConnectionState.Connected } + } + assertIs(svc.state.value) + assertTrue(svc.api != null) + } + + @Test + fun `health check failure triggers reconnect`() = runBlocking { + // Start healthy then switch to failing health + val svc = KiloConnectionService(scope, fake, {}, log) + svc.connect() + mock.awaitSseConnection() + + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + + // Make health checks fail — the health loop runs every 10s, so we + // close the SSE first (which triggers immediate reconnect path) + mock.health = """{"healthy": false}""" + mock.closeSse() + + // Should transition away from Connected + withTimeout(10_000) { + svc.state.first { it !is ConnectionState.Connected } + } + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt new file mode 100644 index 00000000000..99fc7894a1c --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt @@ -0,0 +1,250 @@ +package ai.kilocode.backend + +import ai.kilocode.jetbrains.api.infrastructure.Serializer +import ai.kilocode.jetbrains.api.model.Agent +import ai.kilocode.jetbrains.api.model.AppSkills200ResponseInner +import ai.kilocode.jetbrains.api.model.Command +import ai.kilocode.jetbrains.api.model.ProviderList200Response +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Verifies that project-level API model classes serialize/deserialize + * correctly using the production [Serializer.kotlinxSerializationJson]. + */ +class ProjectModelSerializationTest { + + private val json = Serializer.kotlinxSerializationJson + + // ------ ProviderList200Response ------ + + @Test + fun `empty provider list`() { + val src = """{"all":[],"default":{},"connected":[]}""" + val obj = json.decodeFromString(src) + assertTrue(obj.all.isEmpty()) + assertTrue(obj.default.isEmpty()) + assertTrue(obj.connected.isEmpty()) + } + + @Test + fun `provider list with models`() { + val src = """{ + "all": [{ + "id": "anthropic", + "name": "Anthropic", + "env": ["ANTHROPIC_API_KEY"], + "models": { + "claude-4": { + "id": "claude-4", + "name": "Claude 4", + "release_date": "2025-05-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": {"context": 200000, "output": 16000}, + "options": {} + } + } + }], + "default": {"code": "anthropic/claude-4"}, + "connected": ["anthropic"] + }""" + val obj = json.decodeFromString(src) + assertEquals(1, obj.all.size) + assertEquals("anthropic", obj.all[0].id) + assertEquals("Anthropic", obj.all[0].name) + val model = obj.all[0].models["claude-4"] + assertNotNull(model) + assertEquals("Claude 4", model.name) + assertTrue(model.attachment) + assertTrue(model.reasoning) + assertTrue(model.toolCall) + assertEquals("anthropic/claude-4", obj.default["code"]) + assertEquals(listOf("anthropic"), obj.connected) + } + + @Test + fun `provider list with isFree and status`() { + val src = """{ + "all": [{ + "id": "free-provider", + "name": "Free", + "env": [], + "models": { + "free-model": { + "id": "free-model", + "name": "Free Model", + "release_date": "2025-01-01", + "attachment": false, + "reasoning": false, + "temperature": false, + "tool_call": false, + "isFree": true, + "status": "beta", + "limit": {"context": 8000, "output": 4000}, + "options": {} + } + } + }], + "default": {}, + "connected": [] + }""" + val obj = json.decodeFromString(src) + val model = obj.all[0].models["free-model"]!! + assertEquals(true, model.isFree) + assertEquals( + ai.kilocode.jetbrains.api.model.ProviderList200ResponseAllInnerModelsValue.Status.BETA, + model.status, + ) + } + + @Test + fun `provider list ignores unknown fields`() { + val src = """{ + "all": [], + "default": {}, + "connected": [], + "future_field": "value" + }""" + val obj = json.decodeFromString(src) + assertTrue(obj.all.isEmpty()) + } + + // ------ Agent ------ + + @Test + fun `agent with all modes`() { + for (mode in listOf("primary", "subagent", "all")) { + val src = """{"name":"test","mode":"$mode","permission":[],"options":{}}""" + val obj = json.decodeFromString(src) + assertEquals("test", obj.name) + assertEquals(mode, obj.mode.value) + } + } + + @Test + fun `agent with optional fields`() { + val src = """{ + "name": "code", + "displayName": "Code Agent", + "description": "Writes code", + "mode": "primary", + "native": true, + "hidden": false, + "color": "#FF5733", + "deprecated": false, + "permission": [], + "options": {}, + "steps": 5 + }""" + val obj = json.decodeFromString(src) + assertEquals("Code Agent", obj.displayName) + assertEquals("Writes code", obj.description) + assertEquals(true, obj.native) + assertEquals(false, obj.hidden) + assertEquals("#FF5733", obj.color) + assertEquals(5, obj.steps) + } + + @Test + fun `agent minimal`() { + val src = """{"name":"ask","mode":"primary","permission":[],"options":{}}""" + val obj = json.decodeFromString(src) + assertEquals("ask", obj.name) + assertNull(obj.displayName) + assertNull(obj.hidden) + } + + @Test + fun `agent list`() { + val src = """[ + {"name":"code","mode":"primary","permission":[],"options":{}}, + {"name":"helper","mode":"subagent","hidden":true,"permission":[],"options":{}} + ]""" + val list = json.decodeFromString>(src) + assertEquals(2, list.size) + assertEquals(Agent.Mode.PRIMARY, list[0].mode) + assertEquals(Agent.Mode.SUBAGENT, list[1].mode) + assertEquals(true, list[1].hidden) + } + + // ------ Command ------ + + @Test + fun `command with source enum`() { + for ((src, expected) in listOf( + "command" to Command.Source.COMMAND, + "mcp" to Command.Source.MCP, + "skill" to Command.Source.SKILL, + )) { + val json = """{"name":"test","template":"","hints":[],"source":"$src"}""" + val obj = this.json.decodeFromString(json) + assertEquals(expected, obj.source) + } + } + + @Test + fun `command with hints`() { + val src = """{ + "name": "clear", + "description": "Clear the conversation", + "template": "", + "hints": ["conversation", "reset"], + "source": "command" + }""" + val obj = json.decodeFromString(src) + assertEquals("clear", obj.name) + assertEquals("Clear the conversation", obj.description) + assertEquals(listOf("conversation", "reset"), obj.hints) + } + + @Test + fun `command list`() { + val src = """[ + {"name":"clear","template":"","hints":[]}, + {"name":"compact","description":"Compact context","template":"","hints":["context"],"source":"command"} + ]""" + val list = json.decodeFromString>(src) + assertEquals(2, list.size) + assertNull(list[0].source) + assertEquals(Command.Source.COMMAND, list[1].source) + } + + // ------ AppSkills200ResponseInner ------ + + @Test + fun `skill roundtrip`() { + val src = """{ + "name": "vscode-visual-regression", + "description": "Write visual regression tests", + "location": "file:///path/to/SKILL.md", + "content": "# Skill content" + }""" + val obj = json.decodeFromString(src) + assertEquals("vscode-visual-regression", obj.name) + assertEquals("Write visual regression tests", obj.description) + assertEquals("file:///path/to/SKILL.md", obj.location) + assertEquals("# Skill content", obj.content) + } + + @Test + fun `skill list`() { + val src = """[ + {"name":"s1","description":"d1","location":"l1","content":"c1"}, + {"name":"s2","description":"d2","location":"l2","content":"c2"} + ]""" + val list = json.decodeFromString>(src) + assertEquals(2, list.size) + } + + @Test + fun `empty skill list`() { + val list = json.decodeFromString>("[]") + assertTrue(list.isEmpty()) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt new file mode 100644 index 00000000000..765433497b1 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt @@ -0,0 +1,165 @@ +package ai.kilocode.backend + +import ai.kilocode.jetbrains.api.infrastructure.Serializer +import ai.kilocode.jetbrains.api.model.Session +import ai.kilocode.jetbrains.api.model.SessionStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Verifies that session-related API model classes serialize/deserialize + * correctly using the production [Serializer.kotlinxSerializationJson]. + */ +class SessionModelSerializationTest { + + private val json = Serializer.kotlinxSerializationJson + + @Test + fun `Session minimal`() { + val src = """{ + "id": "ses_abc", + "slug": "test-session", + "projectID": "prj_123", + "directory": "/test/project", + "title": "My Session", + "version": "1.0.0", + "time": {"created": 1000.0, "updated": 2000.0} + }""" + val obj = json.decodeFromString(src) + assertEquals("ses_abc", obj.id) + assertEquals("test-session", obj.slug) + assertEquals("prj_123", obj.projectID) + assertEquals("/test/project", obj.directory) + assertEquals("My Session", obj.title) + assertEquals(1000.0, obj.time.created) + assertEquals(2000.0, obj.time.updated) + assertNull(obj.parentID) + assertNull(obj.summary) + } + + @Test + fun `Session with summary`() { + val src = """{ + "id": "ses_1", + "slug": "s", + "projectID": "prj_1", + "directory": "/d", + "title": "T", + "version": "1.0.0", + "time": {"created": 1.0, "updated": 2.0}, + "summary": {"additions": 10, "deletions": 5, "files": 3} + }""" + val obj = json.decodeFromString(src) + assertNotNull(obj.summary) + assertEquals(10.0, obj.summary!!.additions) + assertEquals(5.0, obj.summary!!.deletions) + assertEquals(3.0, obj.summary!!.files) + } + + @Test + fun `Session with parentID and archived`() { + val src = """{ + "id": "ses_child", + "slug": "child", + "projectID": "prj_1", + "directory": "/d", + "title": "Fork", + "version": "1.0.0", + "time": {"created": 1.0, "updated": 2.0, "archived": 3000.0}, + "parentID": "ses_parent" + }""" + val obj = json.decodeFromString(src) + assertEquals("ses_parent", obj.parentID) + assertEquals(3000.0, obj.time.archived) + } + + @Test + fun `Session list`() { + val src = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/d","title":"A","version":"1","time":{"created":1,"updated":1}}, + {"id":"ses_2","slug":"s2","projectID":"prj","directory":"/d","title":"B","version":"1","time":{"created":2,"updated":2}} + ]""" + val list = json.decodeFromString>(src) + assertEquals(2, list.size) + assertEquals("ses_1", list[0].id) + assertEquals("ses_2", list[1].id) + } + + @Test + fun `empty session list`() { + val list = json.decodeFromString>("[]") + assertTrue(list.isEmpty()) + } + + @Test + fun `Session ignores unknown fields`() { + val src = """{ + "id": "ses_x", + "slug": "x", + "projectID": "prj_x", + "directory": "/x", + "title": "X", + "version": "1", + "time": {"created": 1, "updated": 1}, + "unknown_field": "value", + "nested": {"a": 1} + }""" + val obj = json.decodeFromString(src) + assertEquals("ses_x", obj.id) + } + + // ------ SessionStatus ------ + + @Test + fun `SessionStatus idle`() { + val src = """{"type":"idle","attempt":0,"message":"","next":0,"requestID":""}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.IDLE, obj.type) + } + + @Test + fun `SessionStatus busy`() { + val src = """{"type":"busy","attempt":0,"message":"","next":0,"requestID":""}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.BUSY, obj.type) + } + + @Test + fun `SessionStatus retry`() { + val src = """{"type":"retry","attempt":2,"message":"Rate limited","next":1500,"requestID":"req_1"}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.RETRY, obj.type) + assertEquals(2.0, obj.attempt) + assertEquals("Rate limited", obj.message) + assertEquals(1500.0, obj.next) + } + + @Test + fun `SessionStatus offline`() { + val src = """{"type":"offline","attempt":0,"message":"Waiting for response","next":0,"requestID":"req_abc"}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.OFFLINE, obj.type) + assertEquals("req_abc", obj.requestID) + } + + @Test + fun `SessionStatus map`() { + val src = """{ + "ses_1": {"type":"idle","attempt":0,"message":"","next":0,"requestID":""}, + "ses_2": {"type":"busy","attempt":0,"message":"","next":0,"requestID":""} + }""" + val map = json.decodeFromString>(src) + assertEquals(2, map.size) + assertEquals(SessionStatus.Type.IDLE, map["ses_1"]!!.type) + assertEquals(SessionStatus.Type.BUSY, map["ses_2"]!!.type) + } + + @Test + fun `empty status map`() { + val map = json.decodeFromString>("{}") + assertTrue(map.isEmpty()) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 45f1c1e9a44..60b27d1fa9a 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -10,6 +10,8 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.ConcurrentHashMap /** * Lightweight mock HTTP server simulating the Kilo CLI server. @@ -34,6 +36,38 @@ class MockCliServer : AutoCloseable { @Volatile var configStatus = 200 @Volatile var notificationsStatus = 200 + // Project-scoped REST responses + @Volatile var providers = """{"all":[],"default":{},"connected":[]}""" + @Volatile var agents = "[]" + @Volatile var commands = "[]" + @Volatile var skills = "[]" + @Volatile var providersStatus = 200 + @Volatile var agentsStatus = 200 + @Volatile var commandsStatus = 200 + @Volatile var skillsStatus = 200 + + // Session REST responses + @Volatile var sessions = "[]" + @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" + @Volatile var sessionStatuses = "{}" + @Volatile var sessionsStatus = 200 + @Volatile var sessionCreateStatus = 200 + @Volatile var sessionGetStatus = 200 + @Volatile var sessionDeleteStatus = 200 + @Volatile var sessionStatusesStatus = 200 + + /** Configurable delay for all endpoint responses (ms). 0 = no delay. */ + @Volatile var responseDelay: Long = 0 + + /** Request counts by bare path (e.g. "/session" or "/global/config"). Thread-safe. */ + private val counts = ConcurrentHashMap() + + /** Return the number of requests received for [path] (bare, no query). */ + fun requestCount(path: String): Int = counts[path]?.get() ?: 0 + + /** Reset all request counters. */ + fun resetCounts() { counts.clear() } + private val executor = Executors.newCachedThreadPool { r -> Thread(r, "mock-cli-${Thread.currentThread().id}").apply { isDaemon = true } } @@ -128,6 +162,7 @@ class MockCliServer : AutoCloseable { val line = input.readLine() ?: return val parts = line.split(" ") if (parts.size < 2) return + val method = parts[0] val path = parts[1] // Read all headers @@ -137,6 +172,14 @@ class MockCliServer : AutoCloseable { } val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream())) + val bare = path.substringBefore("?") + + // Track request counts + counts.computeIfAbsent(bare) { AtomicInteger(0) }.incrementAndGet() + + // Optional delay for race condition testing + val delay = responseDelay + if (delay > 0) Thread.sleep(delay) when { path == "/global/health" -> respond(output, 200, health) @@ -150,6 +193,17 @@ class MockCliServer : AutoCloseable { } } path == "/global/event" -> handleSse(output) + bare == "/provider" -> respond(output, providersStatus, providers) + bare == "/agent" -> respond(output, agentsStatus, agents) + bare == "/command" -> respond(output, commandsStatus, commands) + bare == "/skill" -> respond(output, skillsStatus, skills) + bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses) + bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions) + bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate) + bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" -> + respond(output, sessionGetStatus, sessionCreate) + bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" -> + respond(output, sessionDeleteStatus, "true") else -> respond(output, 404, """{"error":"Not found"}""") } } catch (_: SocketException) { diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt index 4e291139a26..75c1b9f3a5b 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt @@ -1,6 +1,6 @@ import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty -import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import java.io.File @@ -22,7 +22,7 @@ import java.io.File * Replaced with `kotlinx.serialization.json.JsonElement`. */ abstract class FixGeneratedApiTask : DefaultTask() { - @get:InputDirectory + @get:OutputDirectory abstract val generated: DirectoryProperty @TaskAction @@ -120,7 +120,51 @@ abstract class FixGeneratedApiTask : DefaultTask() { } } - // Fix 6: AnySerializer in Serializer.kt + // Fix 6: Lenient JSON — tolerate missing fields and absent nulls so the + // generated models survive API responses with optional fields the spec + // marks as required. `coerceInputValues` maps type mismatches to + // defaults; `explicitNulls = false` allows omitted nullable fields. + if (file.name == "Serializer.kt") { + if (!text.contains("coerceInputValues")) { + text = text.replace( + "ignoreUnknownKeys = true", + "ignoreUnknownKeys = true\n coerceInputValues = true\n explicitNulls = false" + ) + changed = true + } + } + + // Fix 7: Default values for non-nullable primitives in model data classes. + // The CLI API may omit fields that the OpenAPI spec marks as required + // (e.g. `attachment`, `reasoning` on dynamically added models). + // Add Kotlin defaults so kotlinx.serialization doesn't throw + // MissingFieldException. + if (text.contains("data class") && text.contains("@Serializable")) { + // Pattern: `val foo: kotlin.Boolean,` or `val foo: kotlin.Boolean\n` + // (without ` = ` before the comma/newline, which would mean a default exists) + val primitiveDefaults = listOf( + Regex("""(val \w+:\s*kotlin\.Boolean)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = false${m.groupValues[2]}" + }, + Regex("""(val \w+:\s*kotlin\.Int)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = 0${m.groupValues[2]}" + }, + Regex("""(val \w+:\s*kotlin\.Double)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = 0.0${m.groupValues[2]}" + }, + Regex("""(val \w+:\s*kotlin\.String)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = \"\"${m.groupValues[2]}" + }, + ) + for ((pattern, transform) in primitiveDefaults) { + if (pattern.containsMatchIn(text)) { + text = pattern.replace(text, transform) + changed = true + } + } + } + + // Fix 8: AnySerializer in Serializer.kt if (file.name == "Serializer.kt" && !text.contains("AnySerializer")) { text = text.replace( "import kotlinx.serialization.modules.SerializersModuleBuilder", diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 3f7698cd3c3..4b3e02952db 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -9,6 +9,7 @@ plugins { application id("java") alias(libs.plugins.intellij.platform) + alias(libs.plugins.detekt) alias(libs.plugins.kotlin) apply false alias(libs.plugins.kotlin.serialization) apply false @@ -17,6 +18,19 @@ plugins { subprojects { apply(plugin = "org.jetbrains.intellij.platform.module") + apply(plugin = "io.gitlab.arturbosch.detekt") + + detekt { + config.setFrom(rootProject.file("detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/kotlin") + } +} + +detekt { + config.setFrom(file("detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/kotlin") } allprojects { diff --git a/packages/kilo-jetbrains/detekt.yml b/packages/kilo-jetbrains/detekt.yml new file mode 100644 index 00000000000..e5e889fa4d8 --- /dev/null +++ b/packages/kilo-jetbrains/detekt.yml @@ -0,0 +1,30 @@ +# Detekt configuration for kilo-jetbrains. +# +# Enforces file size and cyclomatic complexity limits — the Kotlin +# equivalent of the ESLint complexity/max-lines rules used by the +# VS Code extension (packages/kilo-vscode/eslint.config.mjs). +# +# New code must stay within the default limits. Do not raise these +# caps; refactor instead. + +complexity: + CyclomaticComplexMethod: + active: true + threshold: 15 + LongMethod: + active: true + threshold: 60 + LargeClass: + active: true + threshold: 600 + TooManyFunctions: + active: true + threshold: 15 + ComplexCondition: + active: true + threshold: 4 + LongParameterList: + active: false + NestedBlockDepth: + active: true + threshold: 4 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt index 60bee94264a..6d5122a8fac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt @@ -12,10 +12,9 @@ import fleet.rpc.client.durable import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** @@ -41,13 +40,8 @@ class KiloAppService(private val cs: CoroutineScope) { var version: String? = null private set - val state: StateFlow = flow { - durable { - KiloAppRpcApi.getInstance() - .state() - .collect { emit(it) } - } - }.stateIn(cs, SharingStarted.Eagerly, init) + private val _state = MutableStateFlow(init) + val state: StateFlow = _state.asStateFlow() fun connect() { if (!started.compareAndSet(false, true)) return @@ -56,6 +50,13 @@ class KiloAppService(private val cs: CoroutineScope) { KiloAppRpcApi.getInstance().connect() } } + cs.launch { + durable { + KiloAppRpcApi.getInstance() + .state() + .collect { _state.value = it } + } + } } /** One-shot health check. Returns null on failure. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt new file mode 100644 index 00000000000..22914195e8a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt @@ -0,0 +1,57 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client + +import ai.kilocode.rpc.KiloProjectRpcApi +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Project-level frontend service that provides reactive access + * to project-scoped data (providers, agents, commands, skills). + * + * Communicates with the backend via [KiloProjectRpcApi]. The flow + * is collected eagerly so that data is available as soon as the + * backend finishes loading. + */ +@Service(Service.Level.PROJECT) +class KiloProjectService( + private val project: Project, + private val cs: CoroutineScope, +) { + companion object { + private val LOG = Logger.getInstance(KiloProjectService::class.java) + private val init = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + } + + private val directory: String get() = project.basePath ?: "" + + val state: StateFlow = flow { + durable { + KiloProjectRpcApi.getInstance() + .state(directory) + .collect { emit(it) } + } + }.stateIn(cs, SharingStarted.Eagerly, init) + + /** Trigger a full reload of all project data. */ + fun reload() { + cs.launch { + try { + durable { KiloProjectRpcApi.getInstance().reload(directory) } + } catch (e: Exception) { + LOG.warn("project data reload failed", e) + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt new file mode 100644 index 00000000000..1dee6d12d99 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt @@ -0,0 +1,115 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client + +import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Project-level frontend service for session management. + * + * Provides session CRUD, active session tracking, and live + * status updates via [KiloSessionRpcApi]. All operations are + * scoped to the project's [directory] by default, with support + * for per-session worktree directory overrides. + */ +@Service(Service.Level.PROJECT) +class KiloSessionService( + private val project: Project, + private val cs: CoroutineScope, +) { + companion object { + private val LOG = Logger.getInstance(KiloSessionService::class.java) + } + + private val directory: String get() = project.basePath ?: "" + + private val _sessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = _sessions.asStateFlow() + + private val _active = MutableStateFlow(null) + val active: StateFlow = _active.asStateFlow() + + /** Live session status map from SSE events. */ + val statuses: StateFlow> = flow { + durable { + KiloSessionRpcApi.getInstance() + .statuses() + .collect { emit(it) } + } + }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + + /** Refresh the session list from the server. */ + fun refresh() { + cs.launch { + try { + val result = durable { KiloSessionRpcApi.getInstance().list(directory) } + _sessions.value = result.sessions + } catch (e: Exception) { + LOG.warn("session list failed", e) + } + } + } + + /** Create a new session and make it active. */ + fun create() { + cs.launch { + try { + val session = durable { KiloSessionRpcApi.getInstance().create(directory) } + _active.value = session + refresh() + } catch (e: Exception) { + LOG.warn("session create failed", e) + } + } + } + + /** Select an existing session as active. */ + fun select(id: String) { + cs.launch { + try { + val session = durable { KiloSessionRpcApi.getInstance().get(id, directory) } + _active.value = session + } catch (e: Exception) { + LOG.warn("session select failed", e) + } + } + } + + /** Delete a session. Clears active if it was the deleted one. */ + fun delete(id: String) { + cs.launch { + try { + durable { KiloSessionRpcApi.getInstance().delete(id, directory) } + if (_active.value?.id == id) _active.value = null + refresh() + } catch (e: Exception) { + LOG.warn("session delete failed", e) + } + } + } + + /** Register a worktree directory override for a session. */ + fun setDirectory(id: String, dir: String) { + cs.launch { + try { + durable { KiloSessionRpcApi.getInstance().setDirectory(id, dir) } + } catch (e: Exception) { + LOG.warn("setDirectory failed", e) + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 71330ca5424..dad0fd07170 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -1,160 +1,44 @@ package ai.kilocode.client -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.LoadErrorDto -import ai.kilocode.rpc.dto.LoadProgressDto -import ai.kilocode.rpc.dto.ProfileStatusDto -import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project -import com.intellij.openapi.util.IconLoader -import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory -import com.intellij.openapi.wm.ToolWindowManager -import com.intellij.ui.components.JBLabel import com.intellij.ui.content.ContentFactory -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.GridBagConstraints -import java.awt.GridBagLayout -import javax.swing.Box -import javax.swing.BoxLayout -import javax.swing.JPanel -import javax.swing.SwingConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +/** + * Creates the Kilo Code tool window content. + * + * Wires [KiloAppService] and [KiloProjectService] into a + * [KiloWelcomeUi] panel that shows app + workspace initialization + * status. All UI logic lives in [KiloWelcomeUi]. + */ class KiloToolWindowFactory : ToolWindowFactory { - override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val svc = service() - val mgr = ToolWindowManager.getInstance(project) - val icon = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", KiloToolWindowFactory::class.java), - ).apply { - horizontalAlignment = SwingConstants.CENTER - alignmentX = JPanel.CENTER_ALIGNMENT - } - - val status = JBLabel( - KiloBundle.message("toolwindow.status.disconnected"), - SwingConstants.CENTER, - ).apply { - alignmentX = JPanel.CENTER_ALIGNMENT - font = JBUI.Fonts.label(13f) - foreground = UIUtil.getContextHelpForeground() - setAllowAutoWrapping(true) - } - - val detail = JBLabel("", SwingConstants.CENTER).apply { - alignmentX = JPanel.CENTER_ALIGNMENT - font = JBUI.Fonts.smallFont() - foreground = UIUtil.getContextHelpForeground() - setAllowAutoWrapping(true) - setCopyable(true) - } - - val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - add(icon) - add(Box.createVerticalStrut(JBUI.scale(16))) - add(status) - add(Box.createVerticalStrut(JBUI.scale(6))) - add(detail) - } - - val panel = JPanel(GridBagLayout()).apply { - isOpaque = false - add(body, GridBagConstraints()) - } - - val content = ContentFactory.getInstance().createContent(panel, "", false) - val ui = Disposer.newDisposable() - val job = svc.watch { state -> - mgr.invokeLater { - status.text = title(state) - detail.text = details(state) - detail.isVisible = detail.text.isNotEmpty() - } - } - Disposer.register(ui, Disposable { job.cancel() }) - content.setDisposer(ui) - toolWindow.contentManager.addContent(content) - ActionManager.getInstance().getAction("Kilo.Settings")?.let { - toolWindow.setTitleActions(listOf(it)) - } - svc.connect() - } - - private fun title(state: KiloAppStateDto): String = - when (state.status) { - KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected") - KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") - KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") - KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected") - KiloAppStatusDto.ERROR -> KiloBundle.message( - "toolwindow.status.error", - state.error ?: KiloBundle.message("toolwindow.error.unknown"), - ) - } - - private fun details(state: KiloAppStateDto): String = - when (state.status) { - KiloAppStatusDto.LOADING -> progress(state.progress) - KiloAppStatusDto.READY -> ready(state) - KiloAppStatusDto.ERROR -> errors(state) - else -> "" - } - - private fun progress(p: LoadProgressDto?): String { - if (p == null) return "" - val lines = mutableListOf() - lines.add(item("Config", p.config)) - lines.add(item("Notifications", p.notifications)) - lines.add(profile(p.profile)) - return "${lines.joinToString("
")}" - } - - private fun item(name: String, loaded: Boolean): String = - if (loaded) "$CHECK $name" else "$DOTS $name" - - private fun profile(status: ProfileStatusDto): String = - when (status) { - ProfileStatusDto.PENDING -> "$DOTS Profile" - ProfileStatusDto.LOADED -> "$CHECK Profile" - ProfileStatusDto.NOT_LOGGED_IN -> "$DASH Profile (not logged in)" - } - - private fun ready(state: KiloAppStateDto): String { - val svc = service() - val ver = svc.version - val lines = mutableListOf() - if (ver != null) lines.add("CLI: $ver") - val p = state.progress - if (p != null && p.profile == ProfileStatusDto.NOT_LOGGED_IN) { - lines.add("Profile: not logged in") - } - return if (lines.isEmpty()) "" else "${lines.joinToString("
")}" - } - - private fun errors(state: KiloAppStateDto): String { - if (state.errors.isEmpty()) return "" - val lines = state.errors.map(::formatError) - return "Failed to load:
${lines.joinToString("
")}" - } - - private fun formatError(err: LoadErrorDto): String { - val suffix = err.detail ?: err.status?.let { "HTTP $it" } ?: "" - return if (suffix.isEmpty()) "$CROSS ${err.resource}" - else "$CROSS ${err.resource}: $suffix" - } companion object { - private const val CHECK = "\u2713" - private const val CROSS = "\u2717" - private const val DOTS = "\u2026" - private const val DASH = "\u2013" + private val LOG = Logger.getInstance(KiloToolWindowFactory::class.java) + } + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + try { + val app = service() + val workspace = project.service() + val scope = CoroutineScope(SupervisorJob()) + val ui = KiloWelcomeUi(app, workspace, scope) + + val content = ContentFactory.getInstance().createContent(ui, "", false) + content.setDisposer(ui) + toolWindow.contentManager.addContent(content) + + ActionManager.getInstance().getAction("Kilo.Settings")?.let { + toolWindow.setTitleActions(listOf(it)) + } + } catch (e: Exception) { + LOG.error("Failed to create Kilo tool window content", e) + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt new file mode 100644 index 00000000000..75a8c6764fe --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt @@ -0,0 +1,338 @@ +package ai.kilocode.client + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.ProfileStatusDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.Icon +import javax.swing.JPanel +import javax.swing.SwingConstants + +/** + * Welcome panel showing app + workspace initialization progress. + * + * Uses icon+label rows for each resource being loaded. Icons act as + * status indicators: animated spinner for loading, green check for + * success, red circle for error, grey circle for idle. + */ +class KiloWelcomeUi( + private val app: KiloAppService, + private val workspace: KiloProjectService, + private val cs: CoroutineScope, +) : JPanel(GridBagLayout()), Disposable { + + // ------ status icons ------ + + private val iconLoading: Icon = AnimatedIcon.Default() + private val iconOk: Icon = AllIcons.RunConfigurations.TestPassed + private val iconError: Icon = AllIcons.RunConfigurations.TestFailed + private val iconWarn: Icon = AllIcons.General.Warning + private val iconIdle: Icon = AllIcons.RunConfigurations.TestNotRan + + // ------ header ------ + + private val logo = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", KiloWelcomeUi::class.java), + ).apply { + alignmentX = CENTER_ALIGNMENT + } + + private val status = JBLabel().apply { + alignmentX = CENTER_ALIGNMENT + horizontalAlignment = SwingConstants.CENTER + font = JBUI.Fonts.label(13f) + foreground = UIUtil.getLabelForeground() + } + + // ------ app rows ------ + + private val configRow = row("Config") + private val notifRow = row("Notifications") + private val profileRow = row("Profile") + + // ------ workspace rows ------ + + private val providersRow = row("Providers") + private val agentsRow = row("Agents") + private val commandsRow = row("Commands") + private val skillsRow = row("Skills") + + // ------ section headers ------ + + private val appHeader = header("App") + private val wsHeader = header("Workspace") + + // Section panels group header + rows with left-aligned content. + // Each section is center-aligned as a block in the outer layout. + private val appSection = section(appHeader, configRow, notifRow, profileRow) + private val wsSection = section(wsHeader, providersRow, agentsRow, commandsRow, skillsRow) + + private var appJob: Job? = null + private var wsJob: Job? = null + + init { + isOpaque = false + + val body = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + border = JBUI.Borders.empty(12, 16) + + add(logo) + add(Box.createVerticalStrut(JBUI.scale(12))) + add(status) + add(Box.createVerticalStrut(JBUI.scale(12))) + add(appSection) + add(Box.createVerticalStrut(JBUI.scale(12))) + add(wsSection) + } + + // GridBagLayout with default constraints centers the body + // both vertically and horizontally in the tool window. + add(body, GridBagConstraints()) + + // Initial state: everything idle + resetAll() + + appJob = app.watch { state -> + edt { renderApp(state) } + } + + wsJob = cs.launch { + workspace.state.collect { state -> + edt { renderWorkspace(state) } + } + } + + app.connect() + } + + override fun dispose() { + appJob?.cancel() + wsJob?.cancel() + cs.cancel() + } + + // ------ rendering ------ + + private fun renderApp(state: KiloAppStateDto) { + status.text = title(state) + + when (state.status) { + KiloAppStatusDto.DISCONNECTED -> { + resetAll() + } + KiloAppStatusDto.CONNECTING -> { + configRow.loading() + notifRow.loading() + profileRow.loading() + } + KiloAppStatusDto.LOADING -> { + val p = state.progress + if (p != null) { + if (p.config) configRow.ok("Config") else configRow.loading() + if (p.notifications) notifRow.ok("Notifications") else notifRow.loading() + renderProfile(p.profile) + } + } + KiloAppStatusDto.READY -> { + val p = state.progress + if (p != null) { + configRow.ok("Config") + notifRow.ok("Notifications") + renderProfile(p.profile) + } else { + configRow.ok("Config") + notifRow.ok("Notifications") + profileRow.ok("Logged in") + } + } + KiloAppStatusDto.ERROR -> { + val errors = state.errors.associate { it.resource to it } + configRow.apply { + if ("config" in errors) error("Config: ${errors["config"]?.detail ?: "failed"}") + else ok("Config") + } + notifRow.apply { + if ("notifications" in errors) error("Notifications: ${errors["notifications"]?.detail ?: "failed"}") + else ok("Notifications") + } + profileRow.apply { + if ("profile" in errors) error("Profile: ${errors["profile"]?.detail ?: "failed"}") + else ok("Logged in") + } + } + } + } + + private fun renderWorkspace(state: KiloWorkspaceStateDto) { + val appReady = app.state.value.status == KiloAppStatusDto.READY + val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING + wsSection.isVisible = visible + if (!visible) return + + when (state.status) { + KiloWorkspaceStatusDto.PENDING -> { + providersRow.idle("Providers") + agentsRow.idle("Agents") + commandsRow.idle("Commands") + skillsRow.idle("Skills") + } + KiloWorkspaceStatusDto.LOADING -> { + val p = state.progress + if (p != null) { + if (p.providers) providersRow.ok("Providers") else providersRow.loading() + if (p.agents) agentsRow.ok("Agents") else agentsRow.loading() + if (p.commands) commandsRow.ok("Commands") else commandsRow.loading() + if (p.skills) skillsRow.ok("Skills") else skillsRow.loading() + } else { + providersRow.loading() + agentsRow.loading() + commandsRow.loading() + skillsRow.loading() + } + } + KiloWorkspaceStatusDto.READY -> { + val prov = state.providers?.providers?.size ?: 0 + val ag = state.agents?.all?.size ?: 0 + val cmd = state.commands.size + val sk = state.skills.size + providersRow.ok("Providers ($prov)") + agentsRow.ok("Agents ($ag)") + commandsRow.ok("Commands ($cmd)") + skillsRow.ok("Skills ($sk)") + } + KiloWorkspaceStatusDto.ERROR -> { + val msg = state.error ?: "Unknown error" + providersRow.error(msg) + agentsRow.idle("Agents") + commandsRow.idle("Commands") + skillsRow.idle("Skills") + } + } + } + + // ------ helpers ------ + + private fun title(state: KiloAppStateDto): String = + when (state.status) { + KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected") + KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") + KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") + KiloAppStatusDto.READY -> { + val ver = app.version + if (ver != null) "Connected (CLI $ver)" else KiloBundle.message("toolwindow.status.connected") + } + KiloAppStatusDto.ERROR -> KiloBundle.message( + "toolwindow.status.error", + state.error ?: KiloBundle.message("toolwindow.error.unknown"), + ) + } + + private fun renderProfile(profile: ProfileStatusDto) { + when (profile) { + ProfileStatusDto.LOADED -> profileRow.ok("Logged in") + ProfileStatusDto.NOT_LOGGED_IN -> profileRow.warn("Not logged in") + ProfileStatusDto.PENDING -> profileRow.loading("Profile") + } + } + + private fun resetAll() { + configRow.idle("Config") + notifRow.idle("Notifications") + profileRow.idle("Profile") + providersRow.idle("Providers") + agentsRow.idle("Agents") + commandsRow.idle("Commands") + skillsRow.idle("Skills") + } + + private fun edt(block: () -> Unit) { + ApplicationManager.getApplication().invokeLater(block) + } + + // ------ row factory ------ + + private fun row(text: String): StatusRow = StatusRow(text, iconIdle) + + private fun header(text: String): JBLabel = JBLabel(text).apply { + alignmentX = LEFT_ALIGNMENT + font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or java.awt.Font.BOLD) + foreground = UIUtil.getLabelForeground() + border = JBUI.Borders.empty(0, 0, 4, 0) + } + + /** + * Groups a header and rows into a left-aligned block that + * is centered as a unit inside the outer BoxLayout. + */ + private fun section(hdr: JBLabel, vararg rows: StatusRow): JPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(hdr) + for (r in rows) add(r.label) + } + + /** + * A single status row: icon on the left, label on the right. + * Mutate via [ok], [loading], [error], [idle]. + */ + inner class StatusRow(text: String, icon: Icon) { + val label = JBLabel(text, icon, SwingConstants.LEFT).apply { + font = JBUI.Fonts.label() + foreground = UIUtil.getContextHelpForeground() + iconTextGap = JBUI.scale(6) + border = JBUI.Borders.empty(2, 0) + alignmentX = LEFT_ALIGNMENT + } + + fun ok(msg: String, ic: Icon = iconOk) { + label.icon = ic + label.text = msg + label.foreground = UIUtil.getContextHelpForeground() + } + + fun loading(msg: String = label.text) { + label.icon = iconLoading + label.text = msg + label.foreground = UIUtil.getContextHelpForeground() + } + + fun warn(msg: String) { + label.icon = iconWarn + label.text = msg + label.foreground = UIUtil.getContextHelpForeground() + } + + fun error(msg: String) { + label.icon = iconError + label.text = msg + label.foreground = UIUtil.getErrorForeground() + } + + fun idle(msg: String) { + label.icon = iconIdle + label.text = msg + label.foreground = UIUtil.getContextHelpForeground() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index acd30c58c95..abffce3b604 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -11,6 +11,10 @@ toolwindow.status.loading.short=Loading\u2026 toolwindow.status.disconnected.short=Disconnected toolwindow.status.error.short=Error +toolwindow.section.app=App +toolwindow.section.workspace=Workspace +toolwindow.workspace.pending=Waiting for connection\u2026 + action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings action.Kilo.SettingsGroup.text=Settings diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 6d236a36ae1..964f07922a7 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -7,6 +7,7 @@ kotlin-serialization-plugin = "2.1.20" kotlin-serialization = "1.8.1" okhttp = "4.12.0" openapi-generator = "7.21.0" +detekt = "1.23.8" [libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } @@ -16,6 +17,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } [plugins] +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" } rpc = { id = "rpc", version.ref = "intellij-rpc-plugin" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-jvm-plugin" } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt new file mode 100644 index 00000000000..b75a96c5559 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt @@ -0,0 +1,30 @@ +package ai.kilocode.rpc + +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow + +/** + * Workspace-level RPC API exposed from backend to frontend. + * + * Operations are scoped to a specific directory (workspace root + * or worktree). Each call routes to a [KiloBackendWorkspace] + * via the workspace manager. + */ +@Rpc +interface KiloProjectRpcApi : RemoteApi { + companion object { + suspend fun getInstance(): KiloProjectRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } + + /** Observe workspace state loading progress. */ + suspend fun state(directory: String): Flow + + /** Trigger a full reload of workspace data. */ + suspend fun reload(directory: String) +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt new file mode 100644 index 00000000000..accadf58fc2 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -0,0 +1,48 @@ +package ai.kilocode.rpc + +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow + +/** + * Session management RPC API exposed from backend to frontend. + * + * App-scoped — manages sessions across all directories (workspace + * roots and worktrees). Each call takes a [directory] parameter to + * scope the operation, matching the CLI server's directory-based + * routing. + */ +@Rpc +interface KiloSessionRpcApi : RemoteApi { + companion object { + suspend fun getInstance(): KiloSessionRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } + + /** List root sessions for a directory. */ + suspend fun list(directory: String): SessionListDto + + /** Create a new session in the given directory. */ + suspend fun create(directory: String): SessionDto + + /** Get a single session by ID. */ + suspend fun get(id: String, directory: String): SessionDto + + /** Delete a session. */ + suspend fun delete(id: String, directory: String) + + /** Observe live session status changes. */ + suspend fun statuses(): Flow> + + /** Register a worktree directory override for a session. */ + suspend fun setDirectory(id: String, directory: String) + + /** Get the effective directory for a session (worktree or fallback). */ + suspend fun getDirectory(id: String, fallback: String): String +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentDto.kt new file mode 100644 index 00000000000..6c345eac1db --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentDto.kt @@ -0,0 +1,22 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class AgentDto( + val name: String, + val displayName: String? = null, + val description: String? = null, + val mode: String, + val native: Boolean? = null, + val hidden: Boolean? = null, + val color: String? = null, + val deprecated: Boolean? = null, +) + +@Serializable +data class AgentsDto( + val agents: List, + val all: List, + val default: String, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt new file mode 100644 index 00000000000..367c0c4b89a --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt @@ -0,0 +1,11 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CommandDto( + val name: String, + val description: String? = null, + val source: String? = null, + val hints: List = emptyList(), +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt new file mode 100644 index 00000000000..c1dd8585db5 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -0,0 +1,30 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +enum class KiloWorkspaceStatusDto { + PENDING, + LOADING, + READY, + ERROR, +} + +@Serializable +data class KiloWorkspaceLoadProgressDto( + val providers: Boolean = false, + val agents: Boolean = false, + val commands: Boolean = false, + val skills: Boolean = false, +) + +@Serializable +data class KiloWorkspaceStateDto( + val status: KiloWorkspaceStatusDto, + val progress: KiloWorkspaceLoadProgressDto? = null, + val providers: ProvidersDto? = null, + val agents: AgentsDto? = null, + val commands: List = emptyList(), + val skills: List = emptyList(), + val error: String? = null, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt new file mode 100644 index 00000000000..5223473de67 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt @@ -0,0 +1,30 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ModelDto( + val id: String, + val name: String, + val attachment: Boolean = false, + val reasoning: Boolean = false, + val temperature: Boolean = false, + val toolCall: Boolean = false, + val free: Boolean = false, + val status: String? = null, +) + +@Serializable +data class ProviderDto( + val id: String, + val name: String, + val source: String? = null, + val models: Map = emptyMap(), +) + +@Serializable +data class ProvidersDto( + val providers: List, + val connected: List, + val defaults: Map, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt new file mode 100644 index 00000000000..47958e66bce --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt @@ -0,0 +1,41 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SessionDto( + val id: String, + val projectID: String, + val directory: String, + val parentID: String? = null, + val title: String, + val version: String, + val time: SessionTimeDto, + val summary: SessionSummaryDto? = null, +) + +@Serializable +data class SessionTimeDto( + val created: Double, + val updated: Double, + val archived: Double? = null, +) + +@Serializable +data class SessionSummaryDto( + val additions: Int, + val deletions: Int, + val files: Int, +) + +@Serializable +data class SessionStatusDto( + val type: String, + val message: String? = null, +) + +@Serializable +data class SessionListDto( + val sessions: List, + val statuses: Map, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt new file mode 100644 index 00000000000..e5559393af5 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt @@ -0,0 +1,10 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SkillDto( + val name: String, + val description: String, + val location: String, +)