Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.
Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.
outbox-api:
- Cursor (offset: Long) — курсор в журнале событий агента.
- OffsetSequencer — интерфейс резервирования уникального offset.
- CursorStore — персистентное хранилище текущего offset'а.
- PersistentOffsetSequencer — декоратор над любым OutboxStore,
обновляет CursorStore на каждом append (atomic transaction).
- OutboxGapException — клиент запросил after < earliestCursor() →
сервер не может удовлетворить, клиент обязан делать full resync.
- DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
был общим sealed-типом, теперь это термин из спеки).
- MutableOutboxStore и OutboxStore теперь читают offset через
CursorStore вместо in-memory counter'а.
outbox-inmemory:
- InMemoryOffsetSequencer — для тестов и dev-режима.
- InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.
outbox-ksqlite (новый модуль):
- KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
- KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).
proto + server:
- Snapshot.proto — server-state snapshot endpoint для клиентов,
которым нужна полная материализация (использование TBD).
- Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).
journal-ksqlite:
- KsqliteJournalStore.listFlow/append — без изменений по API,
нотации минимальные (codecs).
standalone:
- DurableLog (бывший ChatAgent-orchestration) — атомарный commit
события в OutboxStore + PersistentOffsetSequencer + materialization
(через Reducer) одной транзакцией.
- SqliteStores — добавляет KsqliteCursorStore в bundle, единая
shared-connection для всех ksqlite-сторов standalone-агента.
- ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
ToolDispatcher — переход на новые абстракции.
- standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
включено (раньше было закомментировано — модуль только создавался).
client:
- AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
ReconnectingOutbox — используют Cursor через transport API.
- client/README.md — синхронизирован с новым поведением (468 строк
diff — это в основном оформление и примеры).
kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).
SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.
Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.
sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
+216
-254
@@ -13,14 +13,19 @@
|
||||
`deleteConversation` / `journal` / `outbox` / `close`.
|
||||
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
|
||||
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
|
||||
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
|
||||
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
|
||||
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
|
||||
- `HttpJournalStore` — `list(convId, afterSeq, upToSeq, limit)` /
|
||||
`count(convId, afterSeq)` → `List<MessageRecord>` со всеми типами записей
|
||||
(User/Assistant/ToolCall/ToolResult/Error + tokens). Адресация — по `seq`
|
||||
(см. «Курсорный протокол»), не по датам.
|
||||
- `HttpEventStore` — `events(after: Cursor?)` / `agentEvents` /
|
||||
`conversationEvents` (SSE), `currentCursor()` / `oldestCursor()`.
|
||||
- `Agent.conversationsSnapshot()` / `Agent.chatSnapshot(convId)` — состояние +
|
||||
`Cursor`, на котором оно валидно. Точка входа resync'а.
|
||||
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
|
||||
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
|
||||
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
|
||||
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
|
||||
с основным потоком событий. См. ниже.
|
||||
потока: `events(after: Cursor?)` (те же `CommonEvent`) и `connectionStatus()`
|
||||
(`Connecting`/`Connected`/`Disconnected`/`Failed`/`Gap`) — статус НЕ мешается
|
||||
с основным потоком событий. Мёртвый курсор даёт `Gap` (не ретраится). См. ниже.
|
||||
|
||||
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
|
||||
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
|
||||
@@ -65,6 +70,95 @@ dependencies {
|
||||
|
||||
⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере.
|
||||
|
||||
## Курсорный протокол (как получить гарантированно актуальное состояние)
|
||||
|
||||
Всё серьёзное в `:client` крутится вокруг одного понятия — **курсор события**
|
||||
(`Cursor(epoch, offset)`), аналога Kafka-offset. Он монотонный, сквозной на
|
||||
все события агента (одна общая нумерация для `AgentEvent` и `Conversation`
|
||||
-событий) и лежит **над** двумя хранилищами:
|
||||
|
||||
- **`OutboxStore`** — короткий bounded-tail live-поток `CommonEvent`
|
||||
(уведомления/дельты). Хранится ограниченно (cap/TTL), события вытесняются.
|
||||
- **`journal` + `conversationStore`** — персистентный источник истины
|
||||
(полное состояние). У каждой записи есть свой `seq` из того же счётчика.
|
||||
|
||||
Ключевое свойство: **`CommonEvent.offset` == `MessageRecord.seq` ==
|
||||
`ConversationRecord.seq`**. Событие с `offset = N` — это ровно «строка состояния
|
||||
с `seq = N` изменилась (или появилась/удалилась)». События **абсолютные**: в
|
||||
`UserMessage`/`AssistantMessage` лежит целая запись, `Renamed` несёт новый
|
||||
заголовок, `Deleted` — «строки больше нет». Поэтому накатывать их на состояние
|
||||
можно повторно (идемпотентно по `id`) и в любом порядке относительно снапшота.
|
||||
|
||||
### Инвариант, на котором стоит гарантия
|
||||
|
||||
1. **Писатель** (сервер) сначала пишет строку состояния с `seq = N`, потом
|
||||
кладёт событие с `offset = N` в outbox. `seq` и `offset` — один счётчик.
|
||||
2. **Читатель** (клиент) читает **сначала курсор, потом состояние**:
|
||||
`C = currentCursor()` → `state = listUpTo(C)`. Всё, что `≤ C`, уже в снапшоте;
|
||||
всё, что `> C`, придёт потоком.
|
||||
3. **Применение идемпотентно** (upsert/delete/rename по `id`), поэтому
|
||||
перекрытие снапшота и дельт безвредно.
|
||||
|
||||
Ничего не блокируется. Снапшот — это **не** «заморозка таблицы на время
|
||||
выгрузки»: это baseline на курсоре `C` плюс накат всех дельт `> C`.
|
||||
|
||||
### Правильная последовательность синхронизации
|
||||
|
||||
```
|
||||
1. lastSeen = локально сохранённый курсор (или null при первом запуске)
|
||||
2. попытка: outbox.events(after = lastSeen) ← если сервер ответил
|
||||
OutboxGapException / ConnectionStatus.Gap → курсор мёртв, иди в п.3
|
||||
3. ПОЛНЫЙ RESYNC:
|
||||
a. очистить локальную БД (строки + курсор), пометить «resyncing»
|
||||
b. C = agent.conversationsSnapshot().cursor (или .chatSnapshot(convId))
|
||||
c. подписаться events(after = C) и СКОПИРОВАТЬ события в буфер (не применять!)
|
||||
d. прочитать полное состояние: listUpTo(C) / snapshot.messages
|
||||
e. применить снапшот целиком
|
||||
f. применить буфер дельт в порядке offset
|
||||
4. дальше: применение каждого события из потока (upsert by id)
|
||||
5. сохранить последний offset как lastSeen
|
||||
```
|
||||
|
||||
Порядок из шага 3 критичен: **сначала подписка, потом снапшот**. Если сделать
|
||||
наоборот (снапшот, потом подписка) — события, пришедшие в промежуток, потеряются.
|
||||
Буферизация (а не «применять на лету») закрывает delete-resurrection: событие
|
||||
`Deleted(offset > C)` для строки, которая ещё лежит в необработанной странице
|
||||
снапшота, при применении «на лету» было бы стёрто, а потом снапшот вставил бы
|
||||
строку обратно.
|
||||
|
||||
### Курсор мёртв: `OutboxGapException`
|
||||
|
||||
Клиент давно не заходил, outbox вытеснил его события (`after.offset <
|
||||
oldestCursor().offset`), либо сменилась `epoch` (БД сервера откатили/
|
||||
восстановили/скопировали — счётчик начал считаться заново). Сервер отвечает
|
||||
`410 Gone`. Клиент **не ретраит** — это сигнал «сделай полный resync»
|
||||
(шаг 3 выше). С `ReconnectingOutbox` это приходит как
|
||||
`ConnectionStatus.Gap`, поток закрывается, background-loop встаёт.
|
||||
|
||||
**Никогда не ретрай `OutboxGapException`** — ретрай никогда не пройдёт.
|
||||
|
||||
### Простой вариант: пересоздать outbox на resync
|
||||
|
||||
Если своя реализация шага 3 кажется тяжёлой — минимальный корректный путь
|
||||
через `ReconnectingOutbox`:
|
||||
|
||||
```kotlin
|
||||
var recon = ReconnectingOutbox(agent.outbox, scope)
|
||||
scope.launch { recon.events(after = lastSeen).collect { applyEvent(it) } }
|
||||
scope.launch {
|
||||
recon.connectionStatus().collect { s ->
|
||||
if (s is ConnectionStatus.Gap) {
|
||||
recon.close()
|
||||
val snap = agent.chatSnapshot(convId) // state + cursor
|
||||
applySnapshot(snap.messages) // upsert by id
|
||||
lastSeen = snap.cursor
|
||||
recon = ReconnectingOutbox(agent.outbox, scope)
|
||||
scope.launch { recon.events(after = lastSeen).collect { applyEvent(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Быстрый старт: свой клиент за 5 минут
|
||||
|
||||
Один self-contained пример: создаём агента, открываем диалог,
|
||||
@@ -73,12 +167,11 @@ dependencies {
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.time.Clock
|
||||
|
||||
fun main() = runBlocking {
|
||||
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
|
||||
@@ -91,17 +184,20 @@ fun main() = runBlocking {
|
||||
|
||||
val conv = agent.createConversation(temp = false)
|
||||
|
||||
// Подписка «после текущего курсора» — событий строго после этой точки.
|
||||
val cursor = agent.outbox.currentCursor()
|
||||
|
||||
// 2. Два независимых потока событий диалога:
|
||||
// durable (outbox) — целые события, с курсором после переподключения;
|
||||
// online (OnlineOutbox) — стриминг ответа, только live (без курсора).
|
||||
launch {
|
||||
agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id)
|
||||
agent.outbox.conversationEvents(after = cursor, conversationId = conv.id)
|
||||
.collect { ce ->
|
||||
when (val ev = ce.event) {
|
||||
is Event.AssistantMessage -> println("[answer ready: ${ev.content}]")
|
||||
is Event.Interrupted -> println("[interrupted]")
|
||||
is Event.Error -> println("[error: ${ev.message}]")
|
||||
else -> Unit
|
||||
is DurableEvent.AssistantMessage -> println("[answer ready: ${ev.content}]")
|
||||
is DurableEvent.Interrupted -> println("[interrupted]")
|
||||
is DurableEvent.Error -> println("[error: ${ev.message}]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,12 +225,12 @@ fun main() = runBlocking {
|
||||
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
||||
Ты только получаешь два типизированных `Flow` и рендеришь как хочешь.
|
||||
|
||||
> **Durable vs online.** `Event` (в `agent.outbox`) — «целые» события, их
|
||||
> **Durable vs online.** `DurableEvent` (в `agent.outbox`) — «целые» события, их
|
||||
> можно перезапросить по курсору `after`. `OnlineEvent` (в
|
||||
> `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/
|
||||
> `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при
|
||||
> обрыве фрагмент невосстановим, но целый ответ всегда придёт durable-
|
||||
> `Event.AssistantMessage` и/или ляжет в journal.
|
||||
> `DurableEvent.AssistantMessage` и/или ляжет в journal.
|
||||
|
||||
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
|
||||
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
||||
@@ -143,19 +239,20 @@ fun main() = runBlocking {
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import kotlin.time.Instant
|
||||
|
||||
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
|
||||
val cache = InMemoryJournalStore()
|
||||
|
||||
// Backfill + live-refresh в одном фоне:
|
||||
// Снапшот на курсоре + подписка ПОСЛЕ него — без потерь (см. «Курсорный протокол»).
|
||||
val snap = agent.chatSnapshot(conv.id)
|
||||
cache.appendAll(snap.messages)
|
||||
launch {
|
||||
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
|
||||
.collect { cache.append(it) }
|
||||
agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
|
||||
.collect { ce -> applyDurable(ce.event, cache) } // upsert by id
|
||||
}
|
||||
|
||||
// История — теперь из кэша, без HTTP:
|
||||
val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||
val history = cache.list(conv.id, afterSeq = 0L, upToSeq = Long.MAX_VALUE, limit = Int.MAX_VALUE)
|
||||
history.forEach { rec ->
|
||||
when (rec) {
|
||||
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
|
||||
@@ -167,17 +264,18 @@ history.forEach { rec ->
|
||||
}
|
||||
```
|
||||
|
||||
Шаблон "remote.listFlow → local.append" работает с любым
|
||||
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
|
||||
"без геморроя".
|
||||
Шаблон «snapshot(курсор) → local.apply → live-дельты после курсора» работает с
|
||||
любым `MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
|
||||
«без геморроя» с гарантией актуальности.
|
||||
|
||||
### Что вообще не нужно писать самому
|
||||
|
||||
- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует
|
||||
- HTTP-сериализация `DurableEvent`/`Message` — `agentikHttpClient` регистрирует
|
||||
`agentikJson` и `InstantSerializer`.
|
||||
- SSE-парсер — `readSse()` внутри `:client`.
|
||||
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в
|
||||
`JournalStore.listFlow` сама пагинирует.
|
||||
- Cursor-менеджмент — сервер ведёт единый монотонный `offset`/`seq`, клиент
|
||||
лишь хранит `Cursor(epoch, offset)`. Никаких `Instant`-сравнений и
|
||||
pagination-циклов вручную.
|
||||
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
|
||||
- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)`
|
||||
с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает.
|
||||
@@ -187,7 +285,7 @@ history.forEach { rec ->
|
||||
|
||||
### Что нужно написать самому
|
||||
|
||||
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
|
||||
- UI-рендеринг `DurableEvent`'ов — это твоё (Compose/HTML/CLI).
|
||||
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
|
||||
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
|
||||
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
|
||||
@@ -198,7 +296,7 @@ history.forEach { rec ->
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -212,13 +310,14 @@ val agent = AgentikAgent(
|
||||
val conv = agent.createConversation(temp = false)
|
||||
|
||||
// durable-поток (с курсором): terminal-события хода.
|
||||
val cursor = agent.outbox.currentCursor()
|
||||
launch {
|
||||
agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id)
|
||||
agent.outbox.conversationEvents(after = cursor, conversationId = conv.id)
|
||||
.collect { ce ->
|
||||
when (ce.event) {
|
||||
is Event.AssistantMessage -> println("\n--- answer ready ---")
|
||||
is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}")
|
||||
else -> Unit
|
||||
is DurableEvent.AssistantMessage -> println("\n--- answer ready ---")
|
||||
is DurableEvent.Error -> error("agent error: ${(ce.event as DurableEvent.Error).message}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,268 +334,122 @@ launch {
|
||||
|
||||
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
||||
```
|
||||
## Локальный кэш истории (правильный паттерн)
|
||||
|
||||
## История с локальным кэшем
|
||||
|
||||
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
|
||||
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
|
||||
чтение истории — из локального кэша.
|
||||
|
||||
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
|
||||
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
|
||||
реализовывал интерфейс.
|
||||
Клиент держит свой `MutableJournalStore` и наполняет его **снапшотом на
|
||||
курсоре + дельтами после курсора** (см. «Курсорный протокол»). Чтение истории —
|
||||
из локального кэша, без HTTP.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
|
||||
// Кэш. Для диска — свой MutableJournalStore (KsqliteJournalStore в :journal-ksqlite).
|
||||
val cache = InMemoryJournalStore()
|
||||
|
||||
class ChatSession(
|
||||
private val agent: pw.binom.agentik.proto.Agent,
|
||||
val conversationId: String,
|
||||
) : AutoCloseable {
|
||||
|
||||
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
|
||||
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
|
||||
// (модуль `:journal-api`).
|
||||
val cache = InMemoryJournalStore()
|
||||
|
||||
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
|
||||
private val scope = kotlinx.coroutines.CoroutineScope(
|
||||
kotlinx.coroutines.SupervisorJob() +
|
||||
kotlinx.coroutines.Dispatchers.Default,
|
||||
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Default,
|
||||
)
|
||||
var lastSeen: pw.binom.agentik.outbox.Cursor? = null
|
||||
|
||||
init {
|
||||
// 1. Backfill: забираем всю историю разговора с сервера.
|
||||
scope.launch {
|
||||
agent.journal.listFlow(
|
||||
conversationId = conversationId,
|
||||
after = Instant.DISTANT_PAST,
|
||||
).collect { cache.append(it) }
|
||||
}
|
||||
// 2. Live: на каждом завершённом ходе (durable AssistantMessage)
|
||||
// просим у сервера новые записи.
|
||||
scope.launch {
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce ->
|
||||
if (ce.event is Event.AssistantMessage) {
|
||||
val newest = cache.let {
|
||||
// last-seen курсор — последний createdAt в кэше
|
||||
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
||||
?: Instant.DISTANT_PAST
|
||||
}
|
||||
agent.journal.list(conversationId, newest, offset = 0, limit = 100)
|
||||
.forEach { cache.append(it) }
|
||||
// 1. Снапшот: состояние + курсор, на котором оно валидно.
|
||||
val snap = agent.chatSnapshot(conversationId)
|
||||
snap.messages.forEach { cache.append(it) }
|
||||
lastSeen = snap.cursor
|
||||
// 2. Дельты строго после курсора снапшота.
|
||||
agent.outbox.conversationEvents(after = snap.cursor, conversationId = conversationId)
|
||||
.collect { ce ->
|
||||
applyToCache(ce.event)
|
||||
lastSeen = ce.cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyToCache(e: DurableEvent) {
|
||||
when (e) {
|
||||
is DurableEvent.UserMessage -> cache.append(e.toRecord())
|
||||
is DurableEvent.AssistantMessage -> cache.append(e.toRecord())
|
||||
is DurableEvent.ToolCall -> cache.append(e.toRecord())
|
||||
is DurableEvent.ToolResult -> cache.append(e.toRecord())
|
||||
is DurableEvent.Error -> cache.append(e.toRecord())
|
||||
is DurableEvent.Interrupted -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun history() = kotlinx.coroutines.runBlocking {
|
||||
cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||
cache.list(conversationId, afterSeq = 0L, upToSeq = Long.MAX_VALUE, limit = Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Использование:
|
||||
val session = ChatSession(agent, conv.id)
|
||||
|
||||
// История — из кэша:
|
||||
session.history().forEach { rec ->
|
||||
when (rec) {
|
||||
is MessageRecord.UserMessage -> println("user: ${rec.content.text()}")
|
||||
is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}")
|
||||
is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}")
|
||||
is MessageRecord.ToolResult -> println("tool-result: ${rec.result}")
|
||||
is MessageRecord.Error -> println("error: ${rec.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Отправить новое сообщение:
|
||||
session.scope.launch {
|
||||
agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз")))
|
||||
override fun close() { scope.cancel() }
|
||||
}
|
||||
```
|
||||
|
||||
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
|
||||
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
|
||||
`rec is MessageRecord.UserMessage` для реплик пользователя,
|
||||
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
|
||||
> `applyToCache` через `cache.append` даёт upsert по `id` (append-only store
|
||||
> отбрасывает дубликаты `id`), поэтому перекрытие снапшота и дельт безвредно.
|
||||
> Замените `InMemoryJournalStore` на `KsqliteJournalStore` — код не меняется.
|
||||
|
||||
### Когда курсор мёртв
|
||||
|
||||
Если `conversationEvents(after = ...)` бросает `OutboxGapException` (или
|
||||
`ReconnectingOutbox` эмитит `ConnectionStatus.Gap`) — клиент был оффлайн дольше
|
||||
retention'а. Повторите всю последовательность с шага 1 (снапшот), **предварительно
|
||||
очистив локальную БД** (`cache.clear(conversationId)`), иначе воскреснут
|
||||
удалённые строки. Полный алгоритм — в «Курсорный протокол» выше.
|
||||
|
||||
## Кэш списка бесед
|
||||
|
||||
`agent.conversationStore` — read-only view поверх `conversation`-таблицы
|
||||
`agent.conversationStore` — read-only projection поверх таблицы `conversation`
|
||||
на сервере (`ConversationRecord` = id / title / isTemporal / createdAt /
|
||||
updatedAt, без `Conversation` handle и без флагов image-support).
|
||||
|
||||
**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы
|
||||
при открытии UI уже знал названия, не дёргал сервер лишний раз, и
|
||||
моментально реагировал на создание/удаление/переименование в другой
|
||||
вкладке.
|
||||
|
||||
Подход — тот же **«remote → local snapshot + live-events»**:
|
||||
updatedAt). `AgentikAgent` оборачивает его в локальный кэш
|
||||
(`wrapWithLocalConversationCache`) по тому же протоколу, что и историю:
|
||||
снапшот на курсоре + live-дельты.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
|
||||
// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш:
|
||||
// remote.listFlow → local.upsert (snapshot)
|
||||
// outbox.agentEvents → local.upsert / delete (live)
|
||||
val agent = AgentikAgent(
|
||||
id = "agentik",
|
||||
baseUrl = "http://localhost:8080/agentik",
|
||||
engineFactory = CIO,
|
||||
)
|
||||
val agent = AgentikAgent(id = "agentik", baseUrl = "http://localhost:8080/agentik", engineFactory = CIO)
|
||||
|
||||
// Кэш уже наполняется в фоне, читать можно сразу:
|
||||
val all = agent.conversationStore.list(0, Int.MAX_VALUE)
|
||||
all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") }
|
||||
// Снапшот списка бесед + его курсор (глобальный для агента).
|
||||
val snap = agent.conversationsSnapshot()
|
||||
snap.conversations.forEach { println("${it.id} ${it.title ?: "(no title)"} ${it.updatedAt}") }
|
||||
|
||||
// И наблюдать live-изменения (Created/Deleted/Renamed/Touched)
|
||||
agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev ->
|
||||
when (ev) {
|
||||
is AgentEvent.Created -> println("+ ${ev.conversationId}")
|
||||
is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}")
|
||||
is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})")
|
||||
is AgentEvent.Deleted -> println("- ${ev.id}")
|
||||
// Дельты после курсора снапшота: Created / Deleted / Renamed / Touched.
|
||||
agent.outbox.agentEvents(after = snap.cursor).collect { ce ->
|
||||
when (val ev = ce.event) {
|
||||
is AgentEvent.Created -> println("+ ${ev.conversationId}")
|
||||
is AgentEvent.Renamed -> println("~ ${ev.id} -> ${ev.title}")
|
||||
is AgentEvent.Touched -> println("~ ${ev.id} (${ev.updatedAt})")
|
||||
is AgentEvent.Deleted -> println("- ${ev.id}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP
|
||||
для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как
|
||||
escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен —
|
||||
подмени его на свою реализацию через `wrapWithLocalConversationCache`,
|
||||
если нужен SQLite/JSON-store.
|
||||
|
||||
## Стриминг live-ответа
|
||||
|
||||
Для streaming-рендера текущего хода подписывайся на `events()` и
|
||||
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
|
||||
только для UI-feedback во время хода. После `End` хода запись уже
|
||||
появится в кэше через refresh-блок выше.
|
||||
**Команды** (создать / переименовать / удалить) идут через `agent`; сервер сам
|
||||
эмитит соответствующее `AgentEvent` в outbox, клиент применяет его к кэшу:
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
|
||||
agent.onlineOutbox.onlineEvents(convId).collect { ev ->
|
||||
when (ev) {
|
||||
is OnlineEvent.Working -> println("[working]")
|
||||
is OnlineEvent.StartResponse -> println("[start]")
|
||||
is OnlineEvent.AppendText -> print(ev.body)
|
||||
is OnlineEvent.AppendImage -> showImage(ev.body)
|
||||
is OnlineEvent.End -> println("[end]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
val conv = agent.createConversation(temp = false) // POST /conversations -> Created
|
||||
agent.renameConversation(conv.id, "Новый заголовок") // PATCH /conversations/{id} -> Renamed
|
||||
agent.deleteConversation(conv.id) // DELETE /conversations/{id} -> Deleted
|
||||
```
|
||||
|
||||
Инструментальные вызовы и целый ответ — durable-поток
|
||||
(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и
|
||||
`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`.
|
||||
|
||||
## Прерывание хода
|
||||
|
||||
```kotlin
|
||||
agent.getConversation(convId)!!.interrupt()
|
||||
```
|
||||
|
||||
## Multi-conversation
|
||||
|
||||
Один `Agent`, много `ChatSession`:
|
||||
|
||||
```kotlin
|
||||
val sessions = mutableMapOf<String, ChatSession>()
|
||||
|
||||
fun open(convId: String): ChatSession =
|
||||
sessions.getOrPut(convId) { ChatSession(agent, convId) }
|
||||
|
||||
fun close(convId: String) {
|
||||
sessions.remove(convId)?.close()
|
||||
}
|
||||
```
|
||||
|
||||
Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) +
|
||||
UI-обновление списка — отдельная задача, решается `Flow<CommonEvent.Agent>`.
|
||||
|
||||
## Где `:client` НЕ помогает
|
||||
|
||||
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
|
||||
отдаёт типы и потоки.
|
||||
- **Персистентность кэша** — `InMemoryJournalStore` и
|
||||
`InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой
|
||||
`MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore`
|
||||
в `:journal-ksqlite` как образец).
|
||||
- **Нестандартные движковые настройки** — для `requestTimeout`,
|
||||
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
|
||||
напрямую.
|
||||
|
||||
## Кэш списка бесед
|
||||
|
||||
`agent.conversationStore`, который видит клиент — это **локальный кэш**,
|
||||
а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`)
|
||||
лежит `InMemoryMutableConversationStore`, синхронизированный с сервером:
|
||||
|
||||
1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем
|
||||
в `localStore.upsert(...)`.
|
||||
2. **Live-обновления**: подписка на `outbox.agentEvents(after)`:
|
||||
- `Created(id)` → `remote.get(id)` → `local.upsert(record)`
|
||||
- `Deleted(id)` → `local.delete(id)`
|
||||
- `Renamed(id, title)` → `local.rename(id, title)`
|
||||
- `Touched(id, updatedAt)` → `local.touch(id, updatedAt)`
|
||||
|
||||
UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без
|
||||
HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит
|
||||
`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox,
|
||||
клиент видит их через SSE и применяет к локальной копии.
|
||||
|
||||
**Команды** (создать / переименовать / удалить) идут через `agent`:
|
||||
|
||||
```kotlin
|
||||
// Создать новую беседу:
|
||||
val conv = agent.createConversation(temp = false) // → POST /conversations
|
||||
// → server эмитит Created
|
||||
// → client cache получает Created
|
||||
// → UI увидит её в списке
|
||||
// Переименовать:
|
||||
agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id}
|
||||
// → server эмитит Renamed
|
||||
// → client cache обновляет title
|
||||
// Удалить:
|
||||
agent.deleteConversation(conv.id) // → DELETE /conversations/{id}
|
||||
// → server эмитит Deleted
|
||||
// → client cache удаляет запись
|
||||
```
|
||||
|
||||
`conversationStore` доступен **только для чтения**. Это read-only projection
|
||||
на серверную таблицу `conversation` (id + title + timestamps). Для активной
|
||||
работы (send / interrupt) получай handle через `agent.getConversation(id)`.
|
||||
|
||||
**Никогда не пиши в `conversationStore` напрямую.** Все модификации —
|
||||
командами `agent.createConversation / deleteConversation / renameConversation`.
|
||||
**Никогда не пиши в `conversationStore` напрямую.** Для активной работы
|
||||
(send / interrupt) — handle через `agent.getConversation(id)`.
|
||||
|
||||
### Если хочется своего cache-импла
|
||||
|
||||
`InMemoryMutableConversationStore` подходит для 99% случаев — Map +
|
||||
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
|
||||
после перезапуска) — реализуй свой `MutableConversationStore` поверх
|
||||
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
|
||||
`:journal-ksqlite` как образец.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
|
||||
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
|
||||
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
|
||||
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
|
||||
`InMemoryMutableConversationStore` подходит для большинства случаев. Для диска —
|
||||
свой `MutableConversationStore` (см. `KsqliteMutableConversationStore` в
|
||||
`:journal-ksqlite`). Методы `rename`/`touch` принимают `seq` из общего счётчика:
|
||||
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
|
||||
override suspend fun delete(id: String): Boolean { /* DELETE */ }
|
||||
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
|
||||
@@ -511,15 +464,17 @@ class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
|
||||
./gradlew :client:jvmTest
|
||||
```
|
||||
|
||||
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
|
||||
401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
|
||||
reconnect / exhausted attempts → Failed / close → cancel).
|
||||
Покрывают: JSON-парсинг `DurableEvent`-ов, SSE-стрим, recovery после разрыва,
|
||||
401/404, reconnect-cycle `ReconnectingOutbox` (5 кейсов: успех / обрыв +
|
||||
reconnect / exhausted attempts → Failed / мёртвый курсор → Gap (без ретрая) /
|
||||
close → cancel).
|
||||
|
||||
## Auto-reconnect для живого outbox
|
||||
|
||||
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
|
||||
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
|
||||
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
|
||||
Базовый `OutboxStore.events(after: Cursor?)` — cold SSE-стрим; при обрыве
|
||||
(мобильная сеть, рестарт сервера) клиент должен сам реконнектиться с курсором
|
||||
последнего увиденного события. Это повторяется в каждом клиенте, поэтому
|
||||
`ReconnectingOutbox` берёт это на себя:
|
||||
|
||||
```kotlin
|
||||
val recon = ReconnectingOutbox(
|
||||
@@ -528,7 +483,8 @@ val recon = ReconnectingOutbox(
|
||||
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
|
||||
)
|
||||
|
||||
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
|
||||
// lastSeen — курсор из последнего снапшота / последнего события.
|
||||
scope.launch { recon.events(after = lastSeen).collect { handle(it) } }
|
||||
scope.launch {
|
||||
recon.connectionStatus().collect { status ->
|
||||
when (status) {
|
||||
@@ -536,6 +492,7 @@ scope.launch {
|
||||
is Connected -> ui.hideBanner()
|
||||
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
|
||||
is Failed -> ui.showError(status.cause)
|
||||
is Gap -> resync(status.cause) // курсор мёртв — полный resync
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,6 +501,11 @@ scope.launch {
|
||||
recon.close() // отменяет background-loop, потоки терминируются
|
||||
```
|
||||
|
||||
`Gap` — единственный статус, который **не** ретраится: курсор старше
|
||||
retention'а или чужая эпоха. Обработка — полный resync (см. «Курсорный
|
||||
протокол»). Если не передать `after`, при старте берётся
|
||||
`outbox.currentCursor()` (live-only семантика).
|
||||
|
||||
Два потока **независимы** — `events()` содержит только `CommonEvent`,
|
||||
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
|
||||
"мешающего" `Connecting`/`Disconnected` в потоке событий.
|
||||
|
||||
@@ -18,7 +18,9 @@ import pw.binom.agentik.outbox.OnlineOutbox
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
import pw.binom.agentik.proto.ChatSnapshot
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -81,6 +83,22 @@ internal class AgentClient private constructor(
|
||||
return rec.updatedAt
|
||||
}
|
||||
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot {
|
||||
val response = httpClient.get("$agentUrl/snapshot")
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"snapshot: server returned ${response.status}"
|
||||
}
|
||||
return response.body()
|
||||
}
|
||||
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot {
|
||||
val response = httpClient.get("$agentUrl/conversations/$conversationId/snapshot")
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"conversations/$conversationId/snapshot: server returned ${response.status}"
|
||||
}
|
||||
return response.body()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.engine.HttpClientEngineFactory
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
@@ -14,8 +16,9 @@ import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
||||
@@ -34,8 +37,9 @@ import kotlin.time.Instant
|
||||
* )
|
||||
* val conv = agent.createConversation(temp = false)
|
||||
* conv.send(listOf(Content.Text("hi")))
|
||||
* agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||
* .map { it.event }
|
||||
* // Курсор-протокол: сначала снапшот (state + cursor), потом подписка «после»:
|
||||
* val snap = agent.chatSnapshot(conv.id)
|
||||
* agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
|
||||
* .collect { ... }
|
||||
* agent.close() // закрывает HttpClient + локальный кэш
|
||||
* ```
|
||||
@@ -76,10 +80,12 @@ import kotlin.time.Instant
|
||||
*
|
||||
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
|
||||
* Внутри лежит [InMemoryMutableConversationStore], который:
|
||||
* 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`.
|
||||
* 2. Подписывается на `outbox.agentEvents(after)` → для каждого
|
||||
* 1. На старте берёт `conversationsSnapshot()` (полный список + курсор) и
|
||||
* приводит к нему локальную копию.
|
||||
* 2. Подписывается на `outbox.agentEvents(after = snapshot.cursor)` → для каждого
|
||||
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
|
||||
* соответствующий `upsert/delete/rename/touch` к локальной копии.
|
||||
* 3. При `OutboxGapException` повторяет с шага 1 (полный resync).
|
||||
*
|
||||
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
|
||||
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
|
||||
@@ -103,17 +109,23 @@ fun AgentikAgent(
|
||||
/**
|
||||
* Оборачивает [Agent] так, что [Agent.conversationStore] становится
|
||||
* локальным in-memory кэшем, синхронизированным с удалённым стором
|
||||
* через outbox-события.
|
||||
* по курсор-протоколу.
|
||||
*
|
||||
* - **Seed**: при создании делает один snapshot через
|
||||
* `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore].
|
||||
* - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет
|
||||
* `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу.
|
||||
* **Протокол синхронизации** (гарантирует актуальный список бесед):
|
||||
* 1. `conversationsSnapshot()` — база (полный список) + курсор `C`.
|
||||
* 2. `outbox.agentEvents(after = C)` — дельты, применяются поверх базы
|
||||
* (`Created`/`Deleted`/`Renamed`/`Touched`, все абсолютные и идемпотентные).
|
||||
* 3. [OutboxGapException] (курсор мёртв — retention / смена epoch) → повтор
|
||||
* с шага 1 (полный resync: `reconcile` удаляет локальные беседы, которых
|
||||
* нет в снапшоте, и upsert'ит все из снапшота).
|
||||
* 4. Прочие ошибки (сеть) → пауза и повтор.
|
||||
*
|
||||
* Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
|
||||
* (на read-only projection локального [InMemoryMutableConversationStore]).
|
||||
* Остальные методы [Agent] — delegated в [delegate].
|
||||
*/
|
||||
private val RESYNC_RETRY_DELAY = 2.seconds
|
||||
|
||||
private fun wrapWithLocalConversationCache(
|
||||
delegate: Agent,
|
||||
scopeClient: Agent,
|
||||
@@ -124,38 +136,59 @@ private fun wrapWithLocalConversationCache(
|
||||
private val syncJob: Job
|
||||
|
||||
init {
|
||||
// Делаем cacheStore read-only view на localStore.
|
||||
// (Через вложенный класс — см. ниже.)
|
||||
// Запускаем seed + live-refresh параллельно.
|
||||
syncJob = cacheScope.launch {
|
||||
// 1. seed — snapshot всех текущих бесед с сервера
|
||||
try {
|
||||
delegate.conversationStore.listFlow(offset = 0, pageSize = ConversationStore.PAGE_SIZE)
|
||||
.collect { rec -> localStore.upsert(rec) }
|
||||
} catch (_: Throwable) {
|
||||
// seed может упасть (offline / 5xx) — не критично,
|
||||
// live-источник всё равно догонит при первом событии.
|
||||
}
|
||||
syncJob = cacheScope.launch { syncLoop() }
|
||||
}
|
||||
|
||||
// 2. live — применяем outbox-события.
|
||||
// Используем `first()` для knownId после Created — потом отписываемся,
|
||||
// потому что Created нужно вытянуть полный record через `remote.get(id)`.
|
||||
// Renamed/Touched меняют локальную копию без round-trip.
|
||||
delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce ->
|
||||
when (val ev = ce.event) {
|
||||
is AgentEvent.Created -> {
|
||||
// Created не несёт title/timestamps — нужно сходить в remote.
|
||||
val rec = delegate.conversationStore.get(ev.conversationId)
|
||||
if (rec != null) localStore.upsert(rec)
|
||||
}
|
||||
is AgentEvent.Deleted -> localStore.delete(ev.id)
|
||||
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
|
||||
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
|
||||
}
|
||||
private suspend fun syncLoop() {
|
||||
while (cacheScope.isActive) {
|
||||
try {
|
||||
val snap = delegate.conversationsSnapshot()
|
||||
reconcile(snap.conversations)
|
||||
delegate.outbox.agentEvents(after = snap.cursor).collect { ce -> apply(ce.event) }
|
||||
// Штатное завершение потока (не должно) → переподключаемся.
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: OutboxGapException) {
|
||||
// Курсор мёртв — немедленно новый снапшот.
|
||||
} catch (_: Throwable) {
|
||||
// Сеть/5xx — пауза и повтор (локальный кэш сохраняем).
|
||||
delay(RESYNC_RETRY_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит локальный кэш к снапшоту: чего нет в снапшоте — удаляем,
|
||||
* всё из снапшота — upsert. Делает полный resync корректным (в т.ч.
|
||||
* «пропавшие» беседы = удалённые).
|
||||
*/
|
||||
private suspend fun reconcile(records: List<ConversationRecord>) {
|
||||
val fresh = records.mapTo(HashSet()) { it.id }
|
||||
val stale = ArrayList<String>()
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val page = localStore.list(offset, ConversationStore.PAGE_SIZE)
|
||||
if (page.isEmpty()) break
|
||||
page.forEach { if (it.id !in fresh) stale += it.id }
|
||||
offset += page.size
|
||||
}
|
||||
stale.forEach { localStore.delete(it) }
|
||||
records.forEach { localStore.upsert(it) }
|
||||
}
|
||||
|
||||
private suspend fun apply(ev: AgentEvent) {
|
||||
when (ev) {
|
||||
is AgentEvent.Created -> {
|
||||
// Created не несёт title/timestamps — нужно сходить в remote.
|
||||
val rec = delegate.conversationStore.get(ev.conversationId)
|
||||
if (rec != null) localStore.upsert(rec)
|
||||
}
|
||||
is AgentEvent.Deleted -> localStore.delete(ev.id)
|
||||
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
|
||||
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only projection локального кэша — клиент через него только
|
||||
* читает (`get` / `list` / `listFlow`).
|
||||
|
||||
@@ -1,45 +1,42 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
|
||||
/**
|
||||
* HTTP-реализация [OutboxStore] (= [pw.binom.agentik.outbox.OutboxStore]),
|
||||
* ходящая в `:server`-фасад.
|
||||
* HTTP-реализация [OutboxStore], ходящая в `:server`-фасад.
|
||||
*
|
||||
* **Endpoint-раскладка** (новый дизайн — storage handles на [Agent]):
|
||||
* - [events] → `GET {baseUrl}/outbox/events?after=` (полный поток
|
||||
* [CommonEvent], bounded-tail + live SSE, см. [pw.binom.agentik.server.outboxRoutes])
|
||||
* - [agentEvents] → `GET {baseUrl}/events?after=` (legacy proto-роут:
|
||||
* сервер пробрасывает [pw.binom.agentik.outbox.agentEvents] и распаковывает
|
||||
* `.event` для обратной совместимости с форматом AgentEvent)
|
||||
* - [conversationEvents] с `conversationId != null` → `GET /conversations/{id}/events`
|
||||
* **Endpoint-раскладка**:
|
||||
* - [events] → `GET {baseUrl}/outbox/events?epoch=&offset=` (полный поток
|
||||
* [CommonEvent], bounded-tail + live SSE). Без параметров — live-only.
|
||||
* - [agentEvents] → `GET {baseUrl}/events?epoch=&offset=` (только
|
||||
* `CommonEvent.Agent`).
|
||||
* - [conversationEvents] с `conversationId != null` →
|
||||
* `GET /conversations/{id}/events?epoch=&offset=`; с `null` — fallback на
|
||||
* default [OutboxStore.conversationEvents] (общий `/outbox/events` + filter).
|
||||
* - [currentCursor] / [oldestCursor] → `GET {baseUrl}/outbox/cursor`.
|
||||
*
|
||||
* Для [conversationEvents] с `conversationId == null` (события всех диалогов)
|
||||
* fallback на default [OutboxStore.conversationEvents] — общий поток
|
||||
* `/outbox/events` + filter. Это редкий кейс (admin-дашборды), и
|
||||
* оптимизировать его отдельно нерационально.
|
||||
* **Gap** (`410 Gone`): сервер отвечает `410` с [GapResponse] (oldest/current
|
||||
* курсоры) — клиент конвертирует в [OutboxGapException]. Это сигнал сделать
|
||||
* resync: `agent.conversationsSnapshot()` / `agent.chatSnapshot(id)`.
|
||||
*
|
||||
* [earliestEventDate] не имеет своего endpoint'а; возвращает `Clock.System.now()`
|
||||
* (см. KDoc [OutboxStore.earliestEventDate] — для пустого буфера это и есть
|
||||
* контрактное значение). Клиент, который полагался на gap detection через
|
||||
* message store, продолжит работать — просто fallback никогда не сработает.
|
||||
*
|
||||
* **Импорты [CommonEvent]/[AgentEvent]/[Event] идут напрямую из
|
||||
* `pw.binom.agentik.outbox`** — typealias'ы в `:proto.CommonEvent` и т.п.
|
||||
* НЕ поддерживают nested-class access (`CommonEvent.Agent` через alias
|
||||
* даёт "Unresolved qualified name"), поэтому приходится использовать
|
||||
* конкретный пакет. Типы идентичны, alias только для удобства внешнего API.
|
||||
* **Импорты [CommonEvent]/[Cursor] идут напрямую из `pw.binom.agentik.outbox`** —
|
||||
* typealias'ы в `:proto` не поддерживают nested-class access.
|
||||
*/
|
||||
internal class HttpEventStore(
|
||||
private val httpClient: HttpClient,
|
||||
@@ -48,85 +45,80 @@ internal class HttpEventStore(
|
||||
|
||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||
val url = buildString {
|
||||
append("$agentUrl/outbox/events")
|
||||
if (after != null) append("?after=$after")
|
||||
}
|
||||
httpClient.prepareGet(url) { noReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(CommonEvent.serializer(), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun events(after: Cursor?): Flow<CommonEvent> =
|
||||
sse("$agentUrl/outbox/events", after, CommonEvent.serializer())
|
||||
|
||||
/**
|
||||
* Override: идём в `/events` напрямую — сервер фильтрует только lifecycle-события.
|
||||
* Default из [EventStore.agentEvents] читал бы `/events/all` + `filterIsInstance`.
|
||||
*/
|
||||
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = flow {
|
||||
val url = buildString {
|
||||
append("$agentUrl/events")
|
||||
if (after != null) append("?after=$after")
|
||||
}
|
||||
httpClient.prepareGet(url) { noReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"agentEvents: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
val event = agentikJson.decodeFromString(AgentEvent.serializer(), payload)
|
||||
emit(CommonEvent.Agent(date = event.date, event = event))
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
|
||||
sse("$agentUrl/events", after, CommonEvent.Agent.serializer())
|
||||
|
||||
/**
|
||||
* Override с `conversationId != null` — идём в `/conversations/{id}/events`.
|
||||
* С `null` (события всех диалогов) — fallback на default impl из [EventStore]:
|
||||
* общий `/events/all` + filter.
|
||||
*/
|
||||
override fun conversationEvents(
|
||||
after: Instant?,
|
||||
after: Cursor?,
|
||||
conversationId: String?,
|
||||
): Flow<CommonEvent.Conversation> {
|
||||
if (conversationId == null) {
|
||||
return super.conversationEvents(after, null)
|
||||
if (conversationId == null) return super.conversationEvents(after, null)
|
||||
return sse(
|
||||
"$agentUrl/conversations/$conversationId/events",
|
||||
after,
|
||||
CommonEvent.Conversation.serializer(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun currentCursor(): Cursor = cursorResponse().current
|
||||
|
||||
override suspend fun oldestCursor(): Cursor = cursorResponse().oldest
|
||||
|
||||
private suspend fun cursorResponse(): CursorResponse {
|
||||
val response = httpClient.get("$agentUrl/outbox/cursor")
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"outbox.cursor: server returned ${response.status}"
|
||||
}
|
||||
return flow {
|
||||
val url = buildString {
|
||||
append("$agentUrl/conversations/$conversationId/events")
|
||||
if (after != null) append("?after=$after")
|
||||
return response.body()
|
||||
}
|
||||
|
||||
private fun <T> sse(url: String, after: Cursor?, serializer: KSerializer<T>): Flow<T> = flow {
|
||||
httpClient.prepareGet(url) {
|
||||
noReadTimeout()
|
||||
if (after != null) {
|
||||
parameter("epoch", after.epoch)
|
||||
parameter("offset", after.offset)
|
||||
}
|
||||
httpClient.prepareGet(url) { noReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"conversationEvents: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
val event = agentikJson.decodeFromString(Event.serializer(), payload)
|
||||
emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
|
||||
}
|
||||
}.execute { response ->
|
||||
if (response.status == HttpStatusCode.Gone) {
|
||||
throw response.toGapException(after)
|
||||
}
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"$url: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(serializer, payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* У HTTP-варианта нет своего endpoint'а для earliest-event-date.
|
||||
* Контракт [EventStore.earliestEventDate] для пустого буфера говорит
|
||||
* "сейчас" — для HTTP-клиента буфер на нашей стороне всегда "пуст"
|
||||
* (мы не держим своё состояние), поэтому возвращаем `Clock.System.now()`.
|
||||
*/
|
||||
override suspend fun earliestEventDate(): Instant = Clock.System.now()
|
||||
|
||||
override fun close() {
|
||||
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||
}
|
||||
}
|
||||
|
||||
/** Тело `GET {baseUrl}/outbox/cursor`. */
|
||||
@Serializable
|
||||
internal data class CursorResponse(val current: Cursor, val oldest: Cursor)
|
||||
|
||||
/** Тело `410 Gone` (см. [pw.binom.agentik.server.OutboxGapResponse]). */
|
||||
@Serializable
|
||||
internal data class GapResponse(
|
||||
val requested: Cursor? = null,
|
||||
val oldest: Cursor,
|
||||
val current: Cursor,
|
||||
)
|
||||
|
||||
private suspend fun HttpResponse.toGapException(requested: Cursor?): OutboxGapException {
|
||||
val dto = runCatching { agentikJson.decodeFromString(GapResponse.serializer(), bodyAsText()) }.getOrNull()
|
||||
val fallback = requested ?: Cursor(epoch = "", offset = -1L)
|
||||
return OutboxGapException(
|
||||
requested = requested,
|
||||
oldest = dto?.oldest ?: fallback,
|
||||
current = dto?.current ?: fallback,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,23 @@ internal class HttpJournalStore(
|
||||
return response.body<List<MessageRecord>>()
|
||||
}
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
afterSeq: Long,
|
||||
upToSeq: Long,
|
||||
limit: Int,
|
||||
): List<MessageRecord> {
|
||||
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/messages") {
|
||||
parameter("afterSeq", afterSeq)
|
||||
parameter("upToSeq", upToSeq)
|
||||
parameter("limit", limit)
|
||||
}
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"journal.list(seq): server returned ${response.status}"
|
||||
}
|
||||
return response.body<List<MessageRecord>>()
|
||||
}
|
||||
|
||||
override suspend fun count(conversationId: String): Long {
|
||||
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count")
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
@@ -76,6 +93,16 @@ internal class HttpJournalStore(
|
||||
return response.body<CountResponse>().count
|
||||
}
|
||||
|
||||
override suspend fun count(conversationId: String, afterSeq: Long): Long {
|
||||
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count") {
|
||||
parameter("afterSeq", afterSeq)
|
||||
}
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"journal.count(afterSeq): server returned ${response.status}"
|
||||
}
|
||||
return response.body<CountResponse>().count
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
@@ -60,6 +62,17 @@ sealed interface ConnectionStatus {
|
||||
* outbox и т.п.
|
||||
*/
|
||||
data class Failed(val cause: Throwable) : ConnectionStatus
|
||||
|
||||
/**
|
||||
* Курсор мёртв ([pw.binom.agentik.outbox.OutboxGapException]): клиент был
|
||||
* оффлайн дольше retention'а outbox'а или эпоха сменилась. **Не** retry'ится
|
||||
* (ретрай никогда не пройдёт). Создатель обязан сделать полный resync:
|
||||
* взять снапшот (`Agent.conversationsSnapshot()` / `Agent.chatSnapshot(id)`),
|
||||
* применить его и создать новый [ReconnectingOutbox] с курсором снапшота.
|
||||
*
|
||||
* Поток [events] закрывается после этого, background-loop останавливается.
|
||||
*/
|
||||
data class Gap(val cause: OutboxGapException) : ConnectionStatus
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,8 +129,10 @@ data class BackoffPolicy(
|
||||
* ```
|
||||
* val outbox = ReconnectingOutbox(httpEventStore, scope)
|
||||
*
|
||||
* // Курсор берётся из снапшота: state + cursor, затем подписка «после него».
|
||||
* val snap = agent.chatSnapshot(conversationId)
|
||||
* scope.launch {
|
||||
* outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) }
|
||||
* outbox.events(after = snap.cursor).collect { e -> handle(e) }
|
||||
* }
|
||||
* scope.launch {
|
||||
* outbox.connectionStatus().collect { s -> ui.showStatus(s) }
|
||||
@@ -154,19 +169,27 @@ class ReconnectingOutbox(
|
||||
private var job: Job? = null
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
private val lastSeen: AtomicReference<Instant?> = AtomicReference(null)
|
||||
private val lastSeen: AtomicReference<Cursor?> = AtomicReference(null)
|
||||
|
||||
/**
|
||||
* Live-события из [outbox] с авто-reconnect. [after] — начальный курсор;
|
||||
* учитывается только при первом вызове (любом из [events] /
|
||||
* [connectionStatus]). После reconnect курсор берётся из `date`
|
||||
* последнего виденного события.
|
||||
* [connectionStatus]). После reconnect курсор берётся из `offset`
|
||||
* последнего виденного события (та же `epoch`, что и у подписки).
|
||||
*
|
||||
* Если [after] == null, при старте background-loop берётся
|
||||
* [OutboxStore.currentCursor] — это live-only семантика (событий строго
|
||||
* после текущего) плюс известная `epoch` для будущих reconnect.
|
||||
*
|
||||
* При мёртвом курсоре (retention / смена epoch) loop **не** ретраит, а
|
||||
* эмитит [ConnectionStatus.Gap] и останавливается — клиент обязан сделать
|
||||
* resync (снапшот + новый [ReconnectingOutbox] с курсором снапшота).
|
||||
*
|
||||
* Коллекторы независимы — каждый получает свою копию потока (shared).
|
||||
* Медленный коллектор может пропускать события при переполнении буфера
|
||||
* (`DROP_OLDEST`).
|
||||
*/
|
||||
fun events(after: Instant? = null): Flow<CommonEvent> {
|
||||
fun events(after: Cursor? = null): Flow<CommonEvent> {
|
||||
ensureStarted(after)
|
||||
return _events
|
||||
}
|
||||
@@ -183,7 +206,7 @@ class ReconnectingOutbox(
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
private fun ensureStarted(initialCursor: Instant?) {
|
||||
private fun ensureStarted(initialCursor: Cursor?) {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
lastSeen.store(initialCursor)
|
||||
job = scope.launch { runLoop() }
|
||||
@@ -196,9 +219,13 @@ class ReconnectingOutbox(
|
||||
while (currentCoroutineContext().isActive) {
|
||||
attempt++
|
||||
_status.emit(ConnectionStatus.Connecting(attempt))
|
||||
// Курсор подписки: сохранённый lastSeen, либо (при live-only)
|
||||
// currentCursor() — чтобы знать epoch и не терять позицию.
|
||||
val cursor: Cursor? = lastSeen.load() ?: runCatching { outbox.currentCursor() }.getOrNull()
|
||||
var gap: OutboxGapException? = null
|
||||
val error: Throwable? = try {
|
||||
outbox.events(after = lastSeen.load()).collect { event ->
|
||||
lastSeen.store(event.date)
|
||||
outbox.events(after = cursor).collect { event ->
|
||||
lastSeen.store(Cursor(epoch = cursor?.epoch ?: "", offset = event.offset))
|
||||
_events.emit(event)
|
||||
if (!connected) {
|
||||
connected = true
|
||||
@@ -208,10 +235,18 @@ class ReconnectingOutbox(
|
||||
null
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: OutboxGapException) {
|
||||
gap = t
|
||||
null
|
||||
} catch (t: Throwable) {
|
||||
t
|
||||
}
|
||||
connected = false
|
||||
if (gap != null) {
|
||||
// Ретраить бессмысленно: курсор мёртв. Отдаём сигнал наружу.
|
||||
_status.emit(ConnectionStatus.Gap(gap))
|
||||
return
|
||||
}
|
||||
if (attempt >= policy.maxAttempts) {
|
||||
_status.emit(
|
||||
ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally"))
|
||||
|
||||
@@ -11,10 +11,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
@@ -41,7 +42,7 @@ internal class FakeOutbox : OutboxStore {
|
||||
|
||||
private val channel = Channel<Msg>(Channel.UNLIMITED)
|
||||
|
||||
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||
override fun events(after: Cursor?): Flow<CommonEvent> = flow {
|
||||
for (msg in channel) {
|
||||
when (msg) {
|
||||
is Msg.Err -> throw msg.throwable
|
||||
@@ -53,20 +54,24 @@ internal class FakeOutbox : OutboxStore {
|
||||
fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) }
|
||||
fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) }
|
||||
|
||||
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = emptyFlow()
|
||||
override fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> = emptyFlow()
|
||||
override fun conversationEvents(
|
||||
after: Instant?,
|
||||
after: Cursor?,
|
||||
conversationId: String?,
|
||||
): Flow<CommonEvent.Conversation> = emptyFlow()
|
||||
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||
override suspend fun currentCursor(): Cursor = Cursor(epoch = "test", offset = -1L)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(epoch = "test", offset = -1L)
|
||||
override fun close() { channel.close() }
|
||||
}
|
||||
|
||||
private const val TEST_EPOCH = "test"
|
||||
|
||||
private fun testEvent(dateMs: Long): CommonEvent =
|
||||
CommonEvent.Conversation(
|
||||
date = Instant.fromEpochMilliseconds(dateMs),
|
||||
offset = dateMs,
|
||||
conversationId = "test",
|
||||
event = Event.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||
event = DurableEvent.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -141,6 +146,32 @@ class ReconnectingOutboxTest {
|
||||
assertEquals(0, ctx.eventsLog.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gap is not retried and emits Gap status`() = runConnectionTest(attempts = 5) { ctx ->
|
||||
val fake = ctx.fake
|
||||
val gap = OutboxGapException(
|
||||
requested = Cursor("test", -1L),
|
||||
oldest = Cursor("test", 10L),
|
||||
current = Cursor("test", 20L),
|
||||
)
|
||||
fake.throwAtNextEvent(gap)
|
||||
ctx.advanceAndDrain(50)
|
||||
|
||||
val gaps = ctx.statusLog.filterIsInstance<ConnectionStatus.Gap>()
|
||||
assertEquals(1, gaps.size, "status=${ctx.statusLog}")
|
||||
assertEquals(gap, gaps[0].cause)
|
||||
// Ретрая быть не должно: курсор мёртв, следующая попытка ничего не изменит.
|
||||
assertTrue(ctx.statusLog.none { it is ConnectionStatus.Disconnected }, "status=${ctx.statusLog}")
|
||||
assertTrue(
|
||||
ctx.statusLog.none { it is ConnectionStatus.Connecting && it.attempt == 2 },
|
||||
"status=${ctx.statusLog}",
|
||||
)
|
||||
// Поток событий закрыт — новые эмиссии не доходят.
|
||||
fake.push(testEvent(2000))
|
||||
ctx.advanceAndDrain(50)
|
||||
assertEquals(0, ctx.eventsLog.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close cancels background loop`() = runConnectionTest(
|
||||
attempts = 5,
|
||||
|
||||
Reference in New Issue
Block a user