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 @@ -18,7 +18,7 @@ sealed class 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 Ready(val data: AppData, val rev: Long = 0) : KiloAppState()
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloAppState()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
Expand Down Expand Up @@ -118,6 +119,7 @@ class KiloBackendAppService private constructor(
private var loader: Job? = null
private var closed = false
private val loadLock = Any()
private val rev = AtomicLong()

private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
val appState: StateFlow<KiloAppState> = _appState.asStateFlow()
Expand Down Expand Up @@ -338,7 +340,6 @@ class KiloBackendAppService private constructor(
private fun load() {
synchronized(loadLock) {
loader?.cancel()
eventWatcher?.cancel()
loader = cs.launch {
val start = System.currentTimeMillis()
log.info("Application starting — loading config, profile, notifications")
Expand Down Expand Up @@ -655,7 +656,7 @@ class KiloBackendAppService private constructor(
private fun setAppReady(data: AppData) {
warnings = data.warnings
if (data.warnings.isNotEmpty()) warnAppWarnings(data.warnings)
_appState.value = KiloAppState.Ready(data)
_appState.value = KiloAppState.Ready(data, rev.incrementAndGet())
}

private fun setAppError(message: String, errors: List<LoadError>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
Expand Down Expand Up @@ -472,23 +470,25 @@ class KiloBackendWorkspaceTest {
ws.state.first { it is KiloWorkspaceState.Ready }
}

// Fire rapid reloads — simulates rapid SSE disposed events
mock.providers = OPENAI_PROVIDERS_JSON
val before = mock.requestCount("/provider")

repeat(5) { ws.reload() }

// Final state must be valid Ready
withTimeout(15_000) {
while (true) {
val state = ws.state.value
if (state is KiloWorkspaceState.Ready) {
delay(300)
if (ws.state.value is KiloWorkspaceState.Ready) break
}
delay(100)
assertTrue(
mock.awaitRequestCount("/provider", before + 1),
"Workspace reload did not request providers; state=${ws.state.value}; logs=${log.messages}",
)

val state = withTimeout(15_000) {
ws.state.first {
it is KiloWorkspaceState.Ready &&
it.providers.providers.firstOrNull()?.id == "openai"
}
}
} as KiloWorkspaceState.Ready

val state = ws.state.value as KiloWorkspaceState.Ready
assertEquals(1, state.providers.providers.size)
assertEquals("openai", state.providers.providers[0].id)
assertEquals(1, state.agents.agents.size)
}

Expand All @@ -502,35 +502,29 @@ class KiloBackendWorkspaceTest {
val app = setup()
val initial = ready(app)

// Change providers response then fire disposed event
mock.providers = """{
"all": [{
"id": "openai",
"name": "OpenAI",
"source": "api",
"env": [],
"options": {},
"models": {}
}],
"default": {},
"connected": ["openai"]
}"""
mock.providers = OPENAI_PROVIDERS_JSON

assertTrue(mock.awaitSseConnection())
val prev = (app.appState.value as KiloAppState.Ready).rev
val before = mock.requestCount("/global/config")
val reload = async(start = CoroutineStart.UNDISPATCHED) {
app.appState.drop(1).first { it is KiloAppState.Ready }
app.appState.first { it is KiloAppState.Ready && it.rev > prev }
}
mock.pushEvent("global.disposed", """{"type":"global.disposed"}""")
assertTrue(
mock.awaitRequestCount("/global/config", before + 1),
"global.disposed did not start app reload; state=${app.appState.value}; logs=${log.messages}",
)
withTimeout(15_000) { reload.await() }

// Get a fresh workspace — old one was stopped during reload
val ws = app.workspaces.get("/test/project")
assertTrue(ws !== initial)
withTimeout(15_000) {
ws.state.first { it is KiloWorkspaceState.Ready }
}

val state = ws.state.value as KiloWorkspaceState.Ready
val state = withTimeout(15_000) {
ws.state.first {
it is KiloWorkspaceState.Ready &&
it.providers.providers.firstOrNull()?.id == "openai"
}
} as KiloWorkspaceState.Ready
assertEquals("openai", state.providers.providers[0].id)
}

Expand Down Expand Up @@ -572,6 +566,19 @@ class KiloBackendWorkspaceTest {
"connected": ["anthropic"]
}""".trimIndent()

private val OPENAI_PROVIDERS_JSON = """{
"all": [{
"id": "openai",
"name": "OpenAI",
"source": "api",
"env": [],
"options": {},
"models": {}
}],
"default": {},
"connected": ["openai"]
}""".trimIndent()

private val AGENTS_JSON = """[
{"name":"code","displayName":"Code","mode":"primary","permission":[],"options":{}}
]""".trimIndent()
Expand Down
Loading