From 632893a95800389ea10a3c49fd072256a6e2d422 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 24 May 2026 14:50:59 -0400 Subject: [PATCH] feat(jetbrains): gate legacy migration from backend --- .changeset/quiet-comet-migration-gate.md | 5 + mock-v5-migration-config.sh | 96 +++++++++++++++ .../ai/kilocode/backend/app/KiloAppState.kt | 2 + .../backend/app/KiloBackendAppService.kt | 74 +++++++++-- .../kilocode/backend/cli/KiloCliConfigPath.kt | 23 ++++ .../KiloBackendLegacyMigrationStoreService.kt | 116 +++++++++++++----- .../backend/migration/LegacyMigrationStore.kt | 41 ------- .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 29 ++++- .../backend/rpc/KiloMigrationRpcApiImpl.kt | 36 ++++-- .../backend/rpc/KiloSessionRpcApiImpl.kt | 43 +++++-- .../migration/LegacyMigrationDetectionTest.kt | 10 +- .../LegacyMigrationOrchestrationTest.kt | 7 +- .../migration/LegacySettingsFileFixture.kt | 46 +++++++ .../client/migration/KiloMigrationService.kt | 94 ++++++++------ .../ai/kilocode/client/session/SessionUi.kt | 12 +- .../settings/profile/LoggedOutProfileUi.kt | 2 +- .../client/settings/profile/ProfileUi.kt | 2 +- .../migration/KiloMigrationServiceTest.kt | 87 +++++-------- .../migration/SessionUiMigrationTest.kt | 14 --- .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 2 + 20 files changed, 513 insertions(+), 228 deletions(-) create mode 100644 .changeset/quiet-comet-migration-gate.md create mode 100755 mock-v5-migration-config.sh create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliConfigPath.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacySettingsFileFixture.kt diff --git a/.changeset/quiet-comet-migration-gate.md b/.changeset/quiet-comet-migration-gate.md new file mode 100644 index 00000000000..06d20d73895 --- /dev/null +++ b/.changeset/quiet-comet-migration-gate.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show the JetBrains migration wizard from backend startup state and block chat actions until migration is handled. diff --git a/mock-v5-migration-config.sh b/mock-v5-migration-config.sh new file mode 100755 index 00000000000..07ecbc0c33f --- /dev/null +++ b/mock-v5-migration-config.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: ./mock-v5-migration-config.sh seed|clean + +Creates or removes local legacy migration data for JetBrains plugin migration testing. +This script does not touch VS Code storage. + +Environment overrides: + KILO_CONFIG_DIR Config directory to manage. Default: ./.kilo-dev/config/kilo +USAGE +} + +cmd="${1:-}" +if [[ -z "$cmd" ]]; then + usage + exit 2 +fi +shift || true + +if [[ $# -gt 0 ]]; then + case "$1" in + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +fi + +repo="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +dir="${KILO_CONFIG_DIR:-$repo/.kilo-dev/config/kilo}" +config="$dir/kilo.json" +legacy="$dir/legacy-settings.json" + +seed() { + mkdir -p "$dir" + + cat > "$legacy" <<'JSON' +{ + "providerProfiles": "{\"currentApiConfigName\":\"mock-openai\",\"apiConfigs\":{\"mock-openai\":{\"apiProvider\":\"openai-native\",\"openAiNativeApiKey\":\"sk-mock-v5-key\",\"openAiNativeBaseUrl\":\"https://mock.local/v1\",\"apiModelId\":\"gpt-4o-mini\"}}}", + "mcpSettings": "{\"mcpServers\":{\"mock-filesystem\":{\"command\":\"node\",\"args\":[\"mock-mcp-server.js\"],\"env\":{\"MOCK\":\"1\"},\"disabled\":false}}}", + "customModes": "{\"customModes\":[{\"slug\":\"mock-v5-agent\",\"name\":\"Mock V5 Agent\",\"roleDefinition\":\"You are a mock legacy v5 agent used for migration testing.\",\"groups\":[\"read\",\"edit\",\"browser\",\"command\",\"mcp\"]}]}", + "globalState": { + "kilo-code.autoApprovalEnabled": true, + "kilo-code.allowedCommands": ["npm test", "bun test"], + "kilo-code.deniedCommands": ["rm -rf *"], + "alwaysAllowReadOnly": true, + "alwaysAllowWrite": false, + "alwaysAllowExecute": false, + "alwaysAllowMcp": true, + "alwaysAllowModeSwitch": true, + "alwaysAllowSubtasks": true, + "kilo-code.language": "en", + "ghostServiceSettings": { + "enableAutoTrigger": true, + "enableSmartInlineTaskKeybinding": true, + "enableChatAutocomplete": true + } + }, + "taskHistory": "[{\"id\":\"mock-task-1\",\"task\":\"Mock migrated task\",\"workspace\":\"/tmp/mock-v5-workspace\",\"ts\":1700000000000}]", + "conversations": { + "mock-task-1": "[{\"role\":\"user\",\"content\":\"Create a mock migration task\",\"ts\":1700000000000},{\"role\":\"assistant\",\"content\":\"Mock task response from legacy config.\",\"ts\":1700000001000}]" + } +} +JSON + + rm -f "$config" + rm -f "$dir/opencode.json" + echo "Seeded mock legacy migration file: $legacy" + echo "JetBrains dev isolation should pick this up via XDG_CONFIG_HOME=$repo/.kilo-dev/config" +} + +clean() { + rm -rf "$dir" + echo "Cleaned mock config directory: $dir" +} + +case "$cmd" in + seed) seed ;; + clean) clean ;; + -h|--help) + usage + ;; + *) + echo "unknown command: $cmd" >&2 + usage + exit 2 + ;; +esac diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt index 7bb9c069e10..257284d81a3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt @@ -3,6 +3,7 @@ package ai.kilocode.backend.app import ai.kilocode.jetbrains.api.model.Config import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner import ai.kilocode.jetbrains.api.model.KiloProfile200Response +import ai.kilocode.backend.migration.LegacyMigrationDetection /** * Full application lifecycle state, combining CLI transport connection @@ -15,6 +16,7 @@ sealed class KiloAppState { data object Disconnected : KiloAppState() data object Connecting : KiloAppState() data class Loading(val progress: LoadProgress) : KiloAppState() + data class MigrationRequired(val detection: LegacyMigrationDetection) : KiloAppState() data class Ready(val data: AppData) : KiloAppState() data class Error(val message: String, val errors: List = emptyList()) : KiloAppState() } 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 fb0f6d8430e..49a17acbe17 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,8 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.cli.KiloBackendCliManager +import ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService +import ai.kilocode.backend.migration.LegacyMigrationDetection import ai.kilocode.log.KiloLog import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.jetbrains.api.client.DefaultApi @@ -127,7 +129,7 @@ class KiloBackendAppService private constructor( suspend fun connect() { mutex.withLock { val current = _appState.value - if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) return + if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return ensureWatcher() connection.connect() } @@ -156,6 +158,10 @@ class KiloBackendAppService private constructor( } KiloAppState.Connecting, is KiloAppState.Loading -> Unit + is KiloAppState.MigrationRequired -> { + log.info("retry: rerunning migration detection") + load() + } is KiloAppState.Ready -> { if (current.data.warnings.isEmpty()) return log.info("retry: refreshing config warnings") @@ -190,10 +196,25 @@ class KiloBackendAppService private constructor( return HealthDto(healthy = response.healthy, version = response.version) } + fun requireReady() { + when (_appState.value) { + is KiloAppState.Ready -> return + is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required") + else -> throw IllegalStateException("Kilo backend is not ready") + } + } + + internal suspend fun resumeAfterMigration() { + mutex.withLock { + if (_appState.value !is KiloAppState.MigrationRequired) return + load() + } + } + private suspend fun reconnect() { mutex.withLock { val current = _appState.value - if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) { + if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) { log.info("reconnect: already ${current::class.simpleName} — skipping") return } @@ -210,7 +231,6 @@ class KiloBackendAppService private constructor( ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting is ConnectionState.Connected -> { - models.start(connection.apiClient ?: return@collect, next.port) load() } is ConnectionState.Error -> setAppError( @@ -243,6 +263,18 @@ class KiloBackendAppService private constructor( val progress = AtomicReference(LoadProgress()) _appState.value = KiloAppState.Loading(progress.get()) + val migration = detectMigration() + if (migration != null) { + stopRuntime() + profile = null + config = null + notifications = emptyList() + warnings = emptyList() + _appState.value = KiloAppState.MigrationRequired(migration) + log.info("Application paused — legacy migration required") + return@launch + } + val errors = CopyOnWriteArrayList() var cfg: Config? = null var prof: KiloProfile200Response? = null @@ -300,6 +332,7 @@ class KiloBackendAppService private constructor( profile = prof config = cfg notifications = notifs + models.start(connection.apiClient!!, connection.port) sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) @@ -326,6 +359,29 @@ class KiloBackendAppService private constructor( } } + private suspend fun detectMigration(): LegacyMigrationDetection? = withContext(Dispatchers.IO) { + val http = connection.apiClient ?: run { + log.info("Migration check: skipped because CLI HTTP client is not connected") + return@withContext null + } + log.info("Migration check: started") + val store = KiloBackendLegacyMigrationStoreService.store(log) + val status = store.status() + if (status != null) { + log.info("Migration check: skipped because status=$status") + return@withContext null + } + val detection = KiloBackendMigrationManager(http, connection.port).detect(store) + log.info("Migration check: completed hasData=${detection.hasData} ${migrationSummary(detection)}") + if (detection.hasData) detection else null + } + + private fun migrationSummary(detection: LegacyMigrationDetection): String { + val providers = detection.providers.count { it.supported } + val unsupported = detection.providers.size - providers + return "providers=$providers unsupportedProviders=$unsupported mcp=${detection.mcpServers.size} modes=${detection.customModes.size} sessions=${detection.sessions.size} model=${detection.defaultModel != null} settings=${detection.settings != null}" + } + /** * Fetch the user profile. Returns [FetchResult.ok] with the response * on success, [FetchResult.ok] with `null` when not logged in or when @@ -550,10 +606,7 @@ class KiloBackendAppService private constructor( loader?.cancel() eventWatcher?.cancel() } - workspaces.stop() - models.stop() - chat.stop() - sessions.stop() + stopRuntime() profile = null config = null notifications = emptyList() @@ -561,6 +614,13 @@ class KiloBackendAppService private constructor( _appState.value = KiloAppState.Disconnected } + private fun stopRuntime() { + workspaces.stop() + models.stop() + chat.stop() + sessions.stop() + } + /** * Refresh the user profile from the CLI backend. * Returns the latest profile data, or null when not logged in. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliConfigPath.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliConfigPath.kt new file mode 100644 index 00000000000..69e7cd137aa --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliConfigPath.kt @@ -0,0 +1,23 @@ +package ai.kilocode.backend.cli + +import com.intellij.openapi.util.SystemInfo +import java.io.File + +internal object KiloCliConfigPath { + fun resolve(env: Map): File { + env["KILO_CONFIG_DIR"]?.takeIf { it.isNotBlank() }?.let { return File(it) } + env["XDG_CONFIG_HOME"]?.takeIf { it.isNotBlank() }?.let { return File(it, "kilo") } + return File(defaultRoot(), "kilo") + } + + fun legacySettingsFile(env: Map): File = File(resolve(env), "legacy-settings.json") + + private fun defaultRoot(): File { + if (SystemInfo.isWindows) { + val app = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + if (app != null) return File(app) + } + if (SystemInfo.isMac) return File(System.getProperty("user.home"), "Library/Application Support") + return File(System.getProperty("user.home"), ".config") + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt index fb7e536662e..64e48960e5f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt @@ -1,50 +1,104 @@ package ai.kilocode.backend.migration -import com.intellij.ide.util.PropertiesComponent +import ai.kilocode.backend.cli.KiloBackendCliManager +import ai.kilocode.backend.cli.KiloCliConfigPath +import ai.kilocode.log.KiloLog import com.intellij.openapi.components.Service import com.intellij.openapi.components.service +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File -/** - * Provides the production [LegacyMigrationStore] for use by the migration RPC implementation. - * - * Status persistence uses [PropertiesComponent] (app-level JetBrains persistent store). - * Raw legacy source acquisition is not yet implemented; the store returns null for all - * data accessors, so [LegacyMigrationEngine.detect] will report hasData=false and the - * migration wizard will remain hidden until a real source adapter is plugged in. - */ +/** Provides the production [LegacyMigrationStore] backed by the CLI Kilo config directory. */ @Service(Service.Level.APP) class KiloBackendLegacyMigrationStoreService { companion object { - private const val STATUS_KEY = "kilo.legacyMigrationStatus" - fun getInstance(): KiloBackendLegacyMigrationStoreService = service() + + internal fun store(log: KiloLog): LegacyMigrationStore { + val env = KiloBackendCliManager(log).buildEnv("migration") + val file = KiloCliConfigPath.legacySettingsFile(env) + log.info("Migration store: file=${file.absolutePath}") + return LegacySettingsFileMigrationStore(file) { msg, err -> + if (err == null) log.warn(msg) else log.warn(msg, err) + } + } } - fun store(): LegacyMigrationStore = PersistentStatusStore() + private val log = KiloLog.create(KiloBackendLegacyMigrationStoreService::class.java) - private inner class PersistentStatusStore : LegacyMigrationStore { - override fun status(): LegacyMigrationStatus? { - val raw = PropertiesComponent.getInstance().getValue(STATUS_KEY) ?: return null - return runCatching { LegacyMigrationStatus.valueOf(raw) }.getOrNull() + fun store(): LegacyMigrationStore = store(log) +} + +class LegacySettingsFileMigrationStore( + private val file: File, + private val warn: (String, Throwable?) -> Unit = { _, _ -> }, +) : LegacyMigrationStore { + companion object { + private val json = Json { prettyPrint = true } + private const val STATUS = "migrationStatus" + } + + override fun status(): LegacyMigrationStatus? { + val raw = read()?.get(STATUS)?.jsonPrimitive?.content ?: return null + return runCatching { LegacyMigrationStatus.valueOf(raw) }.getOrNull() + } + + override fun mark(status: LegacyMigrationStatus) { + val root = read().orEmpty().toMutableMap() + root[STATUS] = JsonPrimitive(status.name) + write(JsonObject(root)) + } + + override fun providerProfilesRaw(): String? = string("providerProfiles") + override fun oauthRaw(key: String): String? = (read()?.get("oauth") as? JsonObject)?.get(key)?.jsonPrimitive?.content + override fun mcpSettingsRaw(): String? = string("mcpSettings") + override fun customModesRaw(): String? = string("customModes") + override fun customModePromptsRaw(): String? = string("customModePrompts") + override fun autocompleteRaw(): String? = string("autocomplete") + override fun globalStateValue(key: String): JsonElement? = (read()?.get("globalState") as? JsonObject)?.get(key) + override fun taskHistoryRaw(): String? = string("taskHistory") + override fun taskConversationRaw(id: String): String? = (read()?.get("conversations") as? JsonObject)?.get(id)?.jsonPrimitive?.content + + override fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport { + val root = read()?.toMutableMap() ?: return LegacyCleanupReport(cleaned = emptyList(), errors = emptyList()) + val cleaned = mutableListOf() + if (targets.providerProfiles && root.remove("providerProfiles") != null) cleaned.add("providerProfiles") + if (targets.mcpSettings && root.remove("mcpSettings") != null) cleaned.add("mcpSettings") + if (targets.customModes && root.remove("customModes") != null) cleaned.add("customModes") + if (targets.globalState && root.remove("globalState") != null) cleaned.add("globalState") + if (targets.taskHistory) { + val history = root.remove("taskHistory") != null + val conv = root.remove("conversations") != null + if (history || conv) cleaned.add("taskHistory") } + val err = runCatching { write(JsonObject(root)) }.exceptionOrNull()?.message + return LegacyCleanupReport(cleaned = if (err == null) cleaned else emptyList(), errors = listOfNotNull(err)) + } - override fun mark(status: LegacyMigrationStatus) { - PropertiesComponent.getInstance().setValue(STATUS_KEY, status.name) + private fun string(key: String): String? = read()?.get(key)?.jsonPrimitive?.content + + private fun read(): JsonObject? { + if (!file.isFile) return null + return try { + json.parseToJsonElement(file.readText()).jsonObject + } catch (e: SerializationException) { + warn("Malformed legacy migration settings at ${file.absolutePath}", e) + null + } catch (e: IllegalArgumentException) { + warn("Malformed legacy migration settings at ${file.absolutePath}", e) + null } + } - // Legacy source adapters — not yet implemented; return null to suppress migration UI. - override fun providerProfilesRaw(): String? = null - override fun oauthRaw(key: String): String? = null - override fun mcpSettingsRaw(): String? = null - override fun customModesRaw(): String? = null - override fun customModePromptsRaw(): String? = null - override fun autocompleteRaw(): String? = null - override fun globalStateValue(key: String) = null - override fun taskHistoryRaw(): String? = null - override fun taskConversationRaw(id: String): String? = null - - override fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport = - LegacyCleanupReport(cleaned = emptyList(), errors = emptyList()) + private fun write(root: JsonObject) { + file.parentFile?.mkdirs() + file.writeText(json.encodeToString(JsonObject.serializer(), root)) } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationStore.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationStore.kt index f722fe152d8..908d81eca53 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationStore.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationStore.kt @@ -34,44 +34,3 @@ interface LegacyMigrationStore { fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport } - -/** - * In-memory store for unit tests and future UI/import flows. - * - * Accepts raw strings/values that callers supply programmatically. - * Calls to [cleanup] always succeed and return the targets as cleaned. - */ -class InMemoryLegacyMigrationStore : LegacyMigrationStore { - var migrationStatus: LegacyMigrationStatus? = null - var providerProfiles: String? = null - val oauthSecrets: MutableMap = mutableMapOf() - var mcpSettings: String? = null - var customModes: String? = null - var customModePrompts: String? = null - var autocomplete: String? = null - val globalState: MutableMap = mutableMapOf() - var taskHistory: String? = null - val conversations: MutableMap = mutableMapOf() - - override fun status() = migrationStatus - override fun mark(status: LegacyMigrationStatus) { migrationStatus = status } - override fun providerProfilesRaw() = providerProfiles - override fun oauthRaw(key: String) = oauthSecrets[key] - override fun mcpSettingsRaw() = mcpSettings - override fun customModesRaw() = customModes - override fun customModePromptsRaw() = customModePrompts - override fun autocompleteRaw() = autocomplete - override fun globalStateValue(key: String) = globalState[key] - override fun taskHistoryRaw() = taskHistory - override fun taskConversationRaw(id: String) = conversations[id] - - override fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport { - val cleaned = mutableListOf() - if (targets.providerProfiles) { providerProfiles = null; cleaned.add("providerProfiles") } - if (targets.mcpSettings) { mcpSettings = null; cleaned.add("mcpSettings") } - if (targets.customModes) { customModes = null; cleaned.add("customModes") } - if (targets.globalState) { globalState.clear(); cleaned.add("globalState") } - if (targets.taskHistory) { taskHistory = null; conversations.clear(); cleaned.add("taskHistory") } - return LegacyCleanupReport(cleaned = cleaned, errors = emptyList()) - } -} 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 c02afa53371..d2971645924 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 @@ -58,15 +58,30 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun reinstall() = app.reinstall() - override suspend fun modelState(): ModelStateDto = app.models.state() + override suspend fun modelState(): ModelStateDto { + app.requireReady() + return app.models.state() + } - override suspend fun updateModelFavorite(update: ModelFavoriteUpdateDto): ModelStateDto = app.models.favorite(update) + override suspend fun updateModelFavorite(update: ModelFavoriteUpdateDto): ModelStateDto { + app.requireReady() + return app.models.favorite(update) + } - override suspend fun updateModelSelection(update: ModelSelectionUpdateDto): ModelStateDto = app.models.selection(update) + override suspend fun updateModelSelection(update: ModelSelectionUpdateDto): ModelStateDto { + app.requireReady() + return app.models.selection(update) + } - override suspend fun clearModelSelection(agent: String): ModelStateDto = app.models.clear(agent) + override suspend fun clearModelSelection(agent: String): ModelStateDto { + app.requireReady() + return app.models.clear(agent) + } - override suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto = app.models.variant(update) + override suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto { + app.requireReady() + return app.models.variant(update) + } override suspend fun refreshProfile(): ProfileDto? = app.refreshProfile()?.let(::profileDto) @@ -91,6 +106,10 @@ internal fun appStateDto(state: KiloAppState): KiloAppStateDto = status = KiloAppStatusDto.LOADING, progress = progress(state.progress), ) + is KiloAppState.MigrationRequired -> KiloAppStateDto( + status = KiloAppStatusDto.MIGRATION_REQUIRED, + migration = MigrationRpcMapper.toDto(state.detection), + ) is KiloAppState.Ready -> KiloAppStateDto( status = KiloAppStatusDto.READY, progress = LoadProgressDto( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt index ab682ac6229..a5a0f022436 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt @@ -17,6 +17,7 @@ import ai.kilocode.rpc.dto.LegacyMigrationEventDto import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto import ai.kilocode.rpc.dto.LegacyMigrationStatusDto import ai.kilocode.backend.app.KiloBackendMigrationManager +import ai.kilocode.log.KiloLog import com.intellij.openapi.components.service import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.trySendBlocking @@ -26,6 +27,10 @@ import kotlinx.coroutines.withContext class KiloMigrationRpcApiImpl : KiloMigrationRpcApi { + companion object { + private val LOG = KiloLog.create(KiloMigrationRpcApiImpl::class.java) + } + private val app: KiloBackendAppService get() = service() private val storeService: KiloBackendLegacyMigrationStoreService get() = service() @@ -36,20 +41,23 @@ class KiloMigrationRpcApiImpl : KiloMigrationRpcApi { } override suspend fun status(): LegacyMigrationStatusDto? { - val mgr = manager() val store = storeService.store() - val status = mgr.status(store) ?: return null + val status = withContext(Dispatchers.IO) { store.status() } ?: return null + LOG.info("Migration RPC status: status=$status") return MigrationRpcMapper.toDto(status) } override suspend fun detect(): LegacyMigrationDetectionDto { + LOG.info("Migration RPC detect: started") val mgr = manager() val store = storeService.store() val detection = withContext(Dispatchers.IO) { mgr.detect(store) } + LOG.info("Migration RPC detect: completed hasData=${detection.hasData} providers=${detection.providers.size} mcp=${detection.mcpServers.size} modes=${detection.customModes.size} sessions=${detection.sessions.size}") return MigrationRpcMapper.toDto(detection) } override suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow { + LOG.info("Migration RPC migrate: starting ${selectionSummary(selections)}") val mgr = manager() val domainSelections = MigrationRpcMapper.fromDto(selections) val store = storeService.store() @@ -57,9 +65,11 @@ class KiloMigrationRpcApiImpl : KiloMigrationRpcApi { withContext(Dispatchers.IO) { val sink = object : LegacyMigrationSink { override fun item(progress: ai.kilocode.backend.migration.LegacyMigrationItemProgress) { + LOG.info("Migration RPC item: item=${progress.item} status=${progress.status} message=${progress.message}") trySendBlocking(LegacyMigrationEventDto.Item(MigrationRpcMapper.toDto(progress))) } override fun session(progress: ai.kilocode.backend.migration.LegacyMigrationSessionProgress) { + LOG.info("Migration RPC session: phase=${progress.phase} session=${progress.session?.id} error=${progress.error}") trySendBlocking(LegacyMigrationEventDto.Session(MigrationRpcMapper.toDto(progress))) } } @@ -67,6 +77,7 @@ class KiloMigrationRpcApiImpl : KiloMigrationRpcApi { mgr.migrate(store, domainSelections, sink) }.getOrElse { e -> val msg = e.message ?: "Migration failed" + LOG.warn("Migration RPC migrate: failed message=$msg", e) val errItem = LegacyMigrationResultItem( item = "Migration", category = MigrationItemCategory.settings, @@ -76,29 +87,40 @@ class KiloMigrationRpcApiImpl : KiloMigrationRpcApi { trySendBlocking(LegacyMigrationEventDto.Complete(listOf(MigrationRpcMapper.toDto(errItem)))) return@withContext } + LOG.info("Migration RPC migrate: complete items=${report.items.size} errors=${report.items.count { it.status == MigrationItemStatus.error }}") trySendBlocking(LegacyMigrationEventDto.Complete(report.items.map(MigrationRpcMapper::toDto))) } } } override suspend fun skip() { - val mgr = manager() + LOG.info("Migration RPC skip: marking skipped") val store = storeService.store() - mgr.mark(store, LegacyMigrationStatus.Skipped) + withContext(Dispatchers.IO) { store.mark(LegacyMigrationStatus.Skipped) } + app.resumeAfterMigration() + LOG.info("Migration RPC skip: resumed app load") } override suspend fun finalize(status: LegacyMigrationStatusDto) { - val mgr = manager() + LOG.info("Migration RPC finalize: status=$status") val store = storeService.store() val domain = MigrationRpcMapper.fromDto(status) - if (domain == LegacyMigrationStatus.Skipped) return - mgr.mark(store, domain) + if (domain != LegacyMigrationStatus.Skipped) { + withContext(Dispatchers.IO) { store.mark(domain) } + } + app.resumeAfterMigration() + LOG.info("Migration RPC finalize: resumed app load") } override suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto { + LOG.info("Migration RPC cleanup: providerProfiles=${targets.providerProfiles} mcp=${targets.mcpSettings} modes=${targets.customModes} state=${targets.globalState} history=${targets.taskHistory}") val mgr = manager() val store = storeService.store() val report = withContext(Dispatchers.IO) { mgr.cleanup(store, MigrationRpcMapper.fromDto(targets)) } + LOG.info("Migration RPC cleanup: cleaned=${report.cleaned.size} errors=${report.errors.size}") return MigrationRpcMapper.toDto(report) } + + private fun selectionSummary(selections: LegacyMigrationSelectionsDto): String = + "providers=${selections.providers.size} mcp=${selections.mcpServers.size} modes=${selections.customModes.size} sessions=${selections.sessions.size} model=${selections.defaultModel} settings=true" } 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 index 0192304ef4a..bcac7678f48 100644 --- 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 @@ -42,45 +42,52 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { } private val workspaces: KiloBackendWorkspaceManager - get() = service().workspaces + get() = app.workspaces private val sessions: KiloBackendSessionManager - get() = service().sessions + get() = app.sessions private val chat: KiloBackendChatManager - get() = service().chat + get() = app.chat + + private val app: KiloBackendAppService + get() = service() override suspend fun list(directory: String): SessionListDto = - workspaces.get(directory).sessions() + ready { workspaces.get(directory).sessions() } override suspend fun recent(directory: String, limit: Int): SessionListDto = - sessions.recent(directory, limit) + ready { sessions.recent(directory, limit) } override suspend fun create(directory: String): SessionDto { + app.requireReady() LOG.info("create session: directory=$directory") return workspaces.get(directory).createSession() } override suspend fun get(id: String, directory: String): SessionDto { + app.requireReady() val dir = sessions.getDirectory(id, directory) return sessions.get(id, dir) } override suspend fun delete(id: String, directory: String) { + app.requireReady() val dir = sessions.getDirectory(id, directory) workspaces.get(dir).deleteSession(id) } override suspend fun rename(id: String, directory: String, title: String): ai.kilocode.rpc.dto.SessionDto { + app.requireReady() val dir = sessions.getDirectory(id, directory) return sessions.rename(id, dir, title) } override suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto = - sessions.cloudSessions(directory, cursor, limit, gitUrl) + ready { sessions.cloudSessions(directory, cursor, limit, gitUrl) } override suspend fun importCloudSession(id: String, directory: String): SessionDto = - sessions.importCloudSession(id, directory) + ready { sessions.importCloudSession(id, directory) } override suspend fun statuses(): Flow> = sessions.statuses @@ -94,18 +101,19 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { // ------ chat ------ override suspend fun prompt(id: String, directory: String, prompt: PromptDto) { + app.requireReady() LOG.info("prompt RPC: session=$id, dir=$directory, parts=${prompt.parts.size}") chat.prompt(id, directory, prompt) } override suspend fun abort(id: String, directory: String) = - chat.abort(id, directory) + ready { chat.abort(id, directory) } override suspend fun compact(id: String, directory: String, model: ModelSelectionDto) = - chat.compact(id, directory, model) + ready { chat.compact(id, directory, model) } override suspend fun messages(id: String, directory: String): List = - chat.messages(id, directory) + ready { chat.messages(id, directory) } override suspend fun events(id: String, directory: String): Flow = chat.events.filter { event -> @@ -137,33 +145,42 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { } override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = - chat.updateConfig(directory, config) + ready { chat.updateConfig(directory, config) } // ------ permission / question resolution ------ override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { + app.requireReady() LOG.info("replyPermission: requestId=$requestId, reply=${reply.reply}") chat.replyPermission(requestId, directory, reply) } override suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) { + app.requireReady() LOG.info("savePermissionRules: requestId=$requestId") chat.savePermissionRules(requestId, directory, rules) } override suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) { + app.requireReady() LOG.info("replyQuestion: requestId=$requestId, answers=${answers.answers.size}") chat.replyQuestion(requestId, directory, answers) } override suspend fun rejectQuestion(requestId: String, directory: String) { + app.requireReady() LOG.info("rejectQuestion: requestId=$requestId") chat.rejectQuestion(requestId, directory) } override suspend fun pendingPermissions(directory: String): List = - chat.pendingPermissions(directory) + ready { chat.pendingPermissions(directory) } override suspend fun pendingQuestions(directory: String): List = - chat.pendingQuestions(directory) + ready { chat.pendingQuestions(directory) } + + private suspend fun ready(block: suspend () -> T): T { + app.requireReady() + return block() + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationDetectionTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationDetectionTest.kt index f05084911b5..438bea00d9b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationDetectionTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationDetectionTest.kt @@ -9,14 +9,15 @@ import kotlin.test.assertNull import kotlin.test.assertTrue /** - * Tests for LegacyMigrationEngine.detect() using InMemoryLegacyMigrationStore. + * Tests for LegacyMigrationEngine.detect() using the production file-backed store. */ class LegacyMigrationDetectionTest { - private fun engine(configure: InMemoryLegacyMigrationStore.() -> Unit = {}): Pair { - val store = InMemoryLegacyMigrationStore().apply(configure) + private fun engine(configure: LegacySettingsFileFixture.() -> Unit = {}): Pair { + val fixture = LegacySettingsFileFixture().apply(configure) + val store = fixture.store() val backend = NoopLegacyMigrationBackend() - return LegacyMigrationEngine(store, backend) to store + return LegacyMigrationEngine(store, backend) to fixture } // ----------------------------------------------------------------------- @@ -265,6 +266,7 @@ customModes: assertNull(eng.status()) eng.mark(LegacyMigrationStatus.Completed) assertEquals(LegacyMigrationStatus.Completed, eng.status()) + store.refresh() assertEquals(LegacyMigrationStatus.Completed, store.migrationStatus) } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationOrchestrationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationOrchestrationTest.kt index 3d237364197..ed6fa40505f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationOrchestrationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationOrchestrationTest.kt @@ -11,10 +11,11 @@ import kotlin.test.assertTrue */ class LegacyMigrationOrchestrationTest { - private fun setup(configure: InMemoryLegacyMigrationStore.() -> Unit = {}): Triple { - val store = InMemoryLegacyMigrationStore().apply(configure) + private fun setup(configure: LegacySettingsFileFixture.() -> Unit = {}): Triple { + val fixture = LegacySettingsFileFixture().apply(configure) + val store = fixture.store() val backend = NoopLegacyMigrationBackend() - return Triple(LegacyMigrationEngine(store, backend), store, backend) + return Triple(LegacyMigrationEngine(store, backend), fixture, backend) } private fun noSelections() = LegacyMigrationSelections( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacySettingsFileFixture.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacySettingsFileFixture.kt new file mode 100644 index 00000000000..003f3ea1fc0 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacySettingsFileFixture.kt @@ -0,0 +1,46 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.nio.file.Files + +internal class LegacySettingsFileFixture { + private val file = Files.createTempDirectory("kilo-legacy-migration").resolve("legacy-settings.json").toFile() + var migrationStatus: LegacyMigrationStatus? = null + var providerProfiles: String? = null + val oauthSecrets: MutableMap = mutableMapOf() + var mcpSettings: String? = null + var customModes: String? = null + var customModePrompts: String? = null + var autocomplete: String? = null + val globalState: MutableMap = mutableMapOf() + var taskHistory: String? = null + val conversations: MutableMap = mutableMapOf() + + fun store(): LegacyMigrationStore { + flush() + return LegacySettingsFileMigrationStore(file) + } + + fun refresh() { + migrationStatus = LegacySettingsFileMigrationStore(file).status() + } + + private fun flush() { + val root = mutableMapOf() + migrationStatus?.let { root["migrationStatus"] = JsonPrimitive(it.name) } + providerProfiles?.let { root["providerProfiles"] = JsonPrimitive(it) } + if (oauthSecrets.isNotEmpty()) root["oauth"] = JsonObject(oauthSecrets.mapValues { JsonPrimitive(it.value) }) + mcpSettings?.let { root["mcpSettings"] = JsonPrimitive(it) } + customModes?.let { root["customModes"] = JsonPrimitive(it) } + customModePrompts?.let { root["customModePrompts"] = JsonPrimitive(it) } + autocomplete?.let { root["autocomplete"] = JsonPrimitive(it) } + if (globalState.isNotEmpty()) root["globalState"] = JsonObject(globalState) + taskHistory?.let { root["taskHistory"] = JsonPrimitive(it) } + if (conversations.isNotEmpty()) root["conversations"] = JsonObject(conversations.mapValues { JsonPrimitive(it.value) }) + file.parentFile.mkdirs() + file.writeText(Json.encodeToString(JsonObject.serializer(), JsonObject(root))) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt index ffdc0bdbb18..8e765a10411 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt @@ -2,8 +2,11 @@ package ai.kilocode.client.migration +import ai.kilocode.client.app.KiloAppService import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloMigrationRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.LegacyMigrationEventDto import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto import ai.kilocode.rpc.dto.LegacyMigrationStatusDto @@ -45,10 +48,13 @@ interface MigrationUiController { class KiloMigrationService internal constructor( private val cs: CoroutineScope, private val rpc: KiloMigrationRpcApi?, + appState: StateFlow?, ) : MigrationUiController { /** Platform constructor — resolves RPC lazily. */ - constructor(cs: CoroutineScope) : this(cs, null) + constructor(cs: CoroutineScope) : this(cs, null, service().state) + + internal constructor(cs: CoroutineScope, rpc: KiloMigrationRpcApi?) : this(cs, rpc, null) companion object { private val LOG = KiloLog.create(KiloMigrationService::class.java) @@ -59,10 +65,15 @@ class KiloMigrationService internal constructor( private val _state = MutableStateFlow(MigrationUiState.Hidden) override val state: StateFlow = _state.asStateFlow() - private val checking = AtomicBoolean(false) private val migrating = AtomicBoolean(false) private val migrateJob = AtomicReference(null) + init { + if (appState != null) { + cs.launch { appState.collect(::onAppState) } + } + } + // ------ RPC helper ------ private suspend fun call(block: suspend KiloMigrationRpcApi.() -> T): T { @@ -72,47 +83,18 @@ class KiloMigrationService internal constructor( // ------ MigrationUiController ------ - /** - * Check if migration is needed. Idempotent and in-flight guarded. - * Calls status first; if status exists, hides. Then calls detect; if no data, hides. - * Detection failures log and leave state unchanged. - */ - override fun check() { - if (!checking.compareAndSet(false, true)) return - cs.launch { - try { - val status = try { - call { status() } - } catch (e: Exception) { - LOG.warn("migration status check failed", e) - checking.set(false) - return@launch - } - if (status != null) { - _state.value = MigrationUiState.Hidden - checking.set(false) - return@launch - } - val detection = try { - call { detect() } - } catch (e: Exception) { - LOG.warn("migration detect failed", e) - checking.set(false) - return@launch - } - _state.value = if (detection.hasData) MigrationUiState.Needed(detection) else MigrationUiState.Hidden - } finally { - checking.set(false) - } - } - } + override fun check() = Unit /** * Start migration for the given user selections. */ override fun start(selections: MigrationUiSelections) { val current = _state.value as? MigrationUiState.Needed ?: return - if (!migrating.compareAndSet(false, true)) return + if (!migrating.compareAndSet(false, true)) { + LOG.info("Migration wizard: start ignored because migration is already running") + return + } + LOG.info("Migration wizard: user started migration ${selectionSummary(selections)}") val dto = MigrationSelectionBuilder.toDto(selections) val initialProgress = buildInitialProgress(selections, current.detection) @@ -148,7 +130,11 @@ class KiloMigrationService internal constructor( */ override fun force(ids: List) { val current = _state.value as? MigrationUiState.Needed ?: return - if (!migrating.compareAndSet(false, true)) return + if (!migrating.compareAndSet(false, true)) { + LOG.info("Migration wizard: force re-import ignored because migration is already running") + return + } + LOG.info("Migration wizard: user forced session re-import sessions=${ids.size} ids=${ids.joinToString(",")}") val dto = MigrationSelectionBuilder.forceSessionsDto(ids) val initialProgress = ids.map { @@ -186,6 +172,7 @@ class KiloMigrationService internal constructor( * Skip migration — marks status and hides for all observers. */ override fun skip() { + LOG.info("Migration wizard: user chose skip") cs.launch { try { call { skip() } @@ -201,11 +188,13 @@ class KiloMigrationService internal constructor( */ override fun finish() { val current = _state.value as? MigrationUiState.Needed ?: run { + LOG.info("Migration wizard: finish requested while hidden") _state.value = MigrationUiState.Hidden return } val hasErrors = current.results.any { it.status == MigrationItemStatusDto.error } val status = if (hasErrors) LegacyMigrationStatusDto.completed_with_errors else LegacyMigrationStatusDto.completed + LOG.info("Migration wizard: user finished migration status=$status results=${current.results.size} errors=${current.results.count { it.status == MigrationItemStatusDto.error }}") cs.launch { try { call { finalize(status) } @@ -223,6 +212,7 @@ class KiloMigrationService internal constructor( when (event) { is LegacyMigrationEventDto.Item -> { val p = event.progress + LOG.info("Migration wizard: item progress item=${p.item} status=${p.status} message=${p.message}") val updated = current.progress.map { if (it.item == p.item) it.copy(status = p.status, message = p.message) else it } @@ -231,6 +221,7 @@ class KiloMigrationService internal constructor( is LegacyMigrationEventDto.Session -> { val sp = event.progress val phase = sp.phase + LOG.info("Migration wizard: session progress phase=$phase session=${sp.session?.id} error=${sp.error}") // Update session summary buckets val summary = when (phase) { @@ -268,6 +259,7 @@ class KiloMigrationService internal constructor( val items = event.items val hasErrors = items.any { it.status == MigrationItemStatusDto.error } val phase = if (hasErrors) MigrationUiPhase.error else MigrationUiPhase.done + LOG.info("Migration wizard: migration complete phase=$phase items=${items.size} errors=${items.count { it.status == MigrationItemStatusDto.error }}") _state.value = current.copy( running = false, phase = phase, @@ -275,11 +267,28 @@ class KiloMigrationService internal constructor( ) } is LegacyMigrationEventDto.Error -> { + LOG.warn("Migration wizard: migration error message=${event.message}") finishWithError(event.message) } } } + private fun onAppState(state: KiloAppStateDto) { + val migration = state.migration + if (state.status == KiloAppStatusDto.MIGRATION_REQUIRED && migration != null) { + val current = _state.value + if (current is MigrationUiState.Needed && current.detection == migration && current.phase != MigrationUiPhase.selecting) return + LOG.info("Migration wizard: showing because backend requires migration ${detectionSummary(migration)}") + _state.value = MigrationUiState.Needed(migration) + return + } + if (migrating.get()) return + if (_state.value !is MigrationUiState.Hidden) { + LOG.info("Migration wizard: hiding because backend status=${state.status}") + } + _state.value = MigrationUiState.Hidden + } + private fun finishWithError(msg: String) { val current = _state.value as? MigrationUiState.Needed ?: return val errItem = LegacyMigrationResultItemDto( @@ -295,6 +304,15 @@ class KiloMigrationService internal constructor( ) } + private fun selectionSummary(selections: MigrationUiSelections): String = + "providers=${selections.providers.size}:${selections.providers.joinToString(",")} mcp=${selections.mcpServers.size}:${selections.mcpServers.joinToString(",")} modes=${selections.customModes.size}:${selections.customModes.joinToString(",")} sessions=${selections.sessions.size} model=${selections.defaultModel} settings=${settingsSummary(selections.settings)}" + + private fun settingsSummary(settings: MigrationSettingsUiSelections): String = + "commandRules=${settings.autoApproval.commandRules},read=${settings.autoApproval.readPermission},write=${settings.autoApproval.writePermission},execute=${settings.autoApproval.executePermission},mcp=${settings.autoApproval.mcpPermission},task=${settings.autoApproval.taskPermission},language=${settings.language},autocomplete=${settings.autocomplete}" + + private fun detectionSummary(detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto): String = + "providers=${detection.providers.size} mcp=${detection.mcpServers.size} modes=${detection.customModes.size} sessions=${detection.sessions.size} model=${detection.defaultModel != null} settings=${detection.settings != null}" + private fun buildInitialProgress( selections: MigrationUiSelections, detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index f752e1b7822..16f3fda3444 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -34,7 +34,6 @@ import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.log.ChatLogSummary import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog -import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service @@ -81,7 +80,6 @@ class SessionUi( private val project = project private val app = app - private val cs = cs private val sessions = sessions private val workspace = workspace private var opening = ref != null @@ -144,7 +142,6 @@ class SessionUi( override fun addNotify() { super.addNotify() - migration.check() resumeOpen() } @@ -174,8 +171,8 @@ class SessionUi( migrationOverlay = MigrationOverlayPanel().apply { onSkip = { migration.skip() } - onDone = { migration.finish(); sessions.refresh(workspace.directory) } - onContinueFromError = { migration.finish(); sessions.refresh(workspace.directory) } + onDone = { migration.finish() } + onContinueFromError = { migration.finish() } onStart = { sel -> migration.start(sel) } onForce = { ids -> migration.force(ids) } } @@ -292,9 +289,6 @@ class SessionUi( is SessionControllerEvent.AppChanged -> { prompt.setReady(controller.model.isReady()) - if (app.state.value.status == KiloAppStatusDto.READY) { - migration.check() - } } is SessionControllerEvent.WorkspaceChanged -> { @@ -345,9 +339,11 @@ class SessionUi( private fun applyMigrationState(state: MigrationUiState) { when (state) { is MigrationUiState.Hidden -> { + if (root.blocker.isVisible) LOG.info("Migration wizard: overlay hidden session=${id ?: cacheKey ?: "new"}") root.setBlocked(false) } is MigrationUiState.Needed -> { + if (!root.blocker.isVisible) LOG.info("Migration wizard: overlay shown session=${id ?: cacheKey ?: "new"} phase=${state.phase}") migrationOverlay.update(state) root.setBlocked(true) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt index f15a3b85d24..80f870dbbf8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt @@ -343,7 +343,7 @@ internal class LoggedOutProfileUi( } private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when { - status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.CONNECTING -> OutMode.CONNECTING + status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.CONNECTING || status == KiloAppStatusDto.MIGRATION_REQUIRED -> OutMode.CONNECTING status == KiloAppStatusDto.ERROR -> OutMode.APP_ERROR login is LoginState.Initiating -> OutMode.INITIATING login is LoginState.Pending -> OutMode.AUTH diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt index 689f908e5e4..82337f20b08 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt @@ -145,7 +145,7 @@ internal class ProfileUi( val p = prof // When loading/connecting and already showing the logged-in card, stay on it to // avoid focus loss during reconnects, initial loads, and org switches. - val transientLoad = s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING + val transientLoad = s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING || s == KiloAppStatusDto.MIGRATION_REQUIRED if (transientLoad && shown == Card.LOGGED_IN) return Card.LOGGED_IN return when { s == KiloAppStatusDto.DISCONNECTED || transientLoad -> Card.LOGGED_OUT diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt index a750ba78f08..c2191802292 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.migration import ai.kilocode.client.testing.FakeMigrationRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto import ai.kilocode.rpc.dto.LegacyMigrationEventDto import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto @@ -15,6 +17,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @Suppress("UnstableApiUsage") @@ -23,12 +26,14 @@ class KiloMigrationServiceTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope private lateinit var rpc: FakeMigrationRpcApi private lateinit var service: KiloMigrationService + private lateinit var app: MutableStateFlow override fun setUp() { super.setUp() scope = CoroutineScope(SupervisorJob()) rpc = FakeMigrationRpcApi() - service = KiloMigrationService(scope, rpc) + app = MutableStateFlow(KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)) + service = KiloMigrationService(scope, rpc, app) } override fun tearDown() { @@ -46,54 +51,36 @@ class KiloMigrationServiceTest : BasePlatformTestCase() { } } - fun `test check calls status before detect`() { - rpc.statusResult = null - rpc.detectResult = FakeMigrationRpcApi.emptyDetection() - service.check() + fun `test migration required app state shows needed without polling`() { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) settle() - assertEquals(1, rpc.statusCalls.size) - assertEquals(1, rpc.detectCalls.size) - } - - fun `test existing status hides state and does not call detect`() { - rpc.statusResult = LegacyMigrationStatusDto.completed - service.check() - settle() - assertEquals(1, rpc.statusCalls.size) + assertEquals(0, rpc.statusCalls.size) assertEquals(0, rpc.detectCalls.size) - assertEquals(MigrationUiState.Hidden, service.state.value) - } - - fun `test no data hides state`() { - rpc.statusResult = null - rpc.detectResult = FakeMigrationRpcApi.emptyDetection() - service.check() - settle() - assertEquals(MigrationUiState.Hidden, service.state.value) - } - - fun `test detected data sets needed state`() { - rpc.statusResult = null - rpc.detectResult = sampleDetection() - service.check() - settle() assertTrue("state should be Needed", service.state.value is MigrationUiState.Needed) } - fun `test duplicate check while in flight makes one rpc call`() { - rpc.statusResult = null - rpc.detectResult = FakeMigrationRpcApi.emptyDetection() - service.check() - service.check() + fun `test ready app state hides migration`() { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) settle() - // Due to in-flight guard only one pair of calls should happen - assertEquals(1, rpc.statusCalls.size) + app.value = KiloAppStateDto(KiloAppStatusDto.READY) + settle() + assertEquals(MigrationUiState.Hidden, service.state.value) + } + + fun `test duplicate migration required does not reset running migration`() { + val detection = sampleDetection() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = detection) + settle() + service.start(MigrationUiSelections(providers = listOf("profile1"))) + settle() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = detection) + settle() + val state = service.state.value as MigrationUiState.Needed + assertEquals(MigrationUiPhase.migrating, state.phase) } fun `test skip marks status and hides`() { - rpc.statusResult = null - rpc.detectResult = sampleDetection() - service.check() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) settle() service.skip() settle() @@ -102,9 +89,7 @@ class KiloMigrationServiceTest : BasePlatformTestCase() { } fun `test finish calls finalize and hides`() { - rpc.statusResult = null - rpc.detectResult = sampleDetection() - service.check() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) settle() service.finish() settle() @@ -114,9 +99,7 @@ class KiloMigrationServiceTest : BasePlatformTestCase() { } fun `test start emits migrating state and initial pending progress`() = runBlocking { - rpc.statusResult = null - rpc.detectResult = sampleDetection() - service.check() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) delay(100) UIUtil.dispatchAllInvocationEvents() @@ -134,9 +117,7 @@ class KiloMigrationServiceTest : BasePlatformTestCase() { } fun `test complete event without errors sets done phase`() = runBlocking { - rpc.statusResult = null - rpc.detectResult = sampleDetection() - service.check() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) delay(100) UIUtil.dispatchAllInvocationEvents() @@ -157,9 +138,7 @@ class KiloMigrationServiceTest : BasePlatformTestCase() { } fun `test complete event with errors sets error phase`() = runBlocking { - rpc.statusResult = null - rpc.detectResult = sampleDetection() - service.check() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) delay(100) UIUtil.dispatchAllInvocationEvents() @@ -179,9 +158,7 @@ class KiloMigrationServiceTest : BasePlatformTestCase() { } fun `test force sends only session selections with force true`() = runBlocking { - rpc.statusResult = null - rpc.detectResult = sampleDetection() - service.check() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) delay(100) UIUtil.dispatchAllInvocationEvents() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt index 08b348464b4..4f0a59b78e1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt @@ -18,20 +18,6 @@ class SessionUiMigrationTest : SessionUiTestBase() { layout() } - fun `test addNotify calls migration check`() { - // SessionUi.addNotify should call migration.check(). - // Use a fresh UI with the same fakeMigration to track the call. - val fresh = newUi(migration = fakeMigration) - val before = fakeMigration.checks.size - try { - fresh.addNotify() - assertTrue("addNotify should call migration.check()", fakeMigration.checks.size > before) - } finally { - fresh.removeNotify() - com.intellij.openapi.util.Disposer.dispose(fresh) - } - } - fun `test hidden migration state keeps blocker hidden`() { val root = find(ui) fakeMigration._state.value = MigrationUiState.Hidden diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index ba91d277175..b766f252fcf 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -7,6 +7,7 @@ enum class KiloAppStatusDto { DISCONNECTED, CONNECTING, LOADING, + MIGRATION_REQUIRED, READY, ERROR, } @@ -88,4 +89,5 @@ data class KiloAppStateDto( val warnings: List = emptyList(), val config: ConfigDto? = null, val profile: ProfileDto? = null, + val migration: LegacyMigrationDetectionDto? = null, )