mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
feat(jetbrains): gate legacy migration from backend
This commit is contained in:
@@ -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.
|
||||
Executable
+96
@@ -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
|
||||
+2
@@ -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()
|
||||
}
|
||||
|
||||
+67
-7
@@ -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.
|
||||
|
||||
+23
@@ -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")
|
||||
}
|
||||
}
|
||||
+85
-31
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
-41
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+24
-5
@@ -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(
|
||||
|
||||
+29
-7
@@ -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"
|
||||
}
|
||||
|
||||
+30
-13
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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(
|
||||
|
||||
+46
@@ -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)))
|
||||
}
|
||||
}
|
||||
+56
-38
@@ -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,
|
||||
|
||||
+4
-8
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+32
-55
@@ -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()
|
||||
|
||||
|
||||
-14
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user