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
View File
@@ -23,7 +23,6 @@ kotlin {
api(project(":proto"))
api(project(":outbox-api"))
api(project(":journal-api"))
implementation(project(":journal-inmemory"))
api(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
@@ -1,24 +1,8 @@
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.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationRecord
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.Duration.Companion.seconds
/**
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
@@ -28,6 +12,11 @@ import kotlin.time.Duration.Companion.seconds
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
* снаружи — всё под капотом.
*
* Возвращает **чистый** HTTP-[Agent]: [Agent.conversationStore] ходит по сети
* напрямую. Локальный кэш с курсор-синхронизацией — отдельно, через
* `:client-sync` (`Agent.withLocalSync(localStore, cursor, scope)`); либа
* намеренно не прячет за собой ни стор, ни курсор, ни их персистентность.
*
* ```
* val agent = AgentikAgent(
* id = "my-client",
@@ -41,7 +30,7 @@ import kotlin.time.Duration.Companion.seconds
* val snap = agent.chatSnapshot(conv.id)
* agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
* .collect { ... }
* agent.close() // закрывает HttpClient + локальный кэш
* agent.close() // закрывает HttpClient
* ```
*
* ## Что клиент должен хранить локально (persistence)
@@ -78,22 +67,16 @@ import kotlin.time.Duration.Companion.seconds
*
* ## Локальный кэш списка бесед
*
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
* Внутри лежит [InMemoryMutableConversationStore], который:
* 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) идут
* через [Agent] и **не** через `conversationStore` (он read-only).
* В этом модуле его **нет**. Синхронизация локального кэша с удалённым агентом
* (снапшот + дельты по курсору, durable-закладка) живёт в `:client-sync` как
* `Agent.withLocalSync(localStore, cursor, scope)`. Хост сам решает, где хранить
* [pw.binom.agentik.journal.MutableConversationStore] и
* [pw.binom.agentik.cursor.MutableCursorHolder] (файл / SQLite / DataStore),
* и обязан держать их durable-парой.
*
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
* HttpClient + локальный кэш + background-coroutine (идемпотентно).
* После этого `createConversation` / `getConversation` etc. не определены.
* HttpClient (идемпотентно). После этого `createConversation` /
* `getConversation` etc. не определены.
*/
fun AgentikAgent(
id: String,
@@ -102,106 +85,5 @@ fun AgentikAgent(
token: String? = null,
): Agent {
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
val client = runBlocking { AgentClient.create(id = id, baseUrl = baseUrl, httpClient = httpClient) }
return wrapWithLocalConversationCache(client, scopeClient = client)
}
/**
* Оборачивает [Agent] так, что [Agent.conversationStore] становится
* локальным in-memory кэшем, синхронизированным с удалённым стором
* по курсор-протоколу.
*
* **Протокол синхронизации** (гарантирует актуальный список бесед):
* 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,
): Agent = object : Agent by delegate {
private val localStore: MutableConversationStore = InMemoryMutableConversationStore()
private val cacheScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val syncJob: Job
init {
syncJob = cacheScope.launch { syncLoop() }
}
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`).
*/
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() {
cacheScope.cancel()
runBlocking { syncJob.join() }
delegate.close()
}
return runBlocking { AgentClient.create(id = id, baseUrl = baseUrl, httpClient = httpClient) }
}
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.outbox.OutboxGapException
import pw.binom.agentik.outbox.OutboxStore
@@ -79,7 +79,7 @@ internal class HttpEventStore(
httpClient.prepareGet(url) {
noReadTimeout()
if (after != null) {
parameter("epoch", after.epoch)
parameter("epoch", after.createdAt)
parameter("offset", after.offset)
}
}.execute { response ->
@@ -115,7 +115,7 @@ internal data class GapResponse(
private suspend fun HttpResponse.toGapException(requested: Cursor?): OutboxGapException {
val dto = runCatching { agentikJson.decodeFromString(GapResponse.serializer(), bodyAsText()) }.getOrNull()
val fallback = requested ?: Cursor(epoch = "", offset = -1L)
val fallback = requested ?: Cursor(createdAt = 0L, offset = 0u)
return OutboxGapException(
requested = requested,
oldest = dto?.oldest ?: fallback,
@@ -11,7 +11,7 @@ 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.cursor.Cursor
import pw.binom.agentik.outbox.OutboxGapException
import pw.binom.agentik.outbox.OutboxStore
import kotlin.concurrent.atomics.AtomicBoolean
@@ -225,7 +225,7 @@ class ReconnectingOutbox(
var gap: OutboxGapException? = null
val error: Throwable? = try {
outbox.events(after = cursor).collect { event ->
lastSeen.store(Cursor(epoch = cursor?.epoch ?: "", offset = event.offset))
lastSeen.store(event.offset)
_events.emit(event)
if (!connected) {
connected = true
@@ -12,7 +12,7 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.outbox.OutboxGapException
import pw.binom.agentik.outbox.DurableEvent
@@ -59,17 +59,19 @@ internal class FakeOutbox : OutboxStore {
after: Cursor?,
conversationId: String?,
): Flow<CommonEvent.Conversation> = emptyFlow()
override suspend fun currentCursor(): Cursor = Cursor(epoch = "test", offset = -1L)
override suspend fun oldestCursor(): Cursor = Cursor(epoch = "test", offset = -1L)
override suspend fun currentCursor(): Cursor = Cursor(createdAt = TEST_EPOCH, offset = 0u)
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = TEST_EPOCH, offset = 0u)
override fun close() { channel.close() }
}
private const val TEST_EPOCH = "test"
private const val TEST_EPOCH = 1_000L
private fun testCursor(offset: ULong) = Cursor(createdAt = TEST_EPOCH, offset = offset)
private fun testEvent(dateMs: Long): CommonEvent =
CommonEvent.Conversation(
date = Instant.fromEpochMilliseconds(dateMs),
offset = dateMs,
offset = testCursor(dateMs.toULong()),
conversationId = "test",
event = DurableEvent.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
)
@@ -150,9 +152,9 @@ class ReconnectingOutboxTest {
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),
requested = testCursor(0u),
oldest = testCursor(10u),
current = testCursor(20u),
)
fake.throwAtNextEvent(gap)
ctx.advanceAndDrain(50)