Изменения вводят live-канал OnlineOutbox и фоновую синхронизацию диалогов.

This commit is contained in:
2026-09-30 11:43:49 +03:00
parent 509763cf36
commit bd7e079780
25 changed files with 491 additions and 102 deletions
@@ -14,6 +14,7 @@ import io.ktor.http.contentType
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
@@ -45,6 +46,7 @@ internal class AgentClient private constructor(
private val agentUrl: String = baseUrl.trimEnd('/')
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
override val onlineOutbox: OnlineOutbox = HttpOnlineOutbox(httpClient = httpClient, baseUrl = agentUrl)
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
@@ -0,0 +1,46 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.outbox.OnlineOutbox
/**
* HTTP-реализация [OnlineOutbox] (= [pw.binom.agentik.outbox.OnlineOutbox]),
* ходящая в `:server`-фасад.
*
* **Endpoint**: [onlineEvents] → `GET {baseUrl}/conversations/{id}/online`
* (live-only SSE, без `after`). Сервер не реплеит — подписка получает
* только то, что эмитится после подключения. Reconnect-логика здесь не
* нужна: при обрыве поток просто закрывается, а потерянные дельты
* восстанавливаются из durable-истории ([HttpEventStore] + журнал).
*/
internal class HttpOnlineOutbox(
private val httpClient: HttpClient,
private val baseUrl: String,
) : OnlineOutbox {
private val agentUrl: String = baseUrl.trimEnd('/')
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> = flow {
val url = "$agentUrl/conversations/$conversationId/online"
httpClient.prepareGet(url) { noReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"onlineEvents: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(OnlineEvent.serializer(), payload))
}
}
}
override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
}
}
@@ -26,7 +26,7 @@ import kotlin.test.fail
/**
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability
* = 15 с убивает SSE read. Наш fix — [noReadTimeout] ставит capability
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
* перед каждым read-стримом.
*
@@ -43,7 +43,7 @@ class SseTimeoutTest {
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
@Test
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking {
fun `sse read survives past default cio timeout with noReadTimeout`(): Unit = runBlocking {
val port = freePort()
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
routing {
@@ -65,7 +65,7 @@ class SseTimeoutTest {
val received = mutableListOf<String>()
client.prepareGet("http://127.0.0.1:$port/sse") {
header("Accept", "text/event-stream")
noSseReadTimeout()
noReadTimeout()
}.execute { resp ->
val ch = resp.bodyAsChannel()
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
@@ -95,14 +95,14 @@ class SseTimeoutTest {
}
/**
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный
* Контр-тест: убеждаемся что БЕЗ [noReadTimeout] дефолтный
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
* Сервер держит stream 17 с; если клиент не выставил capability —
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
* дожидаясь "done".
*/
@Test
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
fun `without noReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
val port = freePort()
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
routing {
@@ -123,7 +123,7 @@ class SseTimeoutTest {
try {
client.prepareGet("http://127.0.0.1:$port/sse") {
header("Accept", "text/event-stream")
// НАМЕРЕННО без noSseReadTimeout.
// НАМЕРЕННО без noReadTimeout.
}.execute { resp ->
val ch = resp.bodyAsChannel()
// Читаем строки, пока не придёт "data: done" — без capability