Добавляет 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:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
+216 -254
View File
@@ -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,