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
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
// для JsonElement в MessageContext.metadata
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,25 @@
package pw.binom.agentik.cursor
import kotlinx.serialization.Serializable
/**
* Класс курсора
*/
@Serializable
data class Cursor(
/**
* Время создания курсора. Unix Time в миллисекундах
*/
val createdAt: Long,
/**
* Значение курсора
*/
val offset: ULong,
) : Comparable<Cursor> {
override fun compareTo(other: Cursor): Int {
val byCreatedAt = createdAt.compareTo(other.createdAt)
if (byCreatedAt != 0) return byCreatedAt
return offset.compareTo(other.offset)
}
}
@@ -0,0 +1,25 @@
package pw.binom.agentik.cursor
/**
* Держатель курсора: хранит текущую позицию и выдаёт следующие [Cursor]'ы.
*
* Один держатель — на одного агента, счётчик сквозной по всем сущностям
* (conversation + message + lifecycle). Это даёт единый курсор на всё: и
* глобальный (чат появился/умер/переименован), и per-chat — просто закладки в
* одном потоке offset'ов.
*
* [Cursor.createdAt] — «эпоха» (момент обнуления счётчика): стабильна, пока
* держатель персистентный, и меняется при сбросе/восстановлении БД. Первый
* выданный offset — `1`; `0` означает «до первого события».
*/
interface CursorHolder {
/** Курсор, который будет выдан следующим [next] (next to assign). */
fun current(): Cursor
/**
* Выдать следующий курсор и сдвинуть позицию на `+1`. Первый вызов на
* чистом счётчике даёт `offset = 1`.
*/
fun next(): Cursor
}
@@ -0,0 +1,43 @@
package pw.binom.agentik.cursor
/**
* Клиентская «закладка» курсора: хранит позицию, докуда локальное состояние
* уже применено.
*
* В отличие от [CursorHolder] (серверный allocator, который *выдаёт* новые
* offset'ы), этот holder их не генерирует — клиент только фиксирует уже
* увиденные: `set(snapshot.cursor)` при полной синхронизации и
* `set(event.offset)` по мере применения событий. Вызывать генерацию новых
* offset'ов на клиенте нельзя — сфабрикованный курсор гарантированно даст
* дырку.
*
* ## Семантика
*
* Инклюзивная: [current] — последний **применённый** курсор, т.е. «состояние
* включает все события с `offset <= current()`». Возобновление подписки —
* `events(after = current())` (эксклюзивно).
*
* `null` — ничего ещё не применено (первый запуск / сброшенный кэш): нужен
* полный снапшот, после которого [set] фиксирует `snapshot.cursor`.
*
* ## Порядок записи
*
* [set] вызывается **после** того, как соответствующее состояние durable
* применено. Курсор можно «отставать» от состояния (реплей идемпотентен), но
* нельзя «убегать вперёд» — иначе на рестарте события между состоянием и
* курсором потеряются.
*
* Реализация может персистить значение (durable-impl пишет сквозь), поэтому
* [set] — `suspend`.
*/
interface MutableCursorHolder {
/** Последний применённый курсор, или `null`, если ещё ничего не применено. */
fun current(): Cursor?
/**
* Зафиксировать курсор. Вызывается после durable-применения состояния,
* которому он соответствует; может писать в персистентное хранилище.
*/
suspend fun set(cursor: Cursor)
}