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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user