feat(jetbrains): gate legacy migration from backend

This commit is contained in:
kirillk
2026-05-24 14:50:59 -04:00
parent db963bb57e
commit 632893a958
20 changed files with 513 additions and 228 deletions
+5
View File
@@ -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.
+96
View File
@@ -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
@@ -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<LoadError> = emptyList()) : KiloAppState()
}
@@ -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<LoadError>()
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.
@@ -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<String, String>): 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<String, String>): 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")
}
}
@@ -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<String>()
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))
}
}
@@ -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<String, String> = mutableMapOf()
var mcpSettings: String? = null
var customModes: String? = null
var customModePrompts: String? = null
var autocomplete: String? = null
val globalState: MutableMap<String, JsonElement> = mutableMapOf()
var taskHistory: String? = null
val conversations: MutableMap<String, String> = 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<String>()
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())
}
}
@@ -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(
@@ -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<LegacyMigrationEventDto> {
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"
}
@@ -42,45 +42,52 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
}
private val workspaces: KiloBackendWorkspaceManager
get() = service<KiloBackendAppService>().workspaces
get() = app.workspaces
private val sessions: KiloBackendSessionManager
get() = service<KiloBackendAppService>().sessions
get() = app.sessions
private val chat: KiloBackendChatManager
get() = service<KiloBackendAppService>().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<Map<String, SessionStatusDto>> =
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<MessageWithPartsDto> =
chat.messages(id, directory)
ready { chat.messages(id, directory) }
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> =
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<PermissionRequestDto> =
chat.pendingPermissions(directory)
ready { chat.pendingPermissions(directory) }
override suspend fun pendingQuestions(directory: String): List<QuestionRequestDto> =
chat.pendingQuestions(directory)
ready { chat.pendingQuestions(directory) }
private suspend fun <T> ready(block: suspend () -> T): T {
app.requireReady()
return block()
}
}
@@ -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<LegacyMigrationEngine, InMemoryLegacyMigrationStore> {
val store = InMemoryLegacyMigrationStore().apply(configure)
private fun engine(configure: LegacySettingsFileFixture.() -> Unit = {}): Pair<LegacyMigrationEngine, LegacySettingsFileFixture> {
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)
}
}
@@ -11,10 +11,11 @@ import kotlin.test.assertTrue
*/
class LegacyMigrationOrchestrationTest {
private fun setup(configure: InMemoryLegacyMigrationStore.() -> Unit = {}): Triple<LegacyMigrationEngine, InMemoryLegacyMigrationStore, NoopLegacyMigrationBackend> {
val store = InMemoryLegacyMigrationStore().apply(configure)
private fun setup(configure: LegacySettingsFileFixture.() -> Unit = {}): Triple<LegacyMigrationEngine, LegacySettingsFileFixture, NoopLegacyMigrationBackend> {
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(
@@ -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<String, String> = mutableMapOf()
var mcpSettings: String? = null
var customModes: String? = null
var customModePrompts: String? = null
var autocomplete: String? = null
val globalState: MutableMap<String, JsonElement> = mutableMapOf()
var taskHistory: String? = null
val conversations: MutableMap<String, String> = mutableMapOf()
fun store(): LegacyMigrationStore {
flush()
return LegacySettingsFileMigrationStore(file)
}
fun refresh() {
migrationStatus = LegacySettingsFileMigrationStore(file).status()
}
private fun flush() {
val root = mutableMapOf<String, JsonElement>()
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)))
}
}
@@ -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<KiloAppStateDto>?,
) : MigrationUiController {
/** Platform constructor — resolves RPC lazily. */
constructor(cs: CoroutineScope) : this(cs, null)
constructor(cs: CoroutineScope) : this(cs, null, service<KiloAppService>().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>(MigrationUiState.Hidden)
override val state: StateFlow<MigrationUiState> = _state.asStateFlow()
private val checking = AtomicBoolean(false)
private val migrating = AtomicBoolean(false)
private val migrateJob = AtomicReference<Job?>(null)
init {
if (appState != null) {
cs.launch { appState.collect(::onAppState) }
}
}
// ------ RPC helper ------
private suspend fun <T> 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<String>) {
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,
@@ -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)
}
@@ -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
@@ -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
@@ -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<KiloAppStateDto>
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()
@@ -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<SessionRootPanel>(ui)
fakeMigration._state.value = MigrationUiState.Hidden
@@ -7,6 +7,7 @@ enum class KiloAppStatusDto {
DISCONNECTED,
CONNECTING,
LOADING,
MIGRATION_REQUIRED,
READY,
ERROR,
}
@@ -88,4 +89,5 @@ data class KiloAppStateDto(
val warnings: List<ConfigWarningDto> = emptyList(),
val config: ConfigDto? = null,
val profile: ProfileDto? = null,
val migration: LegacyMigrationDetectionDto? = null,
)