Добавляет 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
@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
*
* Useful for admin dashboards, debug tools, parent agents: one subscription
* instead of N+1. For regular UI use two separate SSE feeds
* ([AgentEvent] via `/events` и [Event] via `/conversations/{id}/events`);
* ([AgentEvent] via `/events` и [DurableEvent] via `/conversations/{id}/events`);
* [CommonEvent] — for those who need everything in one place.
*
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
@@ -23,12 +23,23 @@ import kotlinx.serialization.Serializable
*/
@Serializable
sealed interface CommonEvent {
/** Момент эмиссии в UTC. Только для отображения/сортировки — **не** курсор. */
val date: Instant
/**
* Монотонный per-agent offset события — **курсор** (см. [Cursor]).
*
* Присваивается writer'ом через [OffsetSequencer.reserve] в тот же момент,
* что и `seq` соответствующей строки состояния (сначала строка, потом
* событие). Клиенты оперируют [Cursor], а не [date].
*/
val offset: Long
@Serializable
@SerialName("agent")
data class Agent(
override val date: Instant,
override val offset: Long,
val event: AgentEvent,
) : CommonEvent
@@ -36,7 +47,8 @@ sealed interface CommonEvent {
@SerialName("conversation")
data class Conversation(
override val date: Instant,
override val offset: Long,
val conversationId: String,
val event: Event,
val event: DurableEvent,
) : CommonEvent
}
@@ -0,0 +1,51 @@
package pw.binom.agentik.outbox
import kotlin.random.Random
import kotlinx.serialization.Serializable
/**
* Курсор события — **позиция в общем монотонном потоке событий агента**.
*
* Состоит из двух частей:
* - [offset] — per-agent монотонный номер события (0-based). Именно он, а не
* wall-clock [CommonEvent.date], является курсором: несколько событий могут
* иметь одинаковый [date] (например `UserMessage` и `ToolCall` в одну
* миллисекунду), но offset'ы всегда строго возрастают и уникальны. Фильтрация
* `offset > after.offset` не теряет события на «ничьих» по времени.
* - [epoch] — идентификатор «мира» счётчика. Меняется при сбросе/восстановлении
* БД, из-за которого offset'ы теряют монотонность. Клиент хранит epoch в своём
* курсоре; несовпадение epoch → сервер сигналит gap ([OutboxGapException]) →
* клиент делает полный resync. Обычный **рестарт** сервера epoch НЕ меняет
* (счётчик персистентный), поэтому клиент продолжает инкрементально.
*
* **Семантика подписки**: [Cursor.offset] — **эксклюзивная** граница.
* `events(after = cursor)` отдаёт события со строго большим offset. Практически
* клиент кладёт сюда offset последнего применённого события, либо [OutboxStore.currentCursor]
* из снапшота.
*
* **Почему не `Instant` и не «id ASC»**: `Instant` лоссов при совпадении millis,
* а `id` — случайный UUID, который не задаёт порядок записи.
*
* **Переполнение счётчика**: `Long` на агента неисчерпаем (≈4.6·10¹⁷ ходов при
* 20 событиях/ход — это ~1.5·10⁷ лет при 1000 ходов/с). Заворачивать его нельзя
* (сломает монотонность), поэтому при любом сбое, инвалидирующем счётчик
* (сброс/восстановление БД), **ротируется [epoch]** и все клиенты делают
* полный resync — это и есть «обработка переполнения», а не wrap.
*/
@Serializable
data class Cursor(
val epoch: String,
val offset: Long,
) {
override fun toString(): String = "$epoch:$offset"
companion object {
/**
* Новый случайный [epoch] (opaque-строка). Новый epoch = «новый мир»
* счётчика: используется при первичной инициализации персистентного
* счётчика и при инвалидации offset-пространства — клиенты с прежним
* курсором получат [OutboxGapException] и сделают полный resync.
*/
fun newEpoch(): String = Random.nextLong().toString(16).padStart(16, '0')
}
}
@@ -0,0 +1,25 @@
package pw.binom.agentik.outbox
/**
* Персистентное хранилище позиции счётчика — [Cursor] (`epoch` + `offset`).
*
* Единственный мост между [OffsetSequencer] (чистая логика монотонного
* счётчика) и durable-носителем (`outbox-ksqlite`). Секвенсор читает позицию
* один раз при создании и держит `epoch`/`offset` в памяти (см.
* [OffsetSequencer] KDoc), поэтому [load] синхронный; [save] — durable
* запись, вызывается на каждом [OffsetSequencer.reserve].
*
* Вызовы сериализованы самим [PersistentOffsetSequencer] (его `Mutex`),
* так что реализация может не иметь собственной синхронизации.
*/
interface CursorStore {
/**
* Текущая позиция счётчика, или `null` если он ещё не инициализирован
* (пустая БД / первый запуск). Для пустого хранилища [PersistentOffsetSequencer]
* сгенерирует новый `epoch` и стартовый offset.
*/
fun load(): Cursor?
/** Записать позицию durable. */
fun save(cursor: Cursor)
}
@@ -30,7 +30,7 @@ import kotlin.time.Instant
* `pw.binom.agentik.outbox.Event`.
*/
@Serializable
sealed interface Event {
sealed interface DurableEvent {
/** Момент эмиссии события в UTC. */
val date: Instant
@@ -46,7 +46,7 @@ sealed interface Event {
val id: String,
val content: List<Content>,
val context: MessageContext? = null,
) : Event
) : DurableEvent
/**
* Целое сообщение ассистента — итог хода. Эмитится при завершении хода,
@@ -63,7 +63,7 @@ sealed interface Event {
val content: List<Content>,
val reasoning: String? = null,
val tokens: TurnTokens? = null,
) : Event
) : DurableEvent
/**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
@@ -78,7 +78,7 @@ sealed interface Event {
val title: String?,
val toolName: String,
val toolArgs: String,
) : Event
) : DurableEvent
/**
* Результат вызова тула. Приходит целиком после завершения исполнения.
@@ -101,7 +101,7 @@ sealed interface Event {
val toolCallId: String,
val toolName: String? = null,
val result: String?,
) : Event
) : DurableEvent
/**
* Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется
@@ -109,7 +109,7 @@ sealed interface Event {
*/
@Serializable
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
data class Interrupted(override val date: Instant) : DurableEvent
/**
* Ошибка хода. После неё поток завершается; дальнейшие события могут
@@ -117,7 +117,7 @@ sealed interface Event {
*/
@Serializable
@SerialName("error")
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
data class Error(override val date: Instant, val message: String, val code: String? = null) : DurableEvent
/**
* Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
@@ -136,7 +136,7 @@ sealed interface Event {
val toolName: String?,
val message: String,
val durationMs: Long,
) : Event
) : DurableEvent
/**
* Диалог переходит в закрытое состояние ([Conversation.close] /
@@ -148,7 +148,7 @@ sealed interface Event {
*
* Парный `Opening` намеренно отсутствует — симметрия не нужна,
* так как открытие тривиально (id уже известен с момента
* `Agent.createConversation` → [Event.ConversationCreated]
* `Agent.createConversation` → [DurableEvent.ConversationCreated]
* / [AgentEvent.Created] в outbox'е).
*/
@Serializable
@@ -156,7 +156,7 @@ sealed interface Event {
data class ConversationClosing(
override val date: Instant,
val conversationId: String,
) : Event
) : DurableEvent
/**
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
@@ -173,5 +173,5 @@ sealed interface Event {
override val date: Instant,
val conversationId: String,
val turnsCompacted: Int,
) : Event
) : DurableEvent
}
@@ -10,17 +10,21 @@ package pw.binom.agentik.outbox
* персистятся, I/O нет — блокировать продюсера незачем. [tryAppendOnline]
* не буферизует и не ждёт (см. [OnlineOutbox]): медленный подписчик может
* потерять дельту, это допустимо.
*
* Диалог берётся из самого события ([OnlineEvent.conversationId]) — отдельного
* параметра нет, чтобы не было двух источников истины.
*/
interface MutableOnlineOutbox : OnlineOutbox {
suspend fun appendOnline(conversationId: String, event: OnlineEvent)
suspend fun appendOnline(event: OnlineEvent)
/**
* Эмитит [event] в live-канал диалога [conversationId]. Не сохраняется.
* Эмитит [event] в live-канал его диалога ([OnlineEvent.conversationId]).
* Не сохраняется.
*
* Возвращает `true`, если событие принято live-каналом. Возврат `false`
* (нет активных подписчиков / буфер переполнен с DROP-политикой) —
* не ошибка: у онлайн-событий нет гарантии доставки.
*/
fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean
fun tryAppendOnline(event: OnlineEvent): Boolean
}
@@ -1,50 +1,40 @@
package pw.binom.agentik.outbox
/**
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
* Mutable вариант [OutboxStore] — добавляет producer-операции [reserveOffset]
* и [append].
*
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
* sub-agents, A2A-bridge). Consumer'ы (server SSE endpoints, admin
* dashboards, parent agents) должны принимать **read-only** [OutboxStore]
* — тогда невозможно случайно писать в store из observer'а.
* Предназначен **только для producer'ов** (ChatAgent, ConversationLoop,
* ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only
* [OutboxStore] — тогда невозможно случайно писать в store из observer'а.
*
* Типичное использование:
* ## Контракт записи (порядок важен)
* ```
* // Producer
* class ChatAgent(private val events: MutableEventStore) {
* suspend fun doSomething() {
* events.append(CommonEvent.Agent(date = now, event = AgentEvent.Created(...)))
* }
* }
*
* // Consumer
* class EventStreamEndpoint(private val events: EventStore) {
* fun stream() = events.events(after = null)
* // Ошибка компиляции если раскомментировать:
* // events.append(...) // ← нельзя, MutableEventStore нет в типе
* }
* val n = outbox.reserveOffset() // 1. забронировать offset
* journal.append(record.copy(seq = n)) // 2. сначала состояние
* outbox.append(event.copy(offset = n)) // 3. потом событие
* ```
* «Сначала состояние, потом событие» — инвариант, на котором держится
* [OutboxStore.currentCursor]: к моменту, когда событие `n` появилось в
* outbox, строка состояния со `seq = n` уже записана.
*
* **Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
* поэтому retry с тем же logical event (например, после network failure
* между producer и store) приведёт к дубликату в tail'е. Это OK для
* use case'a bounded-tail — клиент, делающий catchup через [events](after),
* получит свой диапазон ровно один раз при подключении, а последующие
* retry producer'а просто насытят tail повторами, не задевая уже
* обработанные. Для гарантированной exactly-once — dedup через
* [message-store] (там есть монотонный `id`).
*
* **Silently evicted**: implementation может выкинуть этот event сразу
* после append (TTL/cap) без уведомления producer'а. Producer **не
* должен** полагаться на то, что event дойдёт до клиента, если он
* вне retention window.
* Offset **обязан** быть выставлен в [CommonEvent.offset]; store проверяет
* строгую монотонность и бросает [IllegalArgumentException] на нарушение.
*/
interface MutableOutboxStore : OutboxStore {
/**
* Положить event в log.
* Забронировать следующий монотонный offset (делегирует в
* [OffsetSequencer.reserve]). Вызывается **до** записи состояния.
*/
suspend fun reserveOffset(): Long
/**
* Положить событие в лог. [CommonEvent.offset] должен быть уже выставлен
* (обычно значением из [reserveOffset]).
*
* - **Не идемпотентно** — см. KDoc интерфейса.
* - **Suspend** для KMP I/O impl'ов (SQLite через JNI).
* **Не идемпотентно** — повторный append с тем же offset'ом нарушает
* монотонность и бросит исключение (защита от двойной записи).
*/
suspend fun append(event: CommonEvent)
}
@@ -0,0 +1,39 @@
package pw.binom.agentik.outbox
/**
* Источник монотонных offset'ов **для одного агента**.
*
* Один счётчик на агента, сквозной по всем сущностям (conversation + message +
* lifecycle): это даёт единый [Cursor] на всё — глобальный курсор (чат
* появился/умер/переименован) и per-chat курсор суть просто закладки в одном
* потоке offset'ов (как offset одного Kafka-topic'а с ключом `conversationId`).
*
* **Кто владеет счётчиком**: writer-сторона. В `standalone` это персистентный
* счётчик в той же SQLite-БД, что и журнал, — иначе рестарт сервера сбросил бы
* offset'ы, а у клиента в локальной БД остался бы старый курсор. Персистентность
* даёт дешёвый инкрементальный resume после рестарта; полная инвалидация
* (сброс/восстановление БД) закрывается ротацией [epoch].
*
* **Порядок записи (инвариант)**: сначала пишется строка состояния с
* `seq = reserve()`, потом событие с `offset = <тот же>`. Тогда «состояние
* с offset ≤ C» гарантированно уже записано в момент чтения снапшота с
* курсором C, и всё, что `> C`, придёт потоком.
*
* **`epoch()`/`current()` — не-`suspend`**: persistent-реализация читает
* `(epoch, counter)` один раз при создании (в конструкторе, где и так идёт
* синхронный I/O открытия БД) и держит в памяти; на диск пишет только
* [reserve]. Это позволяет читать курсор из любого места без корутины.
*/
interface OffsetSequencer {
/**
* Идентификатор текущей эпохи счётчика (см. [Cursor.epoch]). Стабилен между
* рестартами, пока счётчик персистентный.
*/
fun epoch(): String
/** Следующий offset, который будет выдан [reserve] (next offset to assign). */
fun current(): Long
/** Забронировать следующий offset; монотонно возрастает на 1 (durable). */
suspend fun reserve(): Long
}
@@ -8,19 +8,19 @@ import kotlin.time.Instant
* **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и
* стриминг ответа агента (дельты текста/картинок).
*
* Принципиальное отличие от [Event] (durable):
* Принципиальное отличие от [DurableEvent] (durable):
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
* ни в journal. Это чистый live-канал.
* - **Только онлайн-подписка**: события, эмитнутые до подписки
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
* Потерянный фрагмент не страшен — целый результат хода приходит
* durable-событием ([Event.AssistantMessage]) и/или лежит в журнале.
* durable-событием ([DurableEvent.AssistantMessage]) и/или лежит в журнале.
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
* есть что реплеить.
*
* Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор,
* который, попав в durable store, копится в RAM (standalone-outbox растёт
* unbounded) и засоряет историю. В [Event] остаются только «целые» события,
* unbounded) и засоряет историю. В [DurableEvent] остаются только «целые» события,
* пригодные к перезапросу по курсору.
*/
@Serializable
@@ -28,6 +28,14 @@ sealed interface OnlineEvent {
/** Момент эмиссии события в UTC (для упорядочивания в рамках стрима). */
val date: Instant
/**
* Id диалога, которому принадлежит событие. Делает событие
* самодостаточным: общий live-поток всех диалогов (`GET /online`)
* разбирается на стороне клиента без внешнего конверта — какое
* событие к какому чату, видно прямо из payload'а.
*/
val conversationId: String
@Serializable
enum class ResponseType {
@SerialName("text") TEXT,
@@ -36,35 +44,48 @@ sealed interface OnlineEvent {
/**
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
* [End]/[Event.Interrupted]/[Event.Error], синхронно из `Conversation.send()`,
* [End]/[DurableEvent.Interrupted]/[DurableEvent.Error], синхронно из `Conversation.send()`,
* чтобы UI мог показать спиннер ещё до первого токена ответа.
*/
@Serializable
@SerialName("working")
data class Working(override val date: Instant) : OnlineEvent
data class Working(override val date: Instant, override val conversationId: String) : OnlineEvent
/** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [Event]. */
/** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [DurableEvent]. */
@Serializable
@SerialName("end")
data class End(override val date: Instant) : OnlineEvent
data class End(override val date: Instant, override val conversationId: String) : OnlineEvent
/** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */
@Serializable
@SerialName("start_reasoning")
data class StartReasoning(override val date: Instant) : OnlineEvent
data class StartReasoning(override val date: Instant, override val conversationId: String) : OnlineEvent
/** Начало ответа ассистента заданного типа. Далее идут соответствующие `Append*`. */
@Serializable
@SerialName("start_response")
data class StartResponse(override val date: Instant, val responseType: ResponseType) : OnlineEvent
data class StartResponse(
override val date: Instant,
override val conversationId: String,
val responseType: ResponseType,
) : OnlineEvent
/** Очередная дельта текста ответа. */
@Serializable
@SerialName("append_text")
data class AppendText(override val date: Instant, val body: String) : OnlineEvent
data class AppendText(
override val date: Instant,
override val conversationId: String,
val body: String,
) : OnlineEvent
/** Очередная дельта картинки ответа. */
@Serializable
@SerialName("append_image")
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : OnlineEvent
data class AppendImage(
override val date: Instant,
override val conversationId: String,
val body: ByteArray,
val mime: String,
) : OnlineEvent
}
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
*
* Это осознанный компромисс: дельты токенов — высокочастотный мусор,
* который в durable-сторе копился бы в RAM и засорял историю. Потеря
* фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage]
* фрагмента при обрыве не критична — целый ответ приходит [DurableEvent.AssistantMessage]
* и/или лежит в [pw.binom.agentik.journal.JournalStore].
*
* Read-only view: запись — через [MutableOnlineOutbox].
@@ -0,0 +1,34 @@
package pw.binom.agentik.outbox
/**
* Курсор клиента вышел за пределы retention'а outbox'а, **или** принадлежит
* другой [Cursor.epoch].
*
* Это **не ошибка выполнения**, а сигнал протокола: «твой курсор мёртв — я не
* могу отдать непрерывный поток событий, начиная с него». Клиент обязан:
* 1. очистить/пометить свой локальный кэш как устаревший;
* 2. запросить у сервера свежий **snapshot состояния** (он вернёт и состояние,
* и актуальный [Cursor]);
* 3. подписаться на события `after = <cursor из снапшота>` и накатить snapshot,
* затем буферизованные дельты.
*
* Бросается **изнутри** [OutboxStore.events] / [OutboxStore.conversationEvents] /
* [OutboxStore.agentEvents] (то есть из Flow, а не отдельной pre-check'ом) — так
* проверка делается под тем же lock'ом, что и регистрация подписчика, и не
* гоняется с конкурентной эвикцией.
*
* **Retry-политики НЕ должна этому исключению ретраить** (см.
* `ReconnectingOutbox`): повторный connect с тем же курсором даст тот же gap и
* превратится в бесконечный цикл. Обработка — resync, не backoff.
*/
class OutboxGapException(
/** Курсор, с которого клиент просил поток (может быть `null` для live-only). */
val requested: Cursor?,
/** Актуальный курсор сервера (`currentCursor()`). */
val current: Cursor,
/** Минимальный курсор, с которого ещё можно продолжить поток (`oldestCursor()`). */
val oldest: Cursor,
) : RuntimeException(
"Outbox cursor is out of retention: requested=$requested, " +
"oldest=$oldest, current=$current. Re-snapshot the full state."
)
@@ -3,92 +3,84 @@ package pw.binom.agentik.outbox
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlin.time.Instant
/**
* Bounded-tail event log с автоматическим управлением TTL.
* Bounded-tail лог **durable**-событий агента.
*
* Хранит **только durable-события [Event]** — «целые» факты хода
* (Working/End/Interrupted/Error, ToolCall/ToolResult/ToolFailed).
* Высокочастотный **стриминг ответа** (дельты текста/картинок) сюда
* НЕ попадает — он живёт в [OnlineOutbox] (live-only, не сохраняется).
* Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle
* диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only, не
* сохраняется и не реплеится).
*
* **Архитектура двухуровневого хранилища событий**:
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
* События автоматически эвиктятся по TTL/cap (implementation-defined).
* 2. **Message store (`:message-store-api`)** = полный audit log, никогда не
* эвиктится. Source of truth для всего прошлого.
* ## Два уровня хранения
* 1. **Этот store** — короткий bounded tail (live SSE + недавний replay),
* эвиктится по TTL/cap (implementation-defined).
* 2. **Журнал (`:journal-api`)** — полный audit log, никогда не эвиктится.
* Source of truth для всего прошлого. Он и есть «полное состояние»,
* которое запрашивает клиент при resync'е.
*
* **Паттерн reconnect** (caller'ы):
* ## Курсор, а не дата
* Позиция в потоке — монотонный [Cursor] `(epoch, offset)`, а не wall-clock
* [CommonEvent.date]. Offset уникален и упорядочен даже когда два события
* делят одну миллисекунду. `Instant` для этого не годится (лоссов на ничьих),
* случайный `id` — тоже (не задаёт порядок записи).
*
* ## Протокол клиента (гарантия «в итоге корректное состояние»)
* ```
* val earliest = store.earliestEventDate()
* if (client.lastSeen < earliest) {
* // gap обнаружен — идём в message store за прошлым
* val gap = messageStore.query(after = client.lastSeen, before = earliest)
* applyAll(gap)
* // 1. Пробуем продолжить с сохранённого курсора.
* try {
* outbox.conversationEvents(after = saved, conversationId = id).collect { apply(it) }
* } catch (e: OutboxGapException) {
* // 2. Курсор мёртв — берём свежий снапшот (состояние + его курсор).
* val snap = agent.chatSnapshot(id) // { state, cursor }
* clearLocal(); applySnapshot(snap.state)
* // 3. Подписка с курсора снапшота; дельты > cursor накатываются поверх.
* outbox.conversationEvents(after = snap.cursor, conversationId = id).collect { apply(it) }
* }
* store.events(after = client.lastSeen).collect { apply(it) }
* ```
* Точный порядок на стороне сервера/snapshot'а (subscribe-before-snapshot,
* буферизация дельт, idempotent apply) описан в `:client/README.md`.
*
* **Нет delete/cleanup методов** — TTL/cap eviction полностью на стороне
* implementation. Это:
* - Убирает single source of truth дублирование (caller не может забыть cleanup).
* - Позволяет impl выбирать retention strategy (TTL, size cap, sliding window).
* - Сохраняет контракт clean: интерфейс только о put/get.
* ## Gap detection
* `after != null && after.offset < oldestCursor().offset` (или другой
* [Cursor.epoch]) → [OutboxGapException] бросается **изнутри** Flow. Проверка
* идёт под тем же lock'ом, что и регистрация подписчика (одним критическим
* участком), поэтому не гоняется с конкурентной эвикцией и не теряет события
* в окне «snapshot → live».
*
* **Read-only**: этот интерфейс предоставляет только read-операции.
* Для записи см. [MutableOutboxStore].
* ## Read-only
* Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore].
*
* **Подписки нереентрантные**: каждый вызов [events] создаёт **новую
* подписку** (cold Flow). Один [events] НЕ видит события, добавленные до
* его вызова, если [after] == null. Если нужен catchup — передавайте
* `after = lastSeenDate` явно.
*
* **Multi-consumer**: разные [events] подписки видят одно и то же live
* tail. Каждая подписка — независимая projection.
* ## Подписки
* Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая
* независимая подписка** (cold Flow). `after == null` → только live (события
* с момента вызова). Иные consumer'ы видят тот же live-tail; каждая подписка —
* своя проекция.
*/
interface OutboxStore : AutoCloseable {
/**
* Subscribe на events.
* Подписка на события.
*
* **`after == null`** → только **live** (события с момента вызова
* `events()`). Каждое новое событие от любого producer'а немедленно
* появится в Flow. Буфер replay не отдаётся.
* - `after == null` → **только live** (события с момента вызова, replay
* буфера не отдаётся);
* - `after != null` → сначала **catchup** всех буферизованных событий с
* `offset > after.offset` (по возрастанию offset), затем live.
*
* **`after != null`** → сначала **catchup**: эмитт все буферизованные
* события с `date > after`, порядок `date ASC` (ties по `id ASC`).
* Затем **live** (как null-case).
*
* Cold Flow: каждый вызов — новая подписка. Вызов **после** append'а
* не увидит этот конкретный event (если `after == null`); для catchup
* передавайте явный `after`.
*
* ВАЖНО: `Flow` НЕ бросает ошибку при потере сети между producer и
* store — такие события просто не дойдут до этого Flow. Для гарантии
* полноты клиент обязан cross-check с [earliestEventDate] и fallback
* в message store при gap'е (см. KDoc интерфейса).
* @throws OutboxGapException изнутри Flow, если [after] старше
* [oldestCursor] (retention gap) или принадлежит другой эпохе.
*/
fun events(after: Instant?): Flow<CommonEvent>
fun events(after: Cursor?): Flow<CommonEvent>
/**
* Subscribe на **только conversation events** (т.е. [CommonEvent.Conversation]).
* Подписка только на conversation-события ([CommonEvent.Conversation]).
*
* - [conversationId] == null → события **всех** диалогов.
* - [conversationId] != null → события **только этого** диалога.
* - `conversationId == null` → все диалоги;
* - `conversationId != null` → только этот диалог.
*
* Семантика `after` идентична [events] (catchup + live).
* Возвращаемый тип — конкретный subtype [CommonEvent.Conversation].
* Семантика [after] и `gap` идентична [events].
*/
/**
* **Default implementation** (читает все events + фильтрует).
*
* Простая реализация через [events] + filterIsInstance. Реализации
* могут override'нуть для эффективности (например, добавить SQL
* `WHERE conversation_id = ?` чтобы не тянуть всё в память), но
* контракт корректен и без override.
*/
fun conversationEvents(after: Instant?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
events(after)
.filterIsInstance<CommonEvent.Conversation>()
.let { filtered ->
@@ -97,53 +89,34 @@ interface OutboxStore : AutoCloseable {
}
/**
* Subscribe на **только agent events** ([CommonEvent.Agent] —
* создание/удаление/переименование диалога).
* Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/
* переименование диалога).
*
* Семантика `after` идентична [events] (catchup + live).
* Возвращаемый тип — конкретный subtype [CommonEvent.Agent].
*
* Полезно для admin-дашборда, который хочет видеть только lifecycle
* диалогов без деталей ходов.
* Семантика [after] и `gap` идентична [events].
*/
/**
* **Default implementation** (читает все events + фильтрует по типу).
*
* Простая реализация через [events] + filterIsInstance. Реализации
* могут override'нуть для эффективности (например, читать только agent
* row'ы из БД), но контракт корректен и без override.
*/
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> =
fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
events(after).filterIsInstance<CommonEvent.Agent>()
/**
* Date **стартовой точки** буфера.
* Актуальный курсор: offset последнего **записанного** события
* (`lastOffset`). Это «commit point» снапшота: состояние со `seq <= cursor.offset`
* уже в БД, всё, что `> cursor.offset`, придёт потоком.
*
* - Если буфер не пуст → `date` самого старого буферизованного event'а.
* - Если буфер пуст → текущее время (`Clock.System.now()` на момент вызова).
*
* **Семантика "now если пусто"** важна: позволяет клиенту безопасно
* подписаться на [events](after = earliest) сразу — он получит только
* новые live event'ы, без ложного catchup. Если бы возвращалось
* `Instant.DISTANT_PAST` или `null` (с проверкой), клиент мог бы
* ошибочно подписаться на несуществующий catchup и зависнуть в ожидании.
*
* **Используется клиентом для gap detection**:
* - `lastSeen < earliest` → есть дыра в покрытии, нужен fallback
* в message store за диапазоном `[lastSeen, earliest)`.
* - `lastSeen >= earliest` → всё доступно через [events](after),
* fallback не нужен.
* - `lastSeen == earliest` → OK, первый live event будет > earliest.
*
* **Edge case**: клиент, подключившийся до того как store увидел хоть
* один event, получает `earliest ≈ now`. Его `lastSeen` будет < earliest
* — адаптируется в первом же poll'е и пойдёт через fallback если
* сообщения audit log существуют (для consistency с прошлым).
*
* Suspend потому что в persistent impl'ах требует SQL query (`MIN(date)`
* или `Clock.now()` для пустого буфера).
* Клиент берёт его из снапшота либо напрямую перед подпиской.
*/
suspend fun earliestEventDate(): Instant
suspend fun currentCursor(): Cursor
/**
* Минимальный курсор, с которого ещё можно продолжить поток без разрыва.
*
* - `after.offset >= oldestCursor().offset` → replay возможен;
* - `after.offset < oldestCursor().offset` → [OutboxGapException].
*
* Для никогда не эвиктировавшего буфера равен offset'у последнего события
* (т.е. «истории нет, но резумиться с конца можно»), а не `-1`: клиент,
* догнавший состояние до рестарта, продолжает инкрементально.
*/
suspend fun oldestCursor(): Cursor
override fun close()
}
@@ -0,0 +1,62 @@
package pw.binom.agentik.outbox
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* [OffsetSequencer] поверх персистентного [CursorStore] — production-счётчик
* событий агента.
*
* На создании читает сохранённый [Cursor] (`epoch` + next-offset) синхронно и
* держит его в памяти. Позиция переживает рестарт процесса, поэтому обычный
* рестарт сервера **не меняет** `epoch` и offset'ы остаются монотонными —
* клиент продолжает инкрементально (см. [Cursor.epoch] KDoc), а не получает
* gap на каждой перезагрузке.
*
* ### Стартовый offset
*
* Если хранилище пустое (первый запуск / апгрейд БД, где `message.seq` уже
* накоплен), новый `epoch` генерируется сразу, а `next` берётся из [initialNext]
* — по умолчанию `0`, но апгрейд должен передать `maxSeq + 1` журнала, иначе
* новые offset'ы столкнутся с уже записанными `seq`. Новый `epoch` при этом
* корректно заставляет клиентов сделать однократный resync.
*
* ### Ротация epoch
*
* Смена «мира» (сброс/восстановление БД) выполняется вызывающим: очисти
* [CursorStore] — следующий старт сгенерирует новый `epoch`.
*/
class PersistentOffsetSequencer(
private val store: CursorStore,
private val initialNext: () -> Long = { 0L },
private val newEpoch: () -> String = { Cursor.newEpoch() },
) : OffsetSequencer {
private val mutex = Mutex()
private val epoch: String
private var next: Long
init {
val saved = store.load()
if (saved == null) {
epoch = newEpoch()
next = initialNext()
// Фиксируем epoch сразу, чтобы он не «прыгал» до первого события.
store.save(Cursor(epoch = epoch, offset = next))
} else {
epoch = saved.epoch
next = saved.offset
}
}
override fun epoch(): String = epoch
override fun current(): Long = next
override suspend fun reserve(): Long = mutex.withLock {
val assigned = next
next = assigned + 1
store.save(Cursor(epoch = epoch, offset = next))
assigned
}
}