refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api

Семантика outbox'а — теперь чистый live-канал:
  * OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
    Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
    на live. Outbox ответственен только за уведомления «что-то произошло».
  * OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
    Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
    рестарт и инкремент для каждого commit.
  * DurableEvent: commit принимает блок { cursor -> MessageRecord }
    (cursor выдаёт CursorStore; клиент не вычисляет offset сам).
  * MessageRecord больше не несёт cursor — это не его ответственность.

Новые модули:
  * :cursor-api         — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
  * :cursor-ksqlite     — KsqliteCursorHolder (персистентный)
  * :cursor-inmemory    — для тестов
  * :client-sync        — LocalSyncAgent (мини-агент поверх :client для десктопа)

Удалены:
  * :sync-core          — старая референсная реализация, заменена
                           cursor-разделением и :client.
  * outbox-ksqlite      — CursorStore/Schema уехали в :cursor-ksqlite.
  * OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.

standalone:
  * ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
    ConversationEvents — подписка через push-паттерн (collect событий).
  * A2aBridge — currentCursor() и conversationEvents(after=) убраны.
  * SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
    читает MAX(created_at).
  * Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
    transport удалён; debug-routes удалены; A2A остался.
  * Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
    (subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅

server / client:
  * Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
  * AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
  * ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
  * JournalStore API расширен count(after=Instant?) для unread-badge.
This commit is contained in:
2026-10-05 23:33:35 +03:00
parent 22a167ed03
commit 9e888227a3
115 changed files with 2147 additions and 6025 deletions
@@ -1,3 +1,6 @@
package pw.binom
/**
* Сигнал о том, что запрошенный курсор уже скомпактирован и запрошенные события недоступны.
*/
class CursorExpiredException : RuntimeException()
@@ -2,4 +2,7 @@ package pw.binom
import pw.binom.agentik.cursor.Cursor
/**
* Событие журнала: полезная нагрузка [data] и её позиция [seq] в общем потоке.
*/
data class Event<EVENT>(val data: EVENT, val seq: Cursor)
@@ -3,6 +3,13 @@ package pw.binom
import kotlinx.coroutines.flow.Flow
import kotlin.jvm.JvmInline
/**
* Результат чтения журнала от курсора: либо поток событий, либо признак того, что запрошенный
* курсор уже скомпактирован ([isExpired]).
*
* Реализован как value-класс без боксинга потока; вместо исключения «протухший» курсор
* возвращается явно, а [getOrException] бросает [CursorExpiredException].
*/
@JvmInline
value class EventSeqResult<EVENT> private constructor(val raw: Any) {
@@ -2,6 +2,13 @@ package pw.binom
import pw.binom.agentik.cursor.Cursor
/**
* Журнал событий, читаемый по курсору (только чтение).
*
* [minCursor]/[maxCursor] — границы хранимого диапазона (null для пустого журнала). [get] отдаёт
* все события начиная с [cursor] включительно, либо [EventSeqResult.expired], если [cursor]
* старше минимального хранимого. Запись — в [MutableEventStore].
*/
interface EventStore<EVENT> : AutoCloseable {
val minCursor: Cursor?
@@ -40,7 +40,7 @@ import pw.binom.db.ksqlite.transaction
* выбрасывается посреди потока.
*/
@OptIn(ExperimentalSerializationApi::class)
class KSqliteMutableEventStore<EVENT>(
class KSqliteMutableEventStore<EVENT> private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
private val tableName: String,
@@ -2,6 +2,10 @@ package pw.binom
import pw.binom.agentik.cursor.Cursor
/**
* Изменяемый журнал событий: к чтению из [EventStore] добавляет дозапись ([push]), полную очистку
* ([clear]) и компакцию ([dropAllBefore], удаляет всё до [cursor] включительно).
*/
interface MutableEventStore<EVENT> : EventStore<EVENT> {
fun push(event: Event<EVENT>) = push(event.data, event.seq)
fun push(event: EVENT, cursor: Cursor)
@@ -2,6 +2,10 @@ package pw.binom
import pw.binom.agentik.cursor.Cursor
/**
* Материализует [STATE] из событий: [apply] применяет событие с позицией [cursor] к состоянию.
* Как именно — решает сам стор, отдельный редьюсер не требуется.
*/
interface MutableStateStore<STATE, EVENT> : StateStore<STATE> {
fun apply(event: EVENT, cursor: Cursor)
fun apply(event: Event<EVENT>) = apply(event.data, event.seq)
@@ -2,11 +2,12 @@ package pw.binom
import pw.binom.agentik.cursor.Cursor
interface MutableStorage<STATE, EVENT>: AutoCloseable {
/**
* Хранилище, совмещающее материализованное состояние ([StateStore]) и журнал событий
* ([EventStore]) в одном месте — локальная БД клиента, из которой рендерится UI.
* [push] дозаписывает событие в журнал.
*/
interface MutableStorage<STATE, EVENT>: StateStore<STATE>,EventStore<EVENT> {
fun push(event: EVENT, cursor: Cursor)
/**
* Возвращает состояние на момент [cursor]
*/
fun getState(cursor: Cursor): STATE
fun clear()
}
@@ -2,9 +2,11 @@ package pw.binom
import pw.binom.agentik.cursor.Cursor
interface StateStore<STATE> {
/**
* Материализованное из журнала состояние, читаемое по курсору (только чтение).
* Наполнение — в [MutableStateStore].
*/
interface StateStore<STATE> : AutoCloseable {
/**
* Возвращает состояние на момент [cursor]
*/
@@ -17,6 +17,9 @@ private const val EPOCH = 1_700_000_000_000L
@Serializable
private data class TestEvent(val value: Int)
/**
* Тесты ksqlite-реализации журнала событий [KSqliteMutableEventStore] на in-memory SQLite.
*/
class KSqliteMutableEventStoreTest {
private fun cursor(offset: ULong, createdAt: Long = EPOCH) = Cursor(createdAt = createdAt, offset = offset)