Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,4 @@ data class AppData(
val profile: KiloProfile200Response?,
val config: ConfigDto,
val notifications: List<KiloNotifications200ResponseInner>,
val warnings: List<ConfigWarning> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import ai.kilocode.jetbrains.api.infrastructure.ClientError
import ai.kilocode.jetbrains.api.infrastructure.ClientException
import ai.kilocode.jetbrains.api.infrastructure.ServerError
import ai.kilocode.jetbrains.api.infrastructure.ServerException
import ai.kilocode.jetbrains.api.model.ConfigWarnings200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest
Expand Down Expand Up @@ -148,9 +147,6 @@ class KiloBackendAppService private constructor(
@Volatile var notifications: List<KiloNotifications200ResponseInner> = emptyList()
private set

@Volatile var warnings: List<ConfigWarning> = emptyList()
private set

suspend fun connect() {
mutex.withLock {
val current = _appState.value
Expand Down Expand Up @@ -217,24 +213,15 @@ class KiloBackendAppService private constructor(
log.info("retry: rerunning migration detection")
load()
}
is KiloAppState.Ready -> {
if (current.data.warnings.isEmpty()) return
log.info("retry: refreshing config warnings")
refreshConfigState()
val next = _appState.value
val warns = (next as? KiloAppState.Ready)?.data?.warnings
if (next is KiloAppState.Ready && warns.isNullOrEmpty()) return
restartConnection("warnings remained after refresh")
}
is KiloAppState.Ready -> Unit
is KiloAppState.Error -> {
val load = current.errors.none { it.resource == "connection" }
if (load && connection.api != null) {
log.info("retry: rerunning app load from ${current.message}")
val prev = _appState.value
load()
val next = awaitLoadResult(prev)
val warns = (next as? KiloAppState.Ready)?.data?.warnings
if (next is KiloAppState.Ready && warns.isNullOrEmpty()) return
if (next is KiloAppState.Ready) return
restartConnection("state remained problematic after load retry")
return
}
Expand Down Expand Up @@ -305,11 +292,10 @@ class KiloBackendAppService private constructor(
log.warn("Global config patch: config reload failed after save $summary")
return (_appState.value as? KiloAppState.Ready) ?: current
}
val warns = fetchWarnings()
val state = _appState.value
if (state is KiloAppState.Ready && state.data === current.data && connection.state.value == connected) {
config = cfg
setAppReady(current.data.copy(config = cfg, warnings = warns))
setAppReady(current.data.copy(config = cfg))
}
log.info("Global config patch: state refreshed $summary")
return (_appState.value as? KiloAppState.Ready) ?: current
Expand Down Expand Up @@ -397,7 +383,6 @@ class KiloBackendAppService private constructor(
profile = null
config = null
notifications = emptyList()
warnings = emptyList()
_appState.value = KiloAppState.MigrationRequired(migration)
log.info("Application paused — legacy migration required")
return@launch
Expand All @@ -407,7 +392,6 @@ class KiloBackendAppService private constructor(
var cfg: ConfigDto? = null
var prof: KiloProfile200Response? = null
var notifs: List<KiloNotifications200ResponseInner> = emptyList()
var warns: List<ConfigWarning> = emptyList()

try {
withTimeout(loadTimeoutMs) {
Expand Down Expand Up @@ -455,8 +439,6 @@ class KiloBackendAppService private constructor(
}
}

warns = fetchWarnings()

ensureActive()
profile = prof
config = cfg
Expand All @@ -471,20 +453,18 @@ class KiloBackendAppService private constructor(
captureBackend("Backend Connected", mapOf("portKnown" to "true"))
captureLoad("Backend Load Completed", start, mapOf(
"profileStatus" to if (prof != null) "loaded" else "not_logged_in",
"warningCount" to warns.size.toString(),
"migrationRequired" to "false",
))
setAppReady(
AppData(
profile = prof,
config = cfg!!,
notifications = notifs,
warnings = warns,
)
)
log.info(
"Application snapshot: profile=${if (prof != null) "loaded" else "not_logged_in"} " +
"warnings=${warns.size} notifications=${notifs.size} ${configSummary(cfg)}",
"notifications=${notifs.size} ${configSummary(cfg)}",
)
log.info("Application started — config, profile, notifications loaded")
} catch (e: TimeoutCancellationException) {
Expand Down Expand Up @@ -679,42 +659,18 @@ class KiloBackendAppService private constructor(
}
}

private suspend fun fetchWarnings(): List<ConfigWarning> {
val client = connection.appLoadApi ?: return emptyList()
return try {
client.configWarnings().map(::warning)
} catch (e: Exception) {
log.warn("Config warnings fetch failed: ${e.message}", e)
emptyList()
}
}

private fun warning(w: ConfigWarnings200ResponseInner) = ConfigWarning(
path = w.path,
message = w.message,
detail = w.detail,
)

private suspend fun refreshConfigState() {
val current = _appState.value as? KiloAppState.Ready ?: return
val connection = connection.state.value as? ConnectionState.Connected ?: return
val cfg = fetchConfig().value ?: return
val warns = fetchWarnings()
val state = _appState.value
if (state !is KiloAppState.Ready || state.data !== current.data) return
if (this.connection.state.value != connection) return
config = cfg
setAppReady(
current.data.copy(
config = cfg,
warnings = warns,
)
)
setAppReady(current.data.copy(config = cfg))
}

private fun setAppReady(data: AppData) {
warnings = data.warnings
if (data.warnings.isNotEmpty()) warnAppWarnings(data.warnings)
_appState.value = KiloAppState.Ready(data, rev.incrementAndGet())
}

Expand All @@ -730,22 +686,12 @@ class KiloBackendAppService private constructor(
log.warn("App error: $text")
}

private fun warnAppWarnings(warnings: List<ConfigWarning>) {
val text = warnings.joinToString("; ") { warning(it) }
log.warn("App warnings: $text")
}

private fun error(err: LoadError): String {
val status = err.status?.let { " status=$it" } ?: ""
val detail = err.detail?.let { " detail=$it" } ?: ""
return "${err.resource}$status$detail"
}

private fun warning(warn: ConfigWarning): String {
val detail = warn.detail?.let { " detail=$it" } ?: ""
return "${warn.path}: ${warn.message}$detail"
}

private fun configSummary(cfg: ConfigDto): String {
val text = cfg.toString()
return "configChars=${text.length} configHash=${text.hashCode().toUInt().toString(16)}"
Expand Down Expand Up @@ -890,7 +836,6 @@ class KiloBackendAppService private constructor(
profile = null
config = null
notifications = emptyList()
warnings = emptyList()
_appState.value = KiloAppState.Disconnected
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,11 @@ class KiloBackendCliManager(
builder.environment().clear()
builder.environment().putAll(env)
builder.redirectErrorStream(false)
workDir()?.let { builder.directory(it) }

log.info("Starting CLI: ${cmd.joinToString(" ")}")
log.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
log.info("CLI cwd: ${builder.directory()?.absolutePath ?: "<inherited>"}")
val proc = try {
builder.start()
} catch (e: Exception) {
Expand Down Expand Up @@ -610,6 +612,23 @@ internal fun buildKiloCliEnv(
devStorageEnv(log)?.forEach { entry -> put(entry.key, entry.value) }
}

/**
* Working directory for the spawned CLI. The CLI resolves a request with no `directory`
* to `process.cwd()`, so inheriting the IDE cwd (typically the user's home on macOS)
* makes those requests target $HOME. Point it at an empty plugin-owned directory instead.
*/
internal fun workDir(
root: File = File(PathManager.getSystemPath(), "kilo"),
log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java),
): File? {
val dir = File(root, "cwd")
// mkdirs() returns false when the directory already exists, including when a concurrent
// spawn created it, so treat an existing directory as success.
if (dir.mkdirs() || dir.isDirectory) return dir
log.warn("Could not create CLI working directory $dir; inheriting IDE working directory")
return null
}

private fun ideEnv(log: KiloLog): Map<String, String> = buildMap {
runCatching {
val info = ApplicationInfo.getInstance()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package ai.kilocode.backend.rpc
import ai.kilocode.backend.app.KiloAppState
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.telemetry.KiloBackendTelemetry
import ai.kilocode.backend.app.ConfigWarning
import ai.kilocode.backend.app.LoadError
import ai.kilocode.backend.app.LoadProgress
import ai.kilocode.backend.app.ProfileResult
Expand All @@ -18,7 +17,6 @@ import ai.kilocode.log.KiloLog
import ai.kilocode.log.LogConfig
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.DeviceAuthDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
Expand Down Expand Up @@ -179,7 +177,6 @@ internal fun appStateDto(state: KiloAppState): KiloAppStateDto =
profile = if (state.data.profile != null) ProfileStatusDto.LOADED
else ProfileStatusDto.NOT_LOGGED_IN,
),
warnings = state.data.warnings.map(::warning),
config = state.data.config,
profile = state.data.profile?.let(::profileDto),
)
Expand Down Expand Up @@ -230,9 +227,3 @@ private fun error(e: LoadError) = LoadErrorDto(
status = e.status,
detail = e.detail,
)

private fun warning(w: ConfigWarning) = ConfigWarningDto(
path = w.path,
message = w.message,
detail = w.detail,
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ai.kilocode.backend.rpc

import ai.kilocode.backend.app.ConfigWarning
import ai.kilocode.backend.app.LoadError
import ai.kilocode.backend.workspace.AgentData
import ai.kilocode.backend.workspace.AgentInfo
Expand All @@ -16,6 +17,7 @@ import ai.kilocode.rpc.dto.ModelCostDto
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.AgentsDto
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.ModelDto
Expand Down Expand Up @@ -72,6 +74,12 @@ internal object KiloWorkspaceDtoMapper {
editable = false,
)

fun warning(w: ConfigWarning) = ConfigWarningDto(
path = w.path,
message = w.message,
detail = w.detail,
)

private fun provider(p: ProviderInfo) = ProviderDto(
id = p.id,
name = p.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ class KiloWorkspaceRpcApiImpl internal constructor(
agents = KiloWorkspaceDtoMapper.agents(state.agents),
commands = state.commands.map(KiloWorkspaceDtoMapper::command),
skills = state.skills.map(KiloWorkspaceDtoMapper::skill),
warnings = state.warnings.map(KiloWorkspaceDtoMapper::warning),
)
is KiloWorkspaceState.Unsupported -> KiloWorkspaceStateDto(
status = KiloWorkspaceStatusDto.UNSUPPORTED,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package ai.kilocode.backend.workspace

import ai.kilocode.backend.app.ConfigWarning
import ai.kilocode.backend.app.KiloBackendSessionManager
import ai.kilocode.backend.app.LoadError
import ai.kilocode.backend.app.SseEvent
import ai.kilocode.backend.cli.KiloBackendHttpClients
import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.log.KiloLog
import ai.kilocode.jetbrains.api.client.DefaultApi
Expand Down Expand Up @@ -57,11 +59,24 @@ class KiloBackendWorkspace(
companion object {
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
private const val WARNINGS_TIMEOUT_SECONDS = 5L
}

private val _state = MutableStateFlow<KiloWorkspaceState>(KiloWorkspaceState.Pending)
val state: StateFlow<KiloWorkspaceState> = _state.asStateFlow()

/**
* Config warnings are optional, so they use a bounded client rather than the workspace [api],
* which has no call/read timeout. A stalled fetch would otherwise hold the workspace on
* Connecting even though the required catalog already loaded.
*/
private val warningsApi by lazy {
DefaultApi(
basePath = "http://127.0.0.1:$port",
client = KiloBackendHttpClients.bounded(http, WARNINGS_TIMEOUT_SECONDS),
)
}

private var loader: Job? = null
private var eventWatcher: Job? = null
private val loadLock = Any()
Expand Down Expand Up @@ -149,13 +164,16 @@ class KiloBackendWorkspace(
}
}

ensureActive()
val warns = warnings()
Comment thread
kirillk marked this conversation as resolved.
ensureActive()
startWatchingGlobalSseEvents()
_state.value = KiloWorkspaceState.Ready(
providers = prov!!,
agents = ag!!,
commands = cmd!!,
skills = sk!!,
warnings = warns,
)
log.info("Workspace data loaded for $directory")
} catch (e: CancellationException) {
Expand Down Expand Up @@ -198,6 +216,8 @@ class KiloBackendWorkspace(
*
* - `global.disposed` — CLI server context torn down, all data stale.
* - `server.instance.disposed` — server instance disposed, reload.
* - `global.config.updated` — project config changed on disk or via CLI; refresh only
* config warnings in-place, without reloading providers/agents/commands/skills.
*
* Idempotent — only one watcher runs at a time.
*/
Expand All @@ -216,14 +236,42 @@ class KiloBackendWorkspace(
log.info("SSE server.instance.disposed — reloading workspace data for $directory")
load()
}
"global.config.updated" -> {
log.info("SSE global.config.updated — refreshing config warnings for $directory")
// Launched so a slow fetch cannot block this collector and stall the
// shared global SSE flow for chat and other workspaces.
launch { refreshWarnings() }
}
}
}
}
}
}

/** Refetches config warnings and updates [Ready][KiloWorkspaceState.Ready] in-place, if still current. */
private suspend fun refreshWarnings() {
val current = _state.value
if (current !is KiloWorkspaceState.Ready) return
val next = warnings()
val latest = _state.value
if (latest === current) _state.value = current.copy(warnings = next)
}

// ------ fetch methods ------

private suspend fun warnings(): List<ConfigWarning> = withContext(Dispatchers.IO) {
try {
warningsApi.configWarnings(directory = directory).map {
ConfigWarning(path = it.path, message = it.message, detail = it.detail)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log.warn("Config warnings fetch failed for $directory: ${e.message}", e)
emptyList()
}
}

private suspend fun fetchProviders(): FetchResult<ProviderData> = withContext(Dispatchers.IO) {
try {
FetchResult.ok(KiloCliDataParser.parseProviders(fetch("/provider?directory=${encode(directory)}")))
Expand Down
Loading
Loading