Изменения вводят live-канал OnlineOutbox и фоновую синхронизацию диалогов.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user