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
+37
View File
@@ -0,0 +1,37 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
// Транспорт-агностичный слой синхронизации: только `:proto` (Agent +
// OutboxStore + snapshots), `:journal-api` (локальный стор бесед) и
// `:cursor-api` (закладка курсора). Никакого ktor — поэтому набор целей
// как у `:proto`, а не как у `:client` (который desktop-only).
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":proto"))
api(project(":journal-api"))
api(project(":cursor-api"))
implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(project(":outbox-inmemory"))
implementation(project(":journal-inmemory"))
implementation(project(":cursor-inmemory"))
}
}
}
@@ -0,0 +1,143 @@
package pw.binom.agentik.client.sync
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.agentik.cursor.MutableCursorHolder
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.OutboxGapException
import pw.binom.agentik.proto.Agent
import kotlin.time.Duration.Companion.seconds
/**
* Оборачивает [Agent] так, что [Agent.conversationStore] становится **локальным**
* стором [localStore], синхронизированным с удалённым агентом по курсор-протоколу.
*
* Механика (см. `:cursor-api` [MutableCursorHolder]):
* 1. **Обычный старт** — подписка на `outbox.agentEvents(after = cursor.current())`
* с сохранённого курсора. Сервер отдаёт события (курсор живой) → применяем,
* после каждого применённого события зовём `cursor.set(event.offset)`.
* 2. **Курсор мёртв / ещё нет** (`OutboxGapException`, `cursor.current() == null`)
* → **полный resync**: `conversationsSnapshot()` (состояние + его курсор `C`),
* `reconcile` локального стора к снапшоту, `cursor.set(C)`, затем подписка
* с того же `C`.
*
* Курсор можно «отставать» от состояния (повторный apply идемпотентен), но нельзя
* «убегать вперёд» — поэтому [localStore] и [cursor] должны быть **durable парой**:
* курсор без сохранённого состояния = потеря данных после рестарта.
*
* ## Владение
* - [localStore], [cursor] и [scope] — **на стороне вызывающего**, обёртка их не
* закрывает;
* - обёртка владеет только фоновой корутиной синхронизации и самим [Agent]
* (её [Agent.close] закрывает делегат).
*/
fun Agent.withLocalSync(
localStore: MutableConversationStore,
cursor: MutableCursorHolder,
scope: CoroutineScope,
): Agent = LocalSyncAgent(
delegate = this,
localStore = localStore,
cursor = cursor,
scope = scope,
)
/** Пауза перед повтором при сетевой ошибке (не при gap — там resync сразу). */
private val RESYNC_RETRY_DELAY = 2.seconds
private class LocalSyncAgent(
private val delegate: Agent,
private val localStore: MutableConversationStore,
private val cursor: MutableCursorHolder,
scope: CoroutineScope,
) : Agent by delegate {
private val syncJob: Job = scope.launch { syncLoop() }
private suspend fun syncLoop() {
while (currentCoroutineContext().isActive) {
try {
syncOnce()
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
// Сеть/5xx — пауза и повтор, локальный кэш сохраняем.
delay(RESYNC_RETRY_DELAY)
}
}
}
private suspend fun syncOnce() {
if (cursor.current() == null) fullResync()
try {
delegate.outbox.agentEvents(after = cursor.current()).collect { ce ->
apply(ce.event)
cursor.set(ce.offset)
}
} catch (_: OutboxGapException) {
// Курсор мёртв (retention / смена epoch) — немедленный полный resync.
fullResync()
}
}
private suspend fun fullResync() {
val snap = delegate.conversationsSnapshot()
reconcile(snap.conversations)
cursor.set(snap.cursor)
}
/**
* Приводит локальный кэш к снапшоту: чего нет в снапшоте — удаляем,
* всё из снапшота — 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`).
*/
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String): ConversationRecord? = localStore.get(id)
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = localStore.list(offset, limit)
override fun close() {} // owned by outer close
}
override fun close() {
syncJob.cancel()
delegate.close()
}
}