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:
@@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
@@ -40,17 +40,17 @@ internal class FakeAgent(
|
||||
override fun events(after: Cursor?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
|
||||
override fun agentEvents(after: Cursor?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>()
|
||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>()
|
||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override fun close() {}
|
||||
}
|
||||
override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests")
|
||||
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation {
|
||||
createCount++
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// KMP-реализации :cursor-api в памяти — для тестов, dev-режима и
|
||||
// embedded-сценариев (Android core, CLI):
|
||||
// - `InMemoryCursorHolder` (серверный allocator): эпоха (`createdAt`)
|
||||
// генерируется при создании и не переживает пересоздание инстанса;
|
||||
// - `InMemoryMutableCursorHolder` (клиентская закладка).
|
||||
//
|
||||
// Зависимости: только `:cursor-api`. Никакого I/O — pure in-memory.
|
||||
//
|
||||
// Цели сборки — jvm() + linuxX64() + mingwX64() (как у `:outbox-inmemory`).
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":cursor-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package pw.binom.agentik.cursor.inmemory
|
||||
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.cursor.CursorHolder
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* In-memory [CursorHolder] — для тестов, dev-режима и ephemeral runtime.
|
||||
*
|
||||
* `createdAt` генерируется при создании и **не переживает** пересоздание
|
||||
* инстанса: новый holder → новая эпоха → клиент с прежним курсором получит
|
||||
* `OutboxGapException` и сделает resync. Для production-агента нужен
|
||||
* персистентный holder (см. `:cursor-ksqlite`).
|
||||
*/
|
||||
class InMemoryCursorHolder(
|
||||
private val createdAt: Long = Clock.System.now().toEpochMilliseconds(),
|
||||
initialOffset: ULong = 1u,
|
||||
) : CursorHolder {
|
||||
|
||||
private var counter: ULong = initialOffset
|
||||
|
||||
override fun current(): Cursor = Cursor(createdAt, counter)
|
||||
|
||||
override fun next(): Cursor {
|
||||
val assigned = current()
|
||||
counter += 1u
|
||||
return assigned
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package pw.binom.agentik.cursor.inmemory
|
||||
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.cursor.MutableCursorHolder
|
||||
|
||||
/**
|
||||
* In-memory [MutableCursorHolder].
|
||||
*
|
||||
* Курсор живёт только в RAM: после пересоздания инстанса (рестарт процесса)
|
||||
* он теряется, и клиент снова пойдёт за снапшотом. Для переживания рестарта
|
||||
* нужен durable-impl (файл / DataStore / SQLite) со стороны хоста — этот класс
|
||||
* лишь даёт готовую реализацию для dev/test/ephemeral-сценариев.
|
||||
*
|
||||
* @param initial стартовое значение; обычно результат загрузки из хранилища.
|
||||
*/
|
||||
class InMemoryMutableCursorHolder(
|
||||
initial: Cursor? = null,
|
||||
) : MutableCursorHolder {
|
||||
|
||||
private var value: Cursor? = initial
|
||||
|
||||
override fun current(): Cursor? = value
|
||||
|
||||
override suspend fun set(cursor: Cursor) {
|
||||
value = cursor
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package pw.binom.agentik.cursor.inmemory
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
|
||||
class InMemoryMutableCursorHolderTest {
|
||||
|
||||
@Test
|
||||
fun startsEmpty() {
|
||||
assertNull(InMemoryMutableCursorHolder().current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startsFromInitial() {
|
||||
val c = Cursor(createdAt = 1_700_000_000_000L, offset = 42u)
|
||||
assertEquals(c, InMemoryMutableCursorHolder(initial = c).current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setReplacesValue() = runTest {
|
||||
val holder = InMemoryMutableCursorHolder()
|
||||
val first = Cursor(createdAt = 1_700_000_000_000L, offset = 1u)
|
||||
val second = Cursor(createdAt = 1_700_000_000_000L, offset = 2u)
|
||||
|
||||
holder.set(first)
|
||||
assertEquals(first, holder.current())
|
||||
|
||||
holder.set(second)
|
||||
assertEquals(second, holder.current())
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// KMP-реализация :outbox-api `CursorStore` поверх ksqlite.
|
||||
// KMP-реализация :cursor-api `CursorHolder` поверх ksqlite.
|
||||
// Минимальная — только таблица `outbox_cursor` (одна строка: epoch + next offset).
|
||||
// Даёт production-агенту персистентный `OffsetSequencer`: обычный рестарт сервера
|
||||
// Даёт production-агенту персистентный счётчик: обычный рестарт сервера
|
||||
// не ротирует epoch, клиент продолжает инкрементально.
|
||||
//
|
||||
// Цели сборки — jvm() + linuxX64() + mingwX64() (как у остальных ksqlite-модулей).
|
||||
@@ -20,7 +20,7 @@ kotlin {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.ksqlite)
|
||||
|
||||
api(project(":outbox-api"))
|
||||
api(project(":cursor-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package pw.binom.agentik.cursor.ksqlite
|
||||
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.cursor.CursorHolder
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [CursorHolder] — таблица `outbox_cursor` (одна строка,
|
||||
* `id = 1`).
|
||||
*
|
||||
* На создании читает сохранённую позицию (`createdAt` + next offset) и держит
|
||||
* её в памяти. Позиция переживает рестарт процесса, поэтому обычный рестарт
|
||||
* сервера **не меняет** `createdAt`, offset'ы остаются монотонными — клиент
|
||||
* продолжает инкрементально, а не получает gap на каждой перезагрузке.
|
||||
*
|
||||
* ## Стартовый offset
|
||||
*
|
||||
* Если таблица пустая (первый запуск / апгрейд БД, где offset уже накоплен),
|
||||
* `createdAt` генерируется ([newCreatedAt]), а next берётся из [initialNext]
|
||||
* (по умолчанию `1`, апгрейд должен передать `maxOffset + 1`).
|
||||
*
|
||||
* ## Смена эпохи
|
||||
*
|
||||
* Смена «мира» (сброс/восстановление БД) — у вызывающего: очисти таблицу,
|
||||
* следующий старт сгенерирует новый `createdAt`.
|
||||
*
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Две формы, как у остальных ksqlite-store'ов:
|
||||
* - `KsqliteCursorHolder(connection, ...)` — внешнее соединение, holder НЕ
|
||||
* закрывает его в [close]. Для shared-connection bundle'а
|
||||
* (`SqliteStores.assemble`).
|
||||
* - `KsqliteCursorHolder(path, ...)` — открывает файловое соединение и
|
||||
* закрывает его в [close].
|
||||
*
|
||||
* `[next]` синхронный — ksqlite-запись блокирующая. Вызовы сериализует
|
||||
* владелец (в `standalone` — мьютекс [pw.binom.agentik.outbox.MutableOutboxStore]),
|
||||
* так что собственная синхронизация не нужна; prepared statements закрываются
|
||||
* в [close] ДО owned-connection (иначе финалайзеры stmt'ов дёргают уже
|
||||
* закрытый parent → SIGSEGV).
|
||||
*/
|
||||
class KsqliteCursorHolder private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
initialNext: () -> ULong,
|
||||
newCreatedAt: () -> Long,
|
||||
) : CursorHolder, AutoCloseable {
|
||||
|
||||
/** Внешнее соединение — holder НЕ закрывает его в [close]. */
|
||||
constructor(
|
||||
connection: SQLiteConnection,
|
||||
initialNext: () -> ULong = { 1u },
|
||||
newCreatedAt: () -> Long = { Clock.System.now().toEpochMilliseconds() },
|
||||
) : this(connection, ownsConnection = false, initialNext, newCreatedAt)
|
||||
|
||||
/** Файловое соединение — holder закрывает его в [close]. */
|
||||
constructor(
|
||||
path: String,
|
||||
initialNext: () -> ULong = { 1u },
|
||||
newCreatedAt: () -> Long = { Clock.System.now().toEpochMilliseconds() },
|
||||
) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
initialNext,
|
||||
newCreatedAt,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
private val getStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT ${Schema.COL_CREATED_AT}, ${Schema.COL_OFFSET} " +
|
||||
"FROM ${Schema.TABLE} WHERE ${Schema.COL_ID} = 1"
|
||||
)
|
||||
private val setStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"INSERT INTO ${Schema.TABLE}(${Schema.COL_ID}, ${Schema.COL_CREATED_AT}, ${Schema.COL_OFFSET}) " +
|
||||
"VALUES(1, ?, ?) ON CONFLICT(${Schema.COL_ID}) DO UPDATE SET " +
|
||||
"${Schema.COL_CREATED_AT}=excluded.${Schema.COL_CREATED_AT}, " +
|
||||
"${Schema.COL_OFFSET}=excluded.${Schema.COL_OFFSET}"
|
||||
)
|
||||
|
||||
private val createdAt: Long
|
||||
private var next: ULong
|
||||
|
||||
init {
|
||||
val saved = load()
|
||||
if (saved == null) {
|
||||
createdAt = newCreatedAt()
|
||||
next = initialNext()
|
||||
// Фиксируем createdAt сразу, чтобы он не «прыгал» до первого события.
|
||||
save(Cursor(createdAt = createdAt, offset = next))
|
||||
} else {
|
||||
createdAt = saved.createdAt
|
||||
next = saved.offset
|
||||
}
|
||||
}
|
||||
|
||||
override fun current(): Cursor = Cursor(createdAt, next)
|
||||
|
||||
override fun next(): Cursor {
|
||||
val assigned = Cursor(createdAt, next)
|
||||
next += 1u
|
||||
save(Cursor(createdAt, next))
|
||||
return assigned
|
||||
}
|
||||
|
||||
private fun load(): Cursor? {
|
||||
getStmt.reset()
|
||||
getStmt.clearBindings()
|
||||
getStmt.executeQuery().use { rs ->
|
||||
if (!rs.next()) return null
|
||||
return Cursor(createdAt = rs.getLong(0)!!, offset = rs.getLong(1)!!.toULong())
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(cursor: Cursor) {
|
||||
setStmt.reset()
|
||||
setStmt.clearBindings()
|
||||
setStmt.bindLong(1, cursor.createdAt)
|
||||
setStmt.bindLong(2, cursor.offset.toLong())
|
||||
setStmt.executeUpdate()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
getStmt.close()
|
||||
setStmt.close()
|
||||
if (ownsConnection) connection.close()
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -1,33 +1,35 @@
|
||||
package pw.binom.agentik.outbox.ksqlite
|
||||
package pw.binom.agentik.cursor.ksqlite
|
||||
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/**
|
||||
* Имена таблиц/колонок для ksqlite-бэкенда `:outbox-api`.
|
||||
* Имена таблиц/колонок для ksqlite-реализации [pw.binom.agentik.cursor.CursorHolder].
|
||||
*
|
||||
* Владеет одной таблицей `outbox_cursor` — ровно одна строка (`id = 1`) с
|
||||
* персистентной позицией счётчика событий агента (`epoch` + next offset).
|
||||
* персистентной позицией счётчика событий агента ([Cursor.createdAt] +
|
||||
* next offset), см. [KsqliteCursorHolder].
|
||||
*
|
||||
* Как и остальные ksqlite-модули, `user_version` как gate не используется
|
||||
* (split-world: несколько модулей ставят его независимо) — [migrate] просто
|
||||
* идемпотентно прогоняет DDL.
|
||||
* `user_version` как gate не используется (split-world: несколько модулей
|
||||
* ставят его независимо) — [migrate] просто идемпотентно прогоняет DDL.
|
||||
*/
|
||||
object Schema {
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 1
|
||||
const val CURRENT_VERSION: Int = 2
|
||||
|
||||
const val TABLE = "outbox_cursor"
|
||||
const val COL_ID = "id"
|
||||
const val COL_EPOCH = "epoch"
|
||||
|
||||
/** Время создания счётчика (Unix ms) — «эпоха» [pw.binom.agentik.cursor.Cursor]. */
|
||||
const val COL_CREATED_AT = "created_at"
|
||||
|
||||
/** Хранит **next** offset (эксклюзивную границу), а не последний выданный. */
|
||||
const val COL_OFFSET = "next_offset"
|
||||
|
||||
private val ddl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE (
|
||||
$COL_ID INTEGER PRIMARY KEY CHECK ($COL_ID = 1),
|
||||
$COL_EPOCH TEXT NOT NULL,
|
||||
$COL_OFFSET INTEGER NOT NULL
|
||||
$COL_ID INTEGER PRIMARY KEY CHECK ($COL_ID = 1),
|
||||
$COL_CREATED_AT INTEGER NOT NULL,
|
||||
$COL_OFFSET INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package pw.binom.agentik.cursor.ksqlite
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
private const val EPOCH = 1_700_000_000_000L
|
||||
|
||||
class KsqliteCursorHolderTest {
|
||||
|
||||
@Test
|
||||
fun generatesMonotonically() {
|
||||
val conn = SQLiteConnection.memory("cursor-holder-test")
|
||||
try {
|
||||
val holder = KsqliteCursorHolder(conn, newCreatedAt = { EPOCH })
|
||||
assertEquals(EPOCH, holder.current().createdAt)
|
||||
assertEquals(Cursor(EPOCH, 1u), holder.current())
|
||||
assertEquals(Cursor(EPOCH, 1u), holder.next())
|
||||
assertEquals(Cursor(EPOCH, 2u), holder.next())
|
||||
assertEquals(Cursor(EPOCH, 3u), holder.current())
|
||||
holder.close()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedsInitialNextOnFirstRun() {
|
||||
val conn = SQLiteConnection.memory("cursor-holder-seed")
|
||||
try {
|
||||
val holder = KsqliteCursorHolder(conn, initialNext = { 42u }, newCreatedAt = { EPOCH })
|
||||
assertEquals(Cursor(EPOCH, 42u), holder.current())
|
||||
assertEquals(Cursor(EPOCH, 42u), holder.next())
|
||||
assertEquals(Cursor(EPOCH, 43u), holder.current())
|
||||
holder.close()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun survivesRestartKeepingEpochAndOffset() {
|
||||
val conn = SQLiteConnection.memory("cursor-holder-restart")
|
||||
try {
|
||||
val before = KsqliteCursorHolder(conn, newCreatedAt = { EPOCH })
|
||||
before.next()
|
||||
before.next()
|
||||
|
||||
// «Рестарт»: новый holder над той же БД.
|
||||
val after = KsqliteCursorHolder(conn, newCreatedAt = { EPOCH + 1 })
|
||||
assertEquals(EPOCH, after.current().createdAt)
|
||||
assertEquals(Cursor(EPOCH, 3u), after.current())
|
||||
assertEquals(Cursor(EPOCH, 3u), after.next())
|
||||
assertEquals(Cursor(EPOCH, 4u), after.current())
|
||||
|
||||
before.close()
|
||||
after.close()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinx-serialization" }
|
||||
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
|
||||
|
||||
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
|
||||
|
||||
@@ -18,6 +18,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":content-api"))
|
||||
api(project(":cursor-api"))
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
|
||||
@@ -14,48 +14,27 @@ import kotlin.time.Instant
|
||||
* Никаких обновлений, никакого удаления (кроме каскадного вместе
|
||||
* с ConversationStore.delete).
|
||||
*
|
||||
* ## Два способа адресации позиции
|
||||
* - **по [Instant] `createdAt`** — legacy, «дай всё после даты»;
|
||||
* - **по [MessageRecord.seq]** (монотонный per-agent offset) — протокол
|
||||
* снапшотов: диапазон `afterSeq < seq <= upToSeq` даёт **конечное и
|
||||
* стабильное** множество строк. Catch-up: `afterSeq = <курсор>, upToSeq = MAX`.
|
||||
* Снапшот с курсором C: `afterSeq = -1, upToSeq = C`.
|
||||
*
|
||||
* [listFlowSeq] использует keyset-пагинацию (`seq > last`), а не `OFFSET` —
|
||||
* иначе конкурентная вставка/удаление сдвигает окно и молча теряет строки.
|
||||
* Адресация позиции — по [Instant] `createdAt` («дай всё после даты»).
|
||||
* Курсора/`seq` здесь нет: монотонный `(эпоха, offset)` для удалённого
|
||||
* resume и снапшотов живёт в sync-слое, который строит свой поток поверх
|
||||
* журнала.
|
||||
*/
|
||||
interface JournalStore : AutoCloseable {
|
||||
|
||||
/** Legacy-страница по `createdAt > [after]`, `ORDER BY createdAt ASC` + `OFFSET`. */
|
||||
/** Страница по `createdAt > [after]`, `ORDER BY createdAt ASC` + `OFFSET`. */
|
||||
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||
|
||||
/**
|
||||
* Keyset-страница записей `[afterSeq] < seq <= [upToSeq]`, `ORDER BY seq ASC`.
|
||||
*
|
||||
* @param afterSeq нижняя эксклюзивная граница (для «с начала» — `-1`).
|
||||
* @param upToSeq верхняя **инклюзивная** граница (для «без отсечки» —
|
||||
* `Long.MAX_VALUE`).
|
||||
*/
|
||||
suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int): List<MessageRecord>
|
||||
|
||||
/** Сколько сообщений в диалоге [conversationId] всего. */
|
||||
suspend fun count(conversationId: String): Long
|
||||
|
||||
/**
|
||||
* Сколько сообщений создано **позже** [after] (строго `createdAt > after`).
|
||||
* Legacy unread-бейдж по времени.
|
||||
* Unread-бейдж по времени.
|
||||
*/
|
||||
suspend fun count(conversationId: String, after: Instant): Long
|
||||
|
||||
/**
|
||||
* Сколько сообщений имеют `seq > [afterSeq]`. Cursor-версия unread-бейджа:
|
||||
* `count(convId, afterSeq = lastSeenOffset)`.
|
||||
*/
|
||||
suspend fun count(conversationId: String, afterSeq: Long): Long
|
||||
|
||||
/**
|
||||
* Cold-flow paging (legacy, по [Instant]). Default-реализация делает N+1
|
||||
* round-trip.
|
||||
* Cold-flow paging по [Instant]. Default-реализация делает N+1 round-trip.
|
||||
*/
|
||||
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
|
||||
var offset = 0
|
||||
@@ -68,26 +47,6 @@ interface JournalStore : AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-flow paging по `seq` (keyset). `cursor` растёт по мере эмиссии;
|
||||
* `upToSeq` ограничивает сверху (снапшот с курсором).
|
||||
*/
|
||||
fun listFlowSeq(
|
||||
conversationId: String,
|
||||
afterSeq: Long = -1L,
|
||||
upToSeq: Long = Long.MAX_VALUE,
|
||||
pageSize: Int = PAGE_SIZE,
|
||||
): Flow<MessageRecord> = flow {
|
||||
var cursor = afterSeq
|
||||
while (true) {
|
||||
val page = list(conversationId, cursor, upToSeq, pageSize)
|
||||
if (page.isEmpty()) return@flow
|
||||
for (rec in page) emit(rec)
|
||||
cursor = page.last().seq
|
||||
if (page.size < pageSize) return@flow
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PAGE_SIZE = 100
|
||||
}
|
||||
|
||||
@@ -10,28 +10,20 @@ import kotlin.time.Instant
|
||||
/**
|
||||
* Запись в таблице `message` (append-only audit).
|
||||
*
|
||||
* ## [seq] — курсор записи
|
||||
* [seq] — **тот же монотонный per-agent offset**, что и `offset` соответствующего
|
||||
* events-события (`OffsetSequencer.reserve()`). Writer резервирует offset и
|
||||
* пишет строку с `seq = offset` **до** append'а события в outbox (инвариант
|
||||
* «сначала состояние, потом событие»).
|
||||
* Позиция записи в потоке задаётся [createdAt] (UTC). Для разрешения ничьих
|
||||
* (несколько сообщений с одинаковым `Instant`) реализация добавляет вторичный
|
||||
* ключ (`id`) при сортировке.
|
||||
*
|
||||
* Нужен для снапшота с «курсором»: клиент берёт `currentCursor() = C` и читает
|
||||
* `listUpTo(convId, C)` — конечное, стабильное множество строк, отражающее
|
||||
* состояние на момент C. Всё, что появится позже, имеет `seq > C` и приедет
|
||||
* потоком событий.
|
||||
*
|
||||
* Значение по умолчанию `0L` — для legacy-записей и тестов; production-путь
|
||||
* (`ConversationLoop` / `ToolDispatcher`) всегда выставляет реальный offset.
|
||||
* Запись с `seq = 0` в снапшоте всегда «≤ C», поэтому попадает в снапшот и
|
||||
* (если её событие ещё и в потоке) применяется дважды — идемпотентно, безвредно.
|
||||
* **Курсора здесь нет.** Монотонный `(эпоха, offset)` и вся семантика
|
||||
* удалённого доступа (снапшоты, resume, gap) — ответственность sync-слоя,
|
||||
* который строит свой упорядоченный поток поверх журнала. Журнал — это просто
|
||||
* durable история диалогов.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface MessageRecord {
|
||||
val id: String
|
||||
val conversationId: String
|
||||
val createdAt: Instant
|
||||
val seq: Long
|
||||
|
||||
@Serializable
|
||||
sealed interface Body : MessageRecord {
|
||||
@@ -46,7 +38,6 @@ sealed interface MessageRecord {
|
||||
override val content: List<Content>,
|
||||
override val createdAt: Instant,
|
||||
val context: MessageContext? = null,
|
||||
override val seq: Long = 0L,
|
||||
) : Body
|
||||
|
||||
@Serializable
|
||||
@@ -63,7 +54,6 @@ sealed interface MessageRecord {
|
||||
* их не раскрывает.
|
||||
*/
|
||||
val reasoning: String? = null,
|
||||
override val seq: Long = 0L,
|
||||
) : Body
|
||||
|
||||
@Serializable
|
||||
@@ -75,7 +65,6 @@ sealed interface MessageRecord {
|
||||
val toolTitle: String?,
|
||||
val toolArgsJson: String,
|
||||
override val createdAt: Instant,
|
||||
override val seq: Long = 0L,
|
||||
) : MessageRecord
|
||||
|
||||
@Serializable
|
||||
@@ -94,7 +83,6 @@ sealed interface MessageRecord {
|
||||
val toolName: String? = null,
|
||||
val result: String?,
|
||||
override val createdAt: Instant,
|
||||
override val seq: Long = 0L,
|
||||
) : MessageRecord
|
||||
|
||||
@Serializable
|
||||
@@ -105,6 +93,5 @@ sealed interface MessageRecord {
|
||||
val message: String,
|
||||
val code: String?,
|
||||
override val createdAt: Instant,
|
||||
override val seq: Long = 0L,
|
||||
) : MessageRecord
|
||||
}
|
||||
|
||||
+2
-20
@@ -15,9 +15,8 @@ import kotlin.time.Instant
|
||||
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
|
||||
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
|
||||
*
|
||||
* **Контракт `list`**: legacy — `createdAt > after`, `ORDER BY createdAt ASC`
|
||||
* (+`offset/limit`); seq-версия — `afterSeq < seq <= upToSeq`,
|
||||
* `ORDER BY seq ASC` (keyset).
|
||||
* **Контракт `list`**: `createdAt > after`, `ORDER BY createdAt ASC`
|
||||
* (+`offset/limit`).
|
||||
*
|
||||
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
|
||||
* на сервере). [close] — no-op.
|
||||
@@ -45,19 +44,6 @@ class InMemoryJournalStore : MutableJournalStore {
|
||||
.toList()
|
||||
}
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
afterSeq: Long,
|
||||
upToSeq: Long,
|
||||
limit: Int,
|
||||
): List<MessageRecord> = mutex.withLock {
|
||||
records.asSequence()
|
||||
.filter { it.conversationId == conversationId && it.seq > afterSeq && it.seq <= upToSeq }
|
||||
.sortedWith(compareBy({ it.seq }, { it.createdAt }, { it.id }))
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
|
||||
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
|
||||
records.removeAll { it.conversationId == conversationId }
|
||||
@@ -71,10 +57,6 @@ class InMemoryJournalStore : MutableJournalStore {
|
||||
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
|
||||
}
|
||||
|
||||
override suspend fun count(conversationId: String, afterSeq: Long): Long = mutex.withLock {
|
||||
records.count { it.conversationId == conversationId && it.seq > afterSeq }.toLong()
|
||||
}
|
||||
|
||||
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
|
||||
suspend fun size(): Int = mutex.withLock { records.size }
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ kotlin {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
api(project(":journal-api"))
|
||||
api(project(":sync"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
|
||||
+9
-74
@@ -32,25 +32,18 @@ import kotlinx.coroutines.withContext
|
||||
* ## Миграция
|
||||
*
|
||||
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
|
||||
* (CREATE TABLE / INDEX IF NOT EXISTS + гейтированный ADD COLUMN), так что
|
||||
* лишних эффектов нет ни в standalone-форме, ни в shared-connection bundle'е,
|
||||
* где несколько store'ов прогоняют миграцию одной и той же схемы по очереди.
|
||||
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
|
||||
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
|
||||
* прогоняют миграцию одной и той же схемы по очереди.
|
||||
*
|
||||
* Prepared statements (insert / list / clear) препарируются один раз в
|
||||
* Prepared statements (insert / list / clear / count) препарируются один раз в
|
||||
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
|
||||
* GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
|
||||
* parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
|
||||
* (см. [pw.binom.db.ksqlite.StmtHolder]).
|
||||
*
|
||||
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
|
||||
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
|
||||
* в [MessageCodecs.kt] рядом.
|
||||
*
|
||||
* ## Курсор ([MessageRecord.seq])
|
||||
*
|
||||
* [list] с диапазоном `[afterSeq] < seq <= [upToSeq]` — keyset-пагинация,
|
||||
* а не `OFFSET`: конкурентная вставка/удаление сдвигает OFFSET-окно и молча
|
||||
* теряет строки. Индекс `idx_msg_conv_seq` покрывает hot-path.
|
||||
* Адресация — по `created_at` (см. [JournalStore]). Курсор/`seq` здесь
|
||||
* отсутствуют: монотонный порядок для удалённого доступа держит sync-слой.
|
||||
*/
|
||||
class KsqliteJournalStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
@@ -88,14 +81,14 @@ class KsqliteJournalStore private constructor(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_MESSAGE}
|
||||
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ})
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
)
|
||||
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ}
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||
FROM ${Schema.TABLE_MESSAGE}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
AND ${Schema.COL_CREATED_AT} > ?
|
||||
@@ -103,18 +96,6 @@ class KsqliteJournalStore private constructor(
|
||||
LIMIT ? OFFSET ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val listSeqStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ}
|
||||
FROM ${Schema.TABLE_MESSAGE}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
AND ${Schema.COL_SEQ} > ?
|
||||
AND ${Schema.COL_SEQ} <= ?
|
||||
ORDER BY ${Schema.COL_SEQ} ASC
|
||||
LIMIT ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||
)
|
||||
@@ -128,13 +109,6 @@ class KsqliteJournalStore private constructor(
|
||||
AND ${Schema.COL_CREATED_AT} > ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val countAfterSeqStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
AND ${Schema.COL_SEQ} > ?
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
||||
val (kind, payload) = encodeRecord(record)
|
||||
@@ -146,7 +120,6 @@ class KsqliteJournalStore private constructor(
|
||||
insertStmt.bindText(3, kind)
|
||||
insertStmt.bindText(4, payload)
|
||||
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
|
||||
insertStmt.bindLong(6, record.seq)
|
||||
insertStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
@@ -174,29 +147,6 @@ class KsqliteJournalStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
afterSeq: Long,
|
||||
upToSeq: Long,
|
||||
limit: Int,
|
||||
): List<MessageRecord> = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
listSeqStmt.reset()
|
||||
listSeqStmt.clearBindings()
|
||||
listSeqStmt.bindText(1, conversationId)
|
||||
listSeqStmt.bindLong(2, afterSeq)
|
||||
listSeqStmt.bindLong(3, upToSeq)
|
||||
listSeqStmt.bindLong(4, limit.toLong())
|
||||
val out = mutableListOf<MessageRecord>()
|
||||
listSeqStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) {
|
||||
out.add(rs.toMessageRecord(json))
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
clearStmt.reset()
|
||||
@@ -231,27 +181,12 @@ class KsqliteJournalStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun count(conversationId: String, afterSeq: Long): Long = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
countAfterSeqStmt.reset()
|
||||
countAfterSeqStmt.clearBindings()
|
||||
countAfterSeqStmt.bindText(1, conversationId)
|
||||
countAfterSeqStmt.bindLong(2, afterSeq)
|
||||
countAfterSeqStmt.executeQuery().use { rs ->
|
||||
check(rs.next()) { "COUNT(*) must return at least one row" }
|
||||
(rs.getLong(0) ?: 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
insertStmt.close()
|
||||
listStmt.close()
|
||||
listSeqStmt.close()
|
||||
clearStmt.close()
|
||||
countAllStmt.close()
|
||||
countAfterStmt.close()
|
||||
countAfterSeqStmt.close()
|
||||
if (ownsConnection) {
|
||||
connection.close()
|
||||
}
|
||||
|
||||
+5
-7
@@ -43,28 +43,26 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
val kind = getText(2)!!
|
||||
val payload = getText(3)!!
|
||||
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
|
||||
// Колонка `seq` — 6-я (индекс 5) в SELECT'ах store'а.
|
||||
val seq = getLong(5) ?: 0L
|
||||
return when (kind) {
|
||||
"user" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context, seq = seq)
|
||||
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
|
||||
}
|
||||
"assistant" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning, seq = seq)
|
||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning)
|
||||
}
|
||||
"tool_call" -> {
|
||||
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
||||
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt, seq = seq)
|
||||
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
|
||||
}
|
||||
"tool_result" -> {
|
||||
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
|
||||
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt, seq = seq)
|
||||
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
|
||||
}
|
||||
"error" -> {
|
||||
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt, seq = seq)
|
||||
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
|
||||
}
|
||||
else -> error("Unknown message kind in audit log: $kind")
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ object Schema {
|
||||
/**
|
||||
* Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL.
|
||||
*
|
||||
* v2: `message.seq` — монотонный per-agent offset (курсор снапшота),
|
||||
* синхронный с `OutboxStore`'ом. Старые БД до-мигрируются через
|
||||
* `ALTER TABLE ... ADD COLUMN` (см. [migrate]).
|
||||
* v4: из `message` убраны колонки курсора (`cursor_created_at` /
|
||||
* `cursor_offset`) и индекс `idx_msg_conv_cursor`. Позиция в потоке —
|
||||
* только `created_at`; монотонный курсор для удалённого доступа живёт в
|
||||
* sync-слое. Существующие БД сохраняют legacy-колонки как неиспользуемые
|
||||
* (в DDL они больше не описываются).
|
||||
*/
|
||||
const val CURRENT_VERSION: Int = 2
|
||||
const val CURRENT_VERSION: Int = 4
|
||||
|
||||
// ───── Таблицы ─────
|
||||
const val TABLE_CONVERSATION = "conversation"
|
||||
@@ -40,16 +42,13 @@ object Schema {
|
||||
const val COL_CONVERSATION_ID = "conversation_id"
|
||||
const val COL_KIND = "kind"
|
||||
const val COL_PAYLOAD_JSON = "payload_json"
|
||||
/** Монотонный per-agent offset записи (см. `OffsetSequencer`). */
|
||||
const val COL_SEQ = "seq"
|
||||
|
||||
// ───── Индексы ─────
|
||||
const val IDX_CONV_UPDATED = "idx_conv_updated"
|
||||
/** Hot-path `list(convId, after, offset, limit)` по времени. */
|
||||
const val IDX_MSG_CONV = "idx_msg_conv"
|
||||
/** Keyset-индекс для `list(convId, afterSeq, upToSeq, limit)`. */
|
||||
const val IDX_MSG_CONV_SEQ = "idx_msg_conv_seq"
|
||||
|
||||
private val v1ConversationDdl = """
|
||||
private val conversationDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_TITLE TEXT,
|
||||
@@ -59,28 +58,22 @@ object Schema {
|
||||
);
|
||||
"""
|
||||
|
||||
private val v2MessageDdl = """
|
||||
private val messageDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||
$COL_KIND TEXT NOT NULL,
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL,
|
||||
$COL_SEQ INTEGER NOT NULL DEFAULT 0
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||
$COL_KIND TEXT NOT NULL,
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
private val v2IndexesDdl = """
|
||||
private val indexesDdl = """
|
||||
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
|
||||
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
|
||||
|
||||
-- Legacy hot-path (по времени): list() по createdAt.
|
||||
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
|
||||
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
|
||||
|
||||
-- Cursor hot-path: keyset-пагинация по seq.
|
||||
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV_SEQ
|
||||
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_SEQ);
|
||||
"""
|
||||
|
||||
/**
|
||||
@@ -88,45 +81,26 @@ object Schema {
|
||||
*
|
||||
* Гарантии:
|
||||
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
|
||||
* уже-мигрированной БД; `ADD COLUMN` гейтится проверкой `PRAGMA table_info`;
|
||||
* уже-мигрированной БД;
|
||||
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
||||
* ROLLBACK оставит БД консистентной.
|
||||
*
|
||||
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
|
||||
* user_version как gate перестал работать — два модуля ставят его в 1,
|
||||
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||
** NOTE**: в сплит-мире (несколько ksqlite-модулей, каждый владеет своей
|
||||
* таблицей) user_version как gate перестал работать — два модуля ставят его
|
||||
* в 1, второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||
* idempotently; координация multi-module миграций — ответственность
|
||||
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
|
||||
*/
|
||||
fun migrate(conn: SQLiteConnection) {
|
||||
conn.exec("BEGIN")
|
||||
try {
|
||||
conn.exec(v1ConversationDdl)
|
||||
conn.exec(v2MessageDdl)
|
||||
// Старая БД (v1) не получит `seq` от CREATE IF NOT EXISTS —
|
||||
// добавляем колонку, если её ещё нет.
|
||||
if (!columnExists(conn, TABLE_MESSAGE, COL_SEQ)) {
|
||||
conn.exec(
|
||||
"ALTER TABLE $TABLE_MESSAGE ADD COLUMN $COL_SEQ INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
conn.exec(v2IndexesDdl)
|
||||
conn.exec(conversationDdl)
|
||||
conn.exec(messageDdl)
|
||||
conn.exec(indexesDdl)
|
||||
conn.exec("COMMIT")
|
||||
} catch (t: Throwable) {
|
||||
runCatching { conn.exec("ROLLBACK") }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun columnExists(conn: SQLiteConnection, table: String, column: String): Boolean {
|
||||
conn.prepare("PRAGMA table_info($table)").use { stmt ->
|
||||
stmt.executeQuery().use { rs ->
|
||||
// PRAGMA table_info: (cid, name, type, notnull, dflt_value, pk)
|
||||
while (rs.next()) {
|
||||
if (rs.getText(1) == column) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// Полный набор KMP-целей. Зеркалит :proto.
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
api(libs.kotlinx.serialization.protobuf)
|
||||
api(libs.ksqlite)
|
||||
api(project(":sync"))
|
||||
api(project(":content-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package pw.binom
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import pw.binom.db.ksqlite.transaction
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Serializable
|
||||
sealed interface MessageRecord {
|
||||
|
||||
@Serializable
|
||||
data class Conversation(val id: String, val title: String?) : MessageRecord
|
||||
|
||||
interface Message {
|
||||
val id: String
|
||||
val date: Instant
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UserMessage(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
val content: List<Content>,
|
||||
val conversationId: String,
|
||||
) : MessageRecord, Message
|
||||
|
||||
@Serializable
|
||||
data class AssistantMessage(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
val conversationId: String,
|
||||
val content: List<Content>,
|
||||
) : MessageRecord, Message
|
||||
|
||||
@Serializable
|
||||
data class ToolCall(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
val conversationId: String,
|
||||
val name: String,
|
||||
val args: String,
|
||||
) : MessageRecord, Message
|
||||
|
||||
@Serializable
|
||||
data class ToolResponse(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
val conversationId: String,
|
||||
val callId: String,
|
||||
val response: String,
|
||||
) : MessageRecord, Message
|
||||
|
||||
@Serializable
|
||||
data class ToolFail(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
val conversationId: String,
|
||||
val callId: String,
|
||||
val response: String,
|
||||
) : MessageRecord, Message
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface MessageEvent {
|
||||
|
||||
interface Conversation {
|
||||
val conversationId: String
|
||||
}
|
||||
|
||||
interface Message {
|
||||
val id: String
|
||||
val date: Instant
|
||||
val conversationId: String
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class CreateConversation(override val conversationId: String, val title: String?) : MessageEvent, Conversation
|
||||
|
||||
@Serializable
|
||||
class DeleteConversation(override val conversationId: String) : MessageEvent, Conversation
|
||||
|
||||
@Serializable
|
||||
class RenameConversation(override val conversationId: String, val title: String?) : MessageEvent, Conversation
|
||||
|
||||
@Serializable
|
||||
class AddUserMessage(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
override val conversationId: String,
|
||||
val content: List<Content>,
|
||||
) : MessageEvent, Message
|
||||
|
||||
@Serializable
|
||||
class AddAssistantMessage(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
override val conversationId: String,
|
||||
val content: List<Content>,
|
||||
) : MessageEvent, Message
|
||||
|
||||
@Serializable
|
||||
class AddToolCall(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
override val conversationId: String,
|
||||
val name: String,
|
||||
val args: String,
|
||||
) : MessageEvent, Message
|
||||
|
||||
@Serializable
|
||||
class AddToolResponse(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
override val conversationId: String,
|
||||
val callId: String,
|
||||
val response: String,
|
||||
) : MessageEvent, Message
|
||||
|
||||
@Serializable
|
||||
class AddToolFail(
|
||||
override val id: String,
|
||||
override val date: Instant,
|
||||
override val conversationId: String,
|
||||
val callId: String,
|
||||
val response: String,
|
||||
) : MessageEvent, Message
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальное хранилище сообщений: журнал событий [MessageEvent] плюс материализованные таблицы
|
||||
* `conversations` и `messages`, из которых читается состояние через [getState].
|
||||
*
|
||||
* Модель состояния — **last-write-cursor**: каждая строка проекции хранит курсор последнего
|
||||
* события, которое её изменило (create/rename для беседы; add для сообщения). [getState] с
|
||||
* курсором `C` отдаёт строки, которых не касались после `C`, — то есть строка, изменённая позже
|
||||
* `C`, выпадает целиком. Это не «замороженный снимок значений», но после применения всех событий
|
||||
* до `C` состояние сходится, поэтому два реплики, применившие один и тот же префикс журнала,
|
||||
* получают одинаковое состояние.
|
||||
*
|
||||
* Инварианты:
|
||||
* - `push` применяет событие к проекции и дозаписывает его в журнал **в одной транзакции**;
|
||||
* - курсор строго возрастает (наследуется от [KSqliteMutableEventStore]) — повторный `push` с тем же
|
||||
* или меньшим курсором бросает [IllegalArgumentException] и откатывает проекцию;
|
||||
* - удаление беседы каскадно удаляет её сообщения из проекции (события в журнале остаются).
|
||||
*
|
||||
* **Не потокобезопасно.** Общие prepared statements и одно соединение — работать строго в одном
|
||||
* потоке. Коллекции [getState] готовят собственные statements, поэтому безопасны относительно
|
||||
* [push]/[clear], но НЕ относительно [close] во время активной коллекции.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class MessageStore(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
) : MutableStorage<Flow<MessageRecord>, MessageEvent> {
|
||||
|
||||
private val events = KSqliteMutableEventStore(
|
||||
connection = connection,
|
||||
tableName = "events",
|
||||
serializer = MessageEvent.serializer(),
|
||||
)
|
||||
|
||||
private val proto = ProtoBuf
|
||||
|
||||
init {
|
||||
connection.transaction {
|
||||
connection.exec(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
ID TEXT NOT NULL PRIMARY KEY,
|
||||
CREATED_AT INTEGER NOT NULL,
|
||||
OFFSET INTEGER NOT NULL,
|
||||
PAYLOAD BLOB NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
connection.exec(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
ID TEXT NOT NULL PRIMARY KEY,
|
||||
CONVERSATION_ID TEXT NOT NULL,
|
||||
CREATED_AT INTEGER NOT NULL,
|
||||
OFFSET INTEGER NOT NULL,
|
||||
PAYLOAD BLOB NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
connection.exec(
|
||||
"CREATE INDEX IF NOT EXISTS messages_by_conversation ON messages (CONVERSATION_ID)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val upsertConversationStatement = connection.prepare(
|
||||
"insert or replace into conversations (ID, CREATED_AT, OFFSET, PAYLOAD) values (?,?,?,?)"
|
||||
)
|
||||
|
||||
private val deleteConversationStatement = connection.prepare(
|
||||
"delete from conversations where ID = ?"
|
||||
)
|
||||
|
||||
private val deleteMessagesByConversationStatement = connection.prepare(
|
||||
"delete from messages where CONVERSATION_ID = ?"
|
||||
)
|
||||
|
||||
private val upsertMessageStatement = connection.prepare(
|
||||
"""
|
||||
insert or replace into messages (ID, CONVERSATION_ID, CREATED_AT, OFFSET, PAYLOAD)
|
||||
values (?,?,?,?,?)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
override fun push(event: MessageEvent, cursor: Cursor) {
|
||||
connection.transaction {
|
||||
when (event) {
|
||||
is MessageEvent.CreateConversation ->
|
||||
upsertConversation(
|
||||
event.conversationId, cursor,
|
||||
MessageRecord.Conversation(event.conversationId, event.title),
|
||||
)
|
||||
|
||||
is MessageEvent.RenameConversation ->
|
||||
upsertConversation(
|
||||
event.conversationId, cursor,
|
||||
MessageRecord.Conversation(event.conversationId, event.title),
|
||||
)
|
||||
|
||||
is MessageEvent.DeleteConversation -> {
|
||||
deleteMessagesByConversationStatement.bindText(1, event.conversationId)
|
||||
deleteMessagesByConversationStatement.executeUpdate()
|
||||
deleteConversationStatement.bindText(1, event.conversationId)
|
||||
deleteConversationStatement.executeUpdate()
|
||||
}
|
||||
|
||||
is MessageEvent.AddUserMessage ->
|
||||
upsertMessage(
|
||||
event.id, event.conversationId, cursor,
|
||||
MessageRecord.UserMessage(event.id, event.date, event.content, event.conversationId),
|
||||
)
|
||||
|
||||
is MessageEvent.AddAssistantMessage ->
|
||||
upsertMessage(
|
||||
event.id, event.conversationId, cursor,
|
||||
MessageRecord.AssistantMessage(event.id, event.date, event.conversationId, event.content),
|
||||
)
|
||||
|
||||
is MessageEvent.AddToolCall ->
|
||||
upsertMessage(
|
||||
event.id, event.conversationId, cursor,
|
||||
MessageRecord.ToolCall(event.id, event.date, event.conversationId, event.name, event.args),
|
||||
)
|
||||
|
||||
is MessageEvent.AddToolResponse ->
|
||||
upsertMessage(
|
||||
event.id, event.conversationId, cursor,
|
||||
MessageRecord.ToolResponse(
|
||||
event.id, event.date, event.conversationId, event.callId, event.response,
|
||||
),
|
||||
)
|
||||
|
||||
is MessageEvent.AddToolFail ->
|
||||
upsertMessage(
|
||||
event.id, event.conversationId, cursor,
|
||||
MessageRecord.ToolFail(
|
||||
event.id, event.date, event.conversationId, event.callId, event.response,
|
||||
),
|
||||
)
|
||||
}
|
||||
events.push(event, cursor)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
connection.transaction {
|
||||
events.clear()
|
||||
connection.exec("delete from conversations")
|
||||
connection.exec("delete from messages")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState(cursor: Cursor): Flow<MessageRecord> = flow {
|
||||
// Statements готовятся на каждую коллекцию: две одновременные коллекции не мешают друг
|
||||
// другу, а закрытие стора во время коллекции — единственный оставшийся небезопасный сценарий.
|
||||
val conversationsStatement = connection.prepare(SELECT_CONVERSATIONS)
|
||||
val messagesStatement = connection.prepare(SELECT_MESSAGES)
|
||||
try {
|
||||
val conversations = RecordSource(conversationsStatement, cursor)
|
||||
val messages = RecordSource(messagesStatement, cursor)
|
||||
while (true) {
|
||||
val a = conversations.peek()
|
||||
val b = messages.peek()
|
||||
if (a == null && b == null) break
|
||||
val takeConversation = b == null || (a != null && a.first <= b.first)
|
||||
val payload = if (takeConversation) conversations.poll()!!.second else messages.poll()!!.second
|
||||
emit(proto.decodeFromByteArray(MessageRecord.serializer(), payload))
|
||||
}
|
||||
} finally {
|
||||
conversationsStatement.close()
|
||||
messagesStatement.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
upsertConversationStatement.close()
|
||||
deleteConversationStatement.close()
|
||||
deleteMessagesByConversationStatement.close()
|
||||
upsertMessageStatement.close()
|
||||
events.close()
|
||||
if (ownsConnection) {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
override val minCursor: Cursor?
|
||||
get() = events.minCursor
|
||||
|
||||
override val maxCursor: Cursor?
|
||||
get() = events.maxCursor
|
||||
|
||||
override fun get(cursor: Cursor): EventSeqResult<MessageEvent> = events.get(cursor)
|
||||
|
||||
private fun upsertConversation(id: String, cursor: Cursor, record: MessageRecord.Conversation) {
|
||||
upsertConversationStatement.bindText(1, id)
|
||||
upsertConversationStatement.bindLong(2, cursor.createdAt)
|
||||
upsertConversationStatement.bindLong(3, cursor.offset.toLong())
|
||||
upsertConversationStatement.bindBlob(4, proto.encodeToByteArray(MessageRecord.serializer(), record))
|
||||
upsertConversationStatement.executeUpdate()
|
||||
}
|
||||
|
||||
private fun upsertMessage(id: String, conversationId: String, cursor: Cursor, record: MessageRecord) {
|
||||
upsertMessageStatement.bindText(1, id)
|
||||
upsertMessageStatement.bindText(2, conversationId)
|
||||
upsertMessageStatement.bindLong(3, cursor.createdAt)
|
||||
upsertMessageStatement.bindLong(4, cursor.offset.toLong())
|
||||
upsertMessageStatement.bindBlob(5, proto.encodeToByteArray(MessageRecord.serializer(), record))
|
||||
upsertMessageStatement.executeUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Источник строк одной таблицы с keyset-пагинацией: отдаёт строки в порядке
|
||||
* `(CREATED_AT, OFFSET)` не позже [upper] и подгружает следующую страницу по мере расхода
|
||||
* буфера.
|
||||
*/
|
||||
private class RecordSource(
|
||||
private val statement: SQLitePreparedStatement,
|
||||
private val upper: Cursor,
|
||||
) {
|
||||
private val buffer = ArrayDeque<Pair<Cursor, ByteArray>>()
|
||||
private var after: Cursor? = null
|
||||
private var exhausted = false
|
||||
|
||||
fun peek(): Pair<Cursor, ByteArray>? {
|
||||
if (buffer.isEmpty() && !exhausted) load()
|
||||
return buffer.firstOrNull()
|
||||
}
|
||||
|
||||
fun poll(): Pair<Cursor, ByteArray>? {
|
||||
val row = peek() ?: return null
|
||||
buffer.removeFirst()
|
||||
after = row.first
|
||||
return row
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val from = after ?: FIRST
|
||||
statement.bindLong(1, upper.createdAt)
|
||||
statement.bindLong(2, upper.createdAt)
|
||||
statement.bindLong(3, upper.offset.toLong())
|
||||
statement.bindLong(4, from.createdAt)
|
||||
statement.bindLong(5, from.createdAt)
|
||||
statement.bindLong(6, from.offset.toLong())
|
||||
statement.bindLong(7, PAGE_SIZE.toLong())
|
||||
var count = 0
|
||||
statement.executeQuery().use { rs ->
|
||||
while (rs.next()) {
|
||||
buffer.addLast(
|
||||
Cursor(rs.getLong(0)!!, rs.getLong(1)!!.toULong()) to rs.getBlob(2)!!
|
||||
)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if (count < PAGE_SIZE) exhausted = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FIRST = Cursor(createdAt = Long.MIN_VALUE, offset = 0uL)
|
||||
private const val PAGE_SIZE = 100
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val SELECT_CONVERSATIONS = """
|
||||
select CREATED_AT, OFFSET, PAYLOAD from conversations
|
||||
where (CREATED_AT < ? or (CREATED_AT = ? and OFFSET <= ?))
|
||||
and (CREATED_AT > ? or (CREATED_AT = ? and OFFSET > ?))
|
||||
order by CREATED_AT, OFFSET
|
||||
limit ?
|
||||
""".trimIndent()
|
||||
|
||||
private val SELECT_MESSAGES = """
|
||||
select CREATED_AT, OFFSET, PAYLOAD from messages
|
||||
where (CREATED_AT < ? or (CREATED_AT = ? and OFFSET <= ?))
|
||||
and (CREATED_AT > ? or (CREATED_AT = ? and OFFSET > ?))
|
||||
order by CREATED_AT, OFFSET
|
||||
limit ?
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package pw.binom
|
||||
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val EPOCH = 1_700_000_000_000L
|
||||
|
||||
/**
|
||||
* Полный набор тестов [MessageStore] на in-memory SQLite: все ветки push, проекция,
|
||||
* курсорная фильтрация, слияние двух таблиц, пагинация, каскадное удаление, откат транзакции,
|
||||
* границы журнала и round-trip содержимого.
|
||||
*/
|
||||
class MessageStoreTest {
|
||||
|
||||
private var counter = 0UL
|
||||
|
||||
private val t0 = Instant.fromEpochMilliseconds(EPOCH)
|
||||
|
||||
private fun nextCursor() = Cursor(createdAt = EPOCH, offset = ++counter)
|
||||
|
||||
private fun cursorAt(offset: ULong) = Cursor(createdAt = EPOCH, offset = offset)
|
||||
|
||||
private suspend fun withStore(block: suspend (MessageStore) -> Unit) {
|
||||
val conn = SQLiteConnection.memory("message-store-${kotlin.random.Random.nextLong()}")
|
||||
val store = MessageStore(connection = conn, ownsConnection = false)
|
||||
try {
|
||||
block(store)
|
||||
} finally {
|
||||
store.close()
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- пустое хранилище -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun emptyStoreHasNoStateAndEmptyStreams() = runTest {
|
||||
withStore { store ->
|
||||
assertNull(store.minCursor)
|
||||
assertNull(store.maxCursor)
|
||||
assertTrue(store.getState(nextCursor()).toList().isEmpty())
|
||||
assertTrue(store.get(nextCursor()).getOrException().toList().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearOnEmptyStoreIsNoop() = runTest {
|
||||
withStore { store ->
|
||||
store.clear()
|
||||
assertNull(store.minCursor)
|
||||
assertNull(store.maxCursor)
|
||||
assertTrue(store.getState(nextCursor()).toList().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- курсоры ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun minAndMaxCursorTrackPushes() = runTest {
|
||||
withStore { store ->
|
||||
assertNull(store.minCursor)
|
||||
assertNull(store.maxCursor)
|
||||
|
||||
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(3u))
|
||||
assertEquals(cursorAt(3u), store.minCursor)
|
||||
assertEquals(cursorAt(3u), store.maxCursor)
|
||||
|
||||
store.push(MessageEvent.RenameConversation("c", "T2"), cursorAt(7u))
|
||||
assertEquals(cursorAt(3u), store.minCursor)
|
||||
assertEquals(cursorAt(7u), store.maxCursor)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pushRejectsNonIncreasingCursorAndRollsBackProjection() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("a"))), cursorAt(5u))
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
store.push(
|
||||
MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("b"))),
|
||||
cursorAt(5u),
|
||||
)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
store.push(
|
||||
MessageEvent.AddUserMessage("m3", t0, "c", listOf(Content.Text("c"))),
|
||||
cursorAt(4u),
|
||||
)
|
||||
}
|
||||
|
||||
// Проекция откатилась: только первое сообщение, курсор на месте.
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(MessageRecord.UserMessage("m1", t0, listOf(Content.Text("a")), "c")),
|
||||
store.getState(cursorAt(5u)).toList(),
|
||||
)
|
||||
assertEquals(cursorAt(5u), store.maxCursor)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearResetsCursorSoLowerCursorAccepted() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(9u))
|
||||
store.clear()
|
||||
|
||||
assertNull(store.minCursor)
|
||||
assertNull(store.maxCursor)
|
||||
|
||||
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(1u))
|
||||
assertEquals(cursorAt(1u), store.minCursor)
|
||||
assertEquals(cursorAt(1u), store.maxCursor)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- беседа: create / rename / delete --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun conversationLifecycleUpdatesCursorOnChange() = runTest {
|
||||
withStore { store ->
|
||||
val created = nextCursor()
|
||||
store.push(MessageEvent.CreateConversation("c1", "First"), created)
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(MessageRecord.Conversation("c1", "First")),
|
||||
store.getState(created).toList(),
|
||||
)
|
||||
|
||||
val renamed = nextCursor()
|
||||
store.push(MessageEvent.RenameConversation("c1", "Renamed"), renamed)
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(MessageRecord.Conversation("c1", "Renamed")),
|
||||
store.getState(renamed).toList(),
|
||||
)
|
||||
// Изменённая позже строка выпадает из состояния на более раннем курсоре.
|
||||
assertTrue(store.getState(created).toList().isEmpty())
|
||||
|
||||
val deleted = nextCursor()
|
||||
store.push(MessageEvent.DeleteConversation("c1"), deleted)
|
||||
assertTrue(store.getState(deleted).toList().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingConversationRemovesItsMessages() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("1"))), nextCursor())
|
||||
store.push(MessageEvent.CreateConversation("b", "B"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m2", t0, "b", listOf(Content.Text("2"))), nextCursor())
|
||||
store.push(MessageEvent.DeleteConversation("a"), nextCursor())
|
||||
|
||||
// Сообщения удалённой беседы не остаются сиротами в состоянии.
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(
|
||||
MessageRecord.Conversation("b", "B"),
|
||||
MessageRecord.UserMessage("m2", t0, listOf(Content.Text("2")), "b"),
|
||||
),
|
||||
store.getState(store.maxCursor!!).toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingConversationKeepsJournalEvents() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("1"))), nextCursor())
|
||||
store.push(MessageEvent.DeleteConversation("a"), nextCursor())
|
||||
|
||||
// Журнал append-only: удаление проекции не трогает историю событий.
|
||||
val events = store.get(cursorAt(1u)).getOrException().toList()
|
||||
assertEquals(3, events.size)
|
||||
assertTrue(events.last().data is MessageEvent.DeleteConversation)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingUnknownConversationIsNoop() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.DeleteConversation("nope"), nextCursor())
|
||||
assertTrue(store.getState(store.maxCursor!!).toList().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- сообщения: все типы + содержимое --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun allMessageTypesInCursorOrder() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), nextCursor())
|
||||
store.push(MessageEvent.AddAssistantMessage("m2", t0, "c", listOf(Content.Text("yo"))), nextCursor())
|
||||
store.push(MessageEvent.AddToolCall("m3", t0, "c", "search", "{}"), nextCursor())
|
||||
store.push(MessageEvent.AddToolResponse("m4", t0, "c", "m3", "ok"), nextCursor())
|
||||
store.push(MessageEvent.AddToolFail("m5", t0, "c", "m3", "err"), nextCursor())
|
||||
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(
|
||||
MessageRecord.Conversation("c", "T"),
|
||||
MessageRecord.UserMessage("m1", t0, listOf(Content.Text("hi")), "c"),
|
||||
MessageRecord.AssistantMessage("m2", t0, "c", listOf(Content.Text("yo"))),
|
||||
MessageRecord.ToolCall("m3", t0, "c", "search", "{}"),
|
||||
MessageRecord.ToolResponse("m4", t0, "c", "m3", "ok"),
|
||||
MessageRecord.ToolFail("m5", t0, "c", "m3", "err"),
|
||||
),
|
||||
store.getState(store.maxCursor!!).toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageContentRoundTrips() = runTest {
|
||||
withStore { store ->
|
||||
val image = Content.Image(byteArrayOf(1, 2, 3, -1, 0, 7), "image/png")
|
||||
store.push(
|
||||
MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("see"), image)),
|
||||
nextCursor(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(
|
||||
MessageRecord.UserMessage("m", t0, listOf(Content.Text("see"), image), "c"),
|
||||
),
|
||||
store.getState(store.maxCursor!!).toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateMessageIdIsReplacedAtLaterCursor() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("v1"))), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("v2"))), nextCursor())
|
||||
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(MessageRecord.UserMessage("m", t0, listOf(Content.Text("v2")), "c")),
|
||||
store.getState(store.maxCursor!!).toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- состояние: фильтр по курсору и слияние --------------------------------------------
|
||||
|
||||
@Test
|
||||
fun stateIsFilteredByCursor() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("1"))), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("2"))), nextCursor())
|
||||
|
||||
assertEquals(3, store.getState(cursorAt(3u)).toList().size)
|
||||
assertEquals(2, store.getState(cursorAt(2u)).toList().size)
|
||||
assertTrue(store.getState(cursorAt(0u)).toList().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateCursorAboveMaxReturnsEverything() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("x"))), nextCursor())
|
||||
|
||||
assertEquals(2, store.getState(cursorAt(999u)).toList().size)
|
||||
|
||||
// Курсор из другой (более старой) эпохи не видит строк текущей.
|
||||
assertTrue(store.getState(Cursor(EPOCH - 1, 999u)).toList().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interleavedTablesMergeInCursorOrder() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("x"))), nextCursor())
|
||||
store.push(MessageEvent.CreateConversation("b", "B"), nextCursor())
|
||||
store.push(MessageEvent.AddAssistantMessage("m2", t0, "b", listOf(Content.Text("y"))), nextCursor())
|
||||
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(
|
||||
MessageRecord.Conversation("a", "A"),
|
||||
MessageRecord.UserMessage("m1", t0, listOf(Content.Text("x")), "a"),
|
||||
MessageRecord.Conversation("b", "B"),
|
||||
MessageRecord.AssistantMessage("m2", t0, "b", listOf(Content.Text("y"))),
|
||||
),
|
||||
store.getState(store.maxCursor!!).toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- пагинация -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun getStatePaginatesAcrossPageBoundary() = runTest {
|
||||
withStore { store ->
|
||||
repeat(100) { i ->
|
||||
store.push(MessageEvent.CreateConversation("c$i", "T$i"), nextCursor())
|
||||
}
|
||||
assertEquals(100, store.getState(store.maxCursor!!).toList().size)
|
||||
|
||||
store.push(MessageEvent.CreateConversation("c100", "T100"), nextCursor())
|
||||
assertEquals(101, store.getState(store.maxCursor!!).toList().size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interleavedTablesMergeAcrossPages() = runTest {
|
||||
withStore { store ->
|
||||
repeat(120) { i ->
|
||||
store.push(MessageEvent.CreateConversation("c$i", "T$i"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m$i", t0, "c$i", listOf(Content.Text("$i"))), nextCursor())
|
||||
}
|
||||
|
||||
val records = store.getState(store.maxCursor!!).toList()
|
||||
assertEquals(240, records.size)
|
||||
for (i in 0 until 120) {
|
||||
assertEquals(
|
||||
MessageRecord.Conversation("c$i", "T$i"),
|
||||
records[i * 2],
|
||||
)
|
||||
assertEquals(
|
||||
MessageRecord.UserMessage("m$i", t0, listOf(Content.Text("$i")), "c$i"),
|
||||
records[i * 2 + 1],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- журнал: get / expired -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun journalKeepsEventsInOrder() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(1u))
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), cursorAt(2u))
|
||||
|
||||
val events = store.get(cursorAt(1u)).getOrException().toList()
|
||||
|
||||
assertEquals(listOf(1uL, 2uL), events.map { it.seq.offset })
|
||||
assertTrue(events[0].data is MessageEvent.CreateConversation)
|
||||
assertTrue(events[1].data is MessageEvent.AddUserMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun journalGetIsInclusiveAtMinAndExpiredBelow() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), cursorAt(5u))
|
||||
|
||||
val atMin = store.get(cursorAt(5u)).getOrException().toList()
|
||||
assertEquals(1, atMin.size)
|
||||
assertEquals(5uL, atMin[0].seq.offset)
|
||||
|
||||
assertTrue(store.get(cursorAt(1u)).isExpired)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- clear -----------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun clearRemovesStateAndJournal() = runTest {
|
||||
withStore { store ->
|
||||
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
|
||||
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), nextCursor())
|
||||
|
||||
store.clear()
|
||||
|
||||
assertNull(store.minCursor)
|
||||
assertNull(store.maxCursor)
|
||||
assertTrue(store.getState(nextCursor()).toList().isEmpty())
|
||||
assertTrue(store.get(nextCursor()).getOrException().toList().isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package pw.binom
|
||||
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLiteException
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val FILE_EPOCH = 1_700_000_000_000L
|
||||
|
||||
/**
|
||||
* JVM-тесты [MessageStore] на файловой БД: durable-сохранение между открытиями и владение
|
||||
* соединением.
|
||||
*/
|
||||
class MessageStoreFileTest {
|
||||
|
||||
@Test
|
||||
fun persistsStateAndCursorsAcrossReopen() = runTest {
|
||||
val dir = Files.createTempDirectory("message-store")
|
||||
val path = dir.resolve("db.sqlite").toString()
|
||||
val t0 = Instant.fromEpochMilliseconds(FILE_EPOCH)
|
||||
try {
|
||||
val conn1 = SQLiteConnection.open(path)
|
||||
val store1 = MessageStore(connection = conn1, ownsConnection = true)
|
||||
store1.push(MessageEvent.CreateConversation("c", "T"), Cursor(FILE_EPOCH, 1uL))
|
||||
store1.push(
|
||||
MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("hi"))),
|
||||
Cursor(FILE_EPOCH, 2uL),
|
||||
)
|
||||
store1.close()
|
||||
|
||||
val conn2 = SQLiteConnection.open(path)
|
||||
val store2 = MessageStore(connection = conn2, ownsConnection = true)
|
||||
try {
|
||||
assertEquals(
|
||||
listOf<MessageRecord>(
|
||||
MessageRecord.Conversation("c", "T"),
|
||||
MessageRecord.UserMessage("m", t0, listOf(Content.Text("hi")), "c"),
|
||||
),
|
||||
store2.getState(Cursor(FILE_EPOCH, 2uL)).toList(),
|
||||
)
|
||||
assertEquals(Cursor(FILE_EPOCH, 1uL), store2.minCursor)
|
||||
assertEquals(Cursor(FILE_EPOCH, 2uL), store2.maxCursor)
|
||||
assertEquals(2, store2.get(Cursor(FILE_EPOCH, 1uL)).getOrException().toList().size)
|
||||
} finally {
|
||||
store2.close()
|
||||
}
|
||||
} finally {
|
||||
dir.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ownsConnectionTrueClosesConnection() {
|
||||
val dir = Files.createTempDirectory("message-store-owned")
|
||||
val path = dir.resolve("db.sqlite").toString()
|
||||
try {
|
||||
val conn = SQLiteConnection.open(path)
|
||||
val store = MessageStore(connection = conn, ownsConnection = true)
|
||||
store.close()
|
||||
assertFailsWith<SQLiteException> { conn.prepare("select 1") }
|
||||
} finally {
|
||||
dir.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ownsConnectionFalseLeavesConnectionOpen() {
|
||||
val dir = Files.createTempDirectory("message-store-borrowed")
|
||||
val path = dir.resolve("db.sqlite").toString()
|
||||
try {
|
||||
val conn = SQLiteConnection.open(path)
|
||||
val store = MessageStore(connection = conn, ownsConnection = false)
|
||||
store.close()
|
||||
// Соединение живо и пригодно к работе.
|
||||
conn.prepare("select 1").close()
|
||||
conn.close()
|
||||
} finally {
|
||||
dir.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ kotlin {
|
||||
// здесь). Message-события несут общие типы содержимого из
|
||||
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
|
||||
api(project(":content-api"))
|
||||
api(project(":cursor-api"))
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
|
||||
@@ -12,7 +12,9 @@ import kotlinx.serialization.Serializable
|
||||
* ([AgentEvent] via `/events` и [DurableEvent] via `/conversations/{id}/events`);
|
||||
* [CommonEvent] — for those who need everything in one place.
|
||||
*
|
||||
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
|
||||
* **Live-уведомление, не носитель позиции.** [date] — момент эмиссии (UTC),
|
||||
* только для отображения/сортировки. Монотонный курсор для удалённого resume
|
||||
* живёт в sync-слое; outbox его не хранит и не выдаёт.
|
||||
*
|
||||
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.CommonEvent`;
|
||||
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
|
||||
@@ -23,23 +25,13 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface CommonEvent {
|
||||
/** Момент эмиссии в UTC. Только для отображения/сортировки — **не** курсор. */
|
||||
/** Момент эмиссии в UTC. Только для отображения/сортировки. */
|
||||
val date: Instant
|
||||
|
||||
/**
|
||||
* Монотонный per-agent offset события — **курсор** (см. [Cursor]).
|
||||
*
|
||||
* Присваивается writer'ом через [OffsetSequencer.reserve] в тот же момент,
|
||||
* что и `seq` соответствующей строки состояния (сначала строка, потом
|
||||
* событие). Клиенты оперируют [Cursor], а не [date].
|
||||
*/
|
||||
val offset: Long
|
||||
|
||||
@Serializable
|
||||
@SerialName("agent")
|
||||
data class Agent(
|
||||
override val date: Instant,
|
||||
override val offset: Long,
|
||||
val event: AgentEvent,
|
||||
) : CommonEvent
|
||||
|
||||
@@ -47,7 +39,6 @@ sealed interface CommonEvent {
|
||||
@SerialName("conversation")
|
||||
data class Conversation(
|
||||
override val date: Instant,
|
||||
override val offset: Long,
|
||||
val conversationId: String,
|
||||
val event: DurableEvent,
|
||||
) : CommonEvent
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Курсор события — **позиция в общем монотонном потоке событий агента**.
|
||||
*
|
||||
* Состоит из двух частей:
|
||||
* - [offset] — per-agent монотонный номер события (0-based). Именно он, а не
|
||||
* wall-clock [CommonEvent.date], является курсором: несколько событий могут
|
||||
* иметь одинаковый [date] (например `UserMessage` и `ToolCall` в одну
|
||||
* миллисекунду), но offset'ы всегда строго возрастают и уникальны. Фильтрация
|
||||
* `offset > after.offset` не теряет события на «ничьих» по времени.
|
||||
* - [epoch] — идентификатор «мира» счётчика. Меняется при сбросе/восстановлении
|
||||
* БД, из-за которого offset'ы теряют монотонность. Клиент хранит epoch в своём
|
||||
* курсоре; несовпадение epoch → сервер сигналит gap ([OutboxGapException]) →
|
||||
* клиент делает полный resync. Обычный **рестарт** сервера epoch НЕ меняет
|
||||
* (счётчик персистентный), поэтому клиент продолжает инкрементально.
|
||||
*
|
||||
* **Семантика подписки**: [Cursor.offset] — **эксклюзивная** граница.
|
||||
* `events(after = cursor)` отдаёт события со строго большим offset. Практически
|
||||
* клиент кладёт сюда offset последнего применённого события, либо [OutboxStore.currentCursor]
|
||||
* из снапшота.
|
||||
*
|
||||
* **Почему не `Instant` и не «id ASC»**: `Instant` лоссов при совпадении millis,
|
||||
* а `id` — случайный UUID, который не задаёт порядок записи.
|
||||
*
|
||||
* **Переполнение счётчика**: `Long` на агента неисчерпаем (≈4.6·10¹⁷ ходов при
|
||||
* 20 событиях/ход — это ~1.5·10⁷ лет при 1000 ходов/с). Заворачивать его нельзя
|
||||
* (сломает монотонность), поэтому при любом сбое, инвалидирующем счётчик
|
||||
* (сброс/восстановление БД), **ротируется [epoch]** и все клиенты делают
|
||||
* полный resync — это и есть «обработка переполнения», а не wrap.
|
||||
*/
|
||||
@Serializable
|
||||
data class Cursor(
|
||||
val epoch: String,
|
||||
val offset: Long,
|
||||
) {
|
||||
override fun toString(): String = "$epoch:$offset"
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Новый случайный [epoch] (opaque-строка). Новый epoch = «новый мир»
|
||||
* счётчика: используется при первичной инициализации персистентного
|
||||
* счётчика и при инвалидации offset-пространства — клиенты с прежним
|
||||
* курсором получат [OutboxGapException] и сделают полный resync.
|
||||
*/
|
||||
fun newEpoch(): String = Random.nextLong().toString(16).padStart(16, '0')
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Персистентное хранилище позиции счётчика — [Cursor] (`epoch` + `offset`).
|
||||
*
|
||||
* Единственный мост между [OffsetSequencer] (чистая логика монотонного
|
||||
* счётчика) и durable-носителем (`outbox-ksqlite`). Секвенсор читает позицию
|
||||
* один раз при создании и держит `epoch`/`offset` в памяти (см.
|
||||
* [OffsetSequencer] KDoc), поэтому [load] синхронный; [save] — durable
|
||||
* запись, вызывается на каждом [OffsetSequencer.reserve].
|
||||
*
|
||||
* Вызовы сериализованы самим [PersistentOffsetSequencer] (его `Mutex`),
|
||||
* так что реализация может не иметь собственной синхронизации.
|
||||
*/
|
||||
interface CursorStore {
|
||||
/**
|
||||
* Текущая позиция счётчика, или `null` если он ещё не инициализирован
|
||||
* (пустая БД / первый запуск). Для пустого хранилища [PersistentOffsetSequencer]
|
||||
* сгенерирует новый `epoch` и стартовый offset.
|
||||
*/
|
||||
fun load(): Cursor?
|
||||
|
||||
/** Записать позицию durable. */
|
||||
fun save(cursor: Cursor)
|
||||
}
|
||||
@@ -10,14 +10,14 @@ import kotlin.time.Instant
|
||||
/**
|
||||
* Элемент **durable**-потока диалога.
|
||||
*
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||
* как курсор («где остановился») при обрыве/переподключении и для разрешения
|
||||
* порядка при равных timestamps.
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC (для отображения и
|
||||
* разрешения порядка при равных timestamps).
|
||||
*
|
||||
* Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
|
||||
* вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
|
||||
* ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
|
||||
* Их можно перезапросить по курсору (`after`).
|
||||
* Durable-история для перезапроса живёт в
|
||||
* [pw.binom.agentik.journal.JournalStore].
|
||||
*
|
||||
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
|
||||
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Mutable вариант [OutboxStore] — добавляет producer-операции [reserveOffset]
|
||||
* и [append].
|
||||
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
|
||||
*
|
||||
* Предназначен **только для producer'ов** (ChatAgent, ConversationLoop,
|
||||
* ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only
|
||||
* [OutboxStore] — тогда невозможно случайно писать в store из observer'а.
|
||||
*
|
||||
* ## Контракт записи (порядок важен)
|
||||
* ## Контракт записи
|
||||
* ```
|
||||
* val n = outbox.reserveOffset() // 1. забронировать offset
|
||||
* journal.append(record.copy(seq = n)) // 2. сначала состояние
|
||||
* outbox.append(event.copy(offset = n)) // 3. потом событие
|
||||
* journal.append(record) // 1. сначала durable состояние (audit)
|
||||
* outbox.append(event) // 2. потом live-уведомление
|
||||
* ```
|
||||
* «Сначала состояние, потом событие» — инвариант, на котором держится
|
||||
* [OutboxStore.currentCursor]: к моменту, когда событие `n` появилось в
|
||||
* outbox, строка состояния со `seq = n` уже записана.
|
||||
*
|
||||
* Offset **обязан** быть выставлен в [CommonEvent.offset]; store проверяет
|
||||
* строгую монотонность и бросает [IllegalArgumentException] на нарушение.
|
||||
* Событие — это уведомление о том, что durable-факт уже записан. Позицию в
|
||||
* потоке (курсор) outbox не ведёт: монотонный порядок для удалённого
|
||||
* доступа держит sync-слой.
|
||||
*/
|
||||
interface MutableOutboxStore : OutboxStore {
|
||||
|
||||
/**
|
||||
* Забронировать следующий монотонный offset (делегирует в
|
||||
* [OffsetSequencer.reserve]). Вызывается **до** записи состояния.
|
||||
*/
|
||||
suspend fun reserveOffset(): Long
|
||||
|
||||
/**
|
||||
* Положить событие в лог. [CommonEvent.offset] должен быть уже выставлен
|
||||
* (обычно значением из [reserveOffset]).
|
||||
*
|
||||
* **Не идемпотентно** — повторный append с тем же offset'ом нарушает
|
||||
* монотонность и бросит исключение (защита от двойной записи).
|
||||
* Опубликовать событие в live-поток.
|
||||
*/
|
||||
suspend fun append(event: CommonEvent)
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Источник монотонных offset'ов **для одного агента**.
|
||||
*
|
||||
* Один счётчик на агента, сквозной по всем сущностям (conversation + message +
|
||||
* lifecycle): это даёт единый [Cursor] на всё — глобальный курсор (чат
|
||||
* появился/умер/переименован) и per-chat курсор суть просто закладки в одном
|
||||
* потоке offset'ов (как offset одного Kafka-topic'а с ключом `conversationId`).
|
||||
*
|
||||
* **Кто владеет счётчиком**: writer-сторона. В `standalone` это персистентный
|
||||
* счётчик в той же SQLite-БД, что и журнал, — иначе рестарт сервера сбросил бы
|
||||
* offset'ы, а у клиента в локальной БД остался бы старый курсор. Персистентность
|
||||
* даёт дешёвый инкрементальный resume после рестарта; полная инвалидация
|
||||
* (сброс/восстановление БД) закрывается ротацией [epoch].
|
||||
*
|
||||
* **Порядок записи (инвариант)**: сначала пишется строка состояния с
|
||||
* `seq = reserve()`, потом событие с `offset = <тот же>`. Тогда «состояние
|
||||
* с offset ≤ C» гарантированно уже записано в момент чтения снапшота с
|
||||
* курсором C, и всё, что `> C`, придёт потоком.
|
||||
*
|
||||
* **`epoch()`/`current()` — не-`suspend`**: persistent-реализация читает
|
||||
* `(epoch, counter)` один раз при создании (в конструкторе, где и так идёт
|
||||
* синхронный I/O открытия БД) и держит в памяти; на диск пишет только
|
||||
* [reserve]. Это позволяет читать курсор из любого места без корутины.
|
||||
*/
|
||||
interface OffsetSequencer {
|
||||
/**
|
||||
* Идентификатор текущей эпохи счётчика (см. [Cursor.epoch]). Стабилен между
|
||||
* рестартами, пока счётчик персистентный.
|
||||
*/
|
||||
fun epoch(): String
|
||||
|
||||
/** Следующий offset, который будет выдан [reserve] (next offset to assign). */
|
||||
fun current(): Long
|
||||
|
||||
/** Забронировать следующий offset; монотонно возрастает на 1 (durable). */
|
||||
suspend fun reserve(): Long
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
/**
|
||||
* Live-канал **онлайн-событий** ([OnlineEvent]) диалога — стриминга ответа
|
||||
@@ -27,7 +28,8 @@ interface OnlineOutbox : AutoCloseable {
|
||||
* Подписка на live-поток онлайн-событий диалога [conversationId].
|
||||
* События, эмитнутые до подписки, не приходят.
|
||||
*/
|
||||
fun onlineEvents(conversationId: String): Flow<OnlineEvent>
|
||||
fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
|
||||
onlineEvents().filter { it.conversationId == conversationId }
|
||||
|
||||
/** Освобождает ресурсы. Idempotent. */
|
||||
override fun close()
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Курсор клиента вышел за пределы retention'а outbox'а, **или** принадлежит
|
||||
* другой [Cursor.epoch].
|
||||
*
|
||||
* Это **не ошибка выполнения**, а сигнал протокола: «твой курсор мёртв — я не
|
||||
* могу отдать непрерывный поток событий, начиная с него». Клиент обязан:
|
||||
* 1. очистить/пометить свой локальный кэш как устаревший;
|
||||
* 2. запросить у сервера свежий **snapshot состояния** (он вернёт и состояние,
|
||||
* и актуальный [Cursor]);
|
||||
* 3. подписаться на события `after = <cursor из снапшота>` и накатить snapshot,
|
||||
* затем буферизованные дельты.
|
||||
*
|
||||
* Бросается **изнутри** [OutboxStore.events] / [OutboxStore.conversationEvents] /
|
||||
* [OutboxStore.agentEvents] (то есть из Flow, а не отдельной pre-check'ом) — так
|
||||
* проверка делается под тем же lock'ом, что и регистрация подписчика, и не
|
||||
* гоняется с конкурентной эвикцией.
|
||||
*
|
||||
* **Retry-политики НЕ должна этому исключению ретраить** (см.
|
||||
* `ReconnectingOutbox`): повторный connect с тем же курсором даст тот же gap и
|
||||
* превратится в бесконечный цикл. Обработка — resync, не backoff.
|
||||
*/
|
||||
class OutboxGapException(
|
||||
/** Курсор, с которого клиент просил поток (может быть `null` для live-only). */
|
||||
val requested: Cursor?,
|
||||
/** Актуальный курсор сервера (`currentCursor()`). */
|
||||
val current: Cursor,
|
||||
/** Минимальный курсор, с которого ещё можно продолжить поток (`oldestCursor()`). */
|
||||
val oldest: Cursor,
|
||||
) : RuntimeException(
|
||||
"Outbox cursor is out of retention: requested=$requested, " +
|
||||
"oldest=$oldest, current=$current. Re-snapshot the full state."
|
||||
)
|
||||
@@ -5,83 +5,49 @@ import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
|
||||
/**
|
||||
* Bounded-tail лог **durable**-событий агента.
|
||||
* Live-шина **durable**-событий агента.
|
||||
*
|
||||
* Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle
|
||||
* диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и
|
||||
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only, не
|
||||
* сохраняется и не реплеится).
|
||||
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only,
|
||||
* не сохраняется и не реплеится).
|
||||
*
|
||||
* ## Два уровня хранения
|
||||
* 1. **Этот store** — короткий bounded tail (live SSE + недавний replay),
|
||||
* эвиктится по TTL/cap (implementation-defined).
|
||||
* 2. **Журнал (`:journal-api`)** — полный audit log, никогда не эвиктится.
|
||||
* Source of truth для всего прошлого. Он и есть «полное состояние»,
|
||||
* которое запрашивает клиент при resync'е.
|
||||
* ## Роль
|
||||
* outbox — это **уведомления в моменте**: расширения агента (reflection,
|
||||
* skill mining, sub-agents) и UI подписываются на живой поток. Историю
|
||||
* («что было раньше») отдаёт [pw.binom.agentik.journal.JournalStore] — полный
|
||||
* durable audit. Всё, что нужно для *удалённого* доступа (resume с курсора,
|
||||
* снапшот + дельты, gap detection), — забота sync-слоя поверх journal/outbox,
|
||||
* а не этого интерфейса.
|
||||
*
|
||||
* ## Курсор, а не дата
|
||||
* Позиция в потоке — монотонный [Cursor] `(epoch, offset)`, а не wall-clock
|
||||
* [CommonEvent.date]. Offset уникален и упорядочен даже когда два события
|
||||
* делят одну миллисекунду. `Instant` для этого не годится (лоссов на ничьих),
|
||||
* случайный `id` — тоже (не задаёт порядок записи).
|
||||
*
|
||||
* ## Протокол клиента (гарантия «в итоге корректное состояние»)
|
||||
* ```
|
||||
* // 1. Пробуем продолжить с сохранённого курсора.
|
||||
* try {
|
||||
* outbox.conversationEvents(after = saved, conversationId = id).collect { apply(it) }
|
||||
* } catch (e: OutboxGapException) {
|
||||
* // 2. Курсор мёртв — берём свежий снапшот (состояние + его курсор).
|
||||
* val snap = agent.chatSnapshot(id) // { state, cursor }
|
||||
* clearLocal(); applySnapshot(snap.state)
|
||||
* // 3. Подписка с курсора снапшота; дельты > cursor накатываются поверх.
|
||||
* outbox.conversationEvents(after = snap.cursor, conversationId = id).collect { apply(it) }
|
||||
* }
|
||||
* ```
|
||||
* Точный порядок на стороне сервера/snapshot'а (subscribe-before-snapshot,
|
||||
* буферизация дельт, idempotent apply) описан в `:client/README.md`.
|
||||
*
|
||||
* ## Gap detection
|
||||
* `after != null && after.offset < oldestCursor().offset` (или другой
|
||||
* [Cursor.epoch]) → [OutboxGapException] бросается **изнутри** Flow. Проверка
|
||||
* идёт под тем же lock'ом, что и регистрация подписчика (одним критическим
|
||||
* участком), поэтому не гоняется с конкурентной эвикцией и не теряет события
|
||||
* в окне «snapshot → live».
|
||||
* ## Курсора и replay здесь нет
|
||||
* [CommonEvent.date] — только метка времени для отображения/сортировки.
|
||||
* Подписки отдают события, эмитированные **после** вызова (live-only).
|
||||
* Клиенту, которому нужен пропущенный хвост, следует читать journal.
|
||||
*
|
||||
* ## Read-only
|
||||
* Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore].
|
||||
*
|
||||
* ## Подписки
|
||||
* Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая
|
||||
* независимая подписка** (cold Flow). `after == null` → только live (события
|
||||
* с момента вызова). Иные consumer'ы видят тот же live-tail; каждая подписка —
|
||||
* своя проекция.
|
||||
* независимая подписка** (cold Flow). Иные consumer'ы видят тот же live-tail;
|
||||
* каждая подписка — своя проекция.
|
||||
*/
|
||||
interface OutboxStore : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Подписка на события.
|
||||
*
|
||||
* - `after == null` → **только live** (события с момента вызова, replay
|
||||
* буфера не отдаётся);
|
||||
* - `after != null` → сначала **catchup** всех буферизованных событий с
|
||||
* `offset > after.offset` (по возрастанию offset), затем live.
|
||||
*
|
||||
* @throws OutboxGapException изнутри Flow, если [after] старше
|
||||
* [oldestCursor] (retention gap) или принадлежит другой эпохе.
|
||||
* Живой поток событий. События, эмитнутые до подписки, не приходят.
|
||||
*/
|
||||
fun events(after: Cursor?): Flow<CommonEvent>
|
||||
fun events(): Flow<CommonEvent>
|
||||
|
||||
/**
|
||||
* Подписка только на conversation-события ([CommonEvent.Conversation]).
|
||||
*
|
||||
* - `conversationId == null` → все диалоги;
|
||||
* - `conversationId != null` → только этот диалог.
|
||||
*
|
||||
* Семантика [after] и `gap` идентична [events].
|
||||
*/
|
||||
fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||
events(after)
|
||||
fun conversationEvents(conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||
events()
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.let { filtered ->
|
||||
if (conversationId == null) filtered
|
||||
@@ -91,32 +57,9 @@ interface OutboxStore : AutoCloseable {
|
||||
/**
|
||||
* Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/
|
||||
* переименование диалога).
|
||||
*
|
||||
* Семантика [after] и `gap` идентична [events].
|
||||
*/
|
||||
fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
|
||||
events(after).filterIsInstance<CommonEvent.Agent>()
|
||||
|
||||
/**
|
||||
* Актуальный курсор: offset последнего **записанного** события
|
||||
* (`lastOffset`). Это «commit point» снапшота: состояние со `seq <= cursor.offset`
|
||||
* уже в БД, всё, что `> cursor.offset`, придёт потоком.
|
||||
*
|
||||
* Клиент берёт его из снапшота либо напрямую перед подпиской.
|
||||
*/
|
||||
suspend fun currentCursor(): Cursor
|
||||
|
||||
/**
|
||||
* Минимальный курсор, с которого ещё можно продолжить поток без разрыва.
|
||||
*
|
||||
* - `after.offset >= oldestCursor().offset` → replay возможен;
|
||||
* - `after.offset < oldestCursor().offset` → [OutboxGapException].
|
||||
*
|
||||
* Для никогда не эвиктировавшего буфера равен offset'у последнего события
|
||||
* (т.е. «истории нет, но резумиться с конца можно»), а не `-1`: клиент,
|
||||
* догнавший состояние до рестарта, продолжает инкрементально.
|
||||
*/
|
||||
suspend fun oldestCursor(): Cursor
|
||||
fun agentEvents(): Flow<CommonEvent.Agent> =
|
||||
events().filterIsInstance<CommonEvent.Agent>()
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* [OffsetSequencer] поверх персистентного [CursorStore] — production-счётчик
|
||||
* событий агента.
|
||||
*
|
||||
* На создании читает сохранённый [Cursor] (`epoch` + next-offset) синхронно и
|
||||
* держит его в памяти. Позиция переживает рестарт процесса, поэтому обычный
|
||||
* рестарт сервера **не меняет** `epoch` и offset'ы остаются монотонными —
|
||||
* клиент продолжает инкрементально (см. [Cursor.epoch] KDoc), а не получает
|
||||
* gap на каждой перезагрузке.
|
||||
*
|
||||
* ### Стартовый offset
|
||||
*
|
||||
* Если хранилище пустое (первый запуск / апгрейд БД, где `message.seq` уже
|
||||
* накоплен), новый `epoch` генерируется сразу, а `next` берётся из [initialNext]
|
||||
* — по умолчанию `0`, но апгрейд должен передать `maxSeq + 1` журнала, иначе
|
||||
* новые offset'ы столкнутся с уже записанными `seq`. Новый `epoch` при этом
|
||||
* корректно заставляет клиентов сделать однократный resync.
|
||||
*
|
||||
* ### Ротация epoch
|
||||
*
|
||||
* Смена «мира» (сброс/восстановление БД) выполняется вызывающим: очисти
|
||||
* [CursorStore] — следующий старт сгенерирует новый `epoch`.
|
||||
*/
|
||||
class PersistentOffsetSequencer(
|
||||
private val store: CursorStore,
|
||||
private val initialNext: () -> Long = { 0L },
|
||||
private val newEpoch: () -> String = { Cursor.newEpoch() },
|
||||
) : OffsetSequencer {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val epoch: String
|
||||
private var next: Long
|
||||
|
||||
init {
|
||||
val saved = store.load()
|
||||
if (saved == null) {
|
||||
epoch = newEpoch()
|
||||
next = initialNext()
|
||||
// Фиксируем epoch сразу, чтобы он не «прыгал» до первого события.
|
||||
store.save(Cursor(epoch = epoch, offset = next))
|
||||
} else {
|
||||
epoch = saved.epoch
|
||||
next = saved.offset
|
||||
}
|
||||
}
|
||||
|
||||
override fun epoch(): String = epoch
|
||||
|
||||
override fun current(): Long = next
|
||||
|
||||
override suspend fun reserve(): Long = mutex.withLock {
|
||||
val assigned = next
|
||||
next = assigned + 1
|
||||
store.save(Cursor(epoch = epoch, offset = next))
|
||||
assigned
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ plugins {
|
||||
// вызываются на каждом `append`, в одном проходе с amortized O(1) для стабильного
|
||||
// размера буфера.
|
||||
//
|
||||
// Зависимости: только `:outbox-api` (api → `:proto` транзитивно).
|
||||
// Зависимости: `:outbox-api` (api → `:proto`/`:cursor-api` транзитивно) и
|
||||
// `:cursor-inmemory` (default-holder для `InMemoryOutboxStore`).
|
||||
// Никакого I/O — pure in-memory.
|
||||
|
||||
kotlin {
|
||||
@@ -20,6 +21,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":outbox-api"))
|
||||
implementation(project(":cursor-inmemory"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
package pw.binom.agentik.outbox.inmemory
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.OffsetSequencer
|
||||
|
||||
/**
|
||||
* In-memory [OffsetSequencer] — для тестов, dev-режима и ephemeral runtime.
|
||||
*
|
||||
* Эпоха генерируется случайно при создании и **не переживает** пересоздание
|
||||
* инстанса: новый store → новый epoch → клиент с прежним курсором получит
|
||||
* [pw.binom.agentik.outbox.OutboxGapException] и сделает resync. Для
|
||||
* production-агента нужен персистентный счётчик (см. `:journal-ksqlite`).
|
||||
*/
|
||||
class InMemoryOffsetSequencer(
|
||||
private val epochId: String = newEpoch(),
|
||||
initialOffset: Long = 0L,
|
||||
) : OffsetSequencer {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var counter: Long = initialOffset
|
||||
|
||||
override fun epoch(): String = epochId
|
||||
|
||||
override fun current(): Long = counter
|
||||
|
||||
override suspend fun reserve(): Long = mutex.withLock { counter++ }
|
||||
|
||||
companion object {
|
||||
/** Делегирует в [Cursor.newEpoch] — единый генератор epoch'а проекта. */
|
||||
fun newEpoch(): String = Cursor.newEpoch()
|
||||
}
|
||||
}
|
||||
-3
@@ -41,9 +41,6 @@ class InMemoryOnlineOutbox(
|
||||
|
||||
override fun onlineEvents(): Flow<OnlineEvent> = liveFlow
|
||||
|
||||
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
|
||||
liveFlow.filter { it.conversationId == conversationId }
|
||||
|
||||
override suspend fun appendOnline(event: OnlineEvent) = liveFlow.emit(event)
|
||||
|
||||
override fun tryAppendOnline(event: OnlineEvent): Boolean = liveFlow.tryEmit(event)
|
||||
|
||||
+24
-131
@@ -1,161 +1,54 @@
|
||||
package pw.binom.agentik.outbox.inmemory
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.OffsetSequencer
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
|
||||
/**
|
||||
* In-memory реализация [MutableOutboxStore] на `ArrayDeque` + [Mutex].
|
||||
* In-memory реализация [MutableOutboxStore] — live-шина durable-событий.
|
||||
*
|
||||
* ## Retention
|
||||
* - [maxMessages] `null` → неограниченно по количеству;
|
||||
* - [ttl] `null` → нет time-based eviction;
|
||||
* - **оба `null` → вечное хранилище в RAM**;
|
||||
* - любой non-null → граница применяется на каждом [append] (amortized O(1)).
|
||||
* Никакого хранения и никакого replay: [CommonEvent] публикуются подписчикам
|
||||
* «в моменте». Единственный буфер — внутренний у [MutableSharedFlow] для
|
||||
* развязки продюсера/подписчиков; при переполнении **старые дропаются**
|
||||
* ([BufferOverflow.DROP_OLDEST]), [append] не блокируется.
|
||||
*
|
||||
* ## Курсор и gap
|
||||
* Offset'ы берутся из [sequencer] ([reserveOffset] = [OffsetSequencer.reserve]).
|
||||
* [currentCursor] = offset последнего **записанного** события; [oldestCursor] =
|
||||
* `buffer.first().offset - 1` (или `lastOffset`, если буфер пуст). Подписка
|
||||
* `after` старше `oldestCursor` (или с чужой эпохой) бросает [OutboxGapException].
|
||||
* История событий для перезапроса живёт в
|
||||
* [pw.binom.agentik.journal.JournalStore]; курсор/resume для удалённого
|
||||
* доступа — в sync-слое. Здесь их нет.
|
||||
*
|
||||
* На старте `lastOffset = sequencer.current() - 1`: если счётчик персистентный
|
||||
* и равен N, то клиент с курсором N-1 (догнавший состояние до рестарта)
|
||||
* продолжает инкрементально, а клиент с курсором < N-1 получает gap и делает
|
||||
* resync. Так рестарт сервера не теряет события молча.
|
||||
*
|
||||
* ## Concurrency
|
||||
* Один [Mutex] защищает append/evict/подписки. Регистрация подписчика и снятие
|
||||
* snapshot'а идут **одним критическим участком** — это закрывает окно
|
||||
* «snapshot → live», в котором append мог потеряться: всё, что попадёт в буфер
|
||||
* после регистрации, доедет до подписчика через его [Channel]. Snapshot
|
||||
* итерируется и эмитится вне lock'а.
|
||||
*
|
||||
* ## Live-tail
|
||||
* Каждому подписчику — свой [Channel] с `DROP_OLDEST`: медленный подписчик
|
||||
* теряет только хвост live-потока и обязан сам сделать resync при обнаружении
|
||||
* gap'а по retention'у.
|
||||
* **Маршрутизация**: один общий [MutableSharedFlow]; [conversationEvents] /
|
||||
* [agentEvents] фильтруют его (см. [MutableOutboxStore]).
|
||||
*/
|
||||
class InMemoryOutboxStore(
|
||||
private val maxMessages: Int?,
|
||||
private val ttl: Duration?,
|
||||
private val clock: Clock = Clock.System,
|
||||
private val sequencer: OffsetSequencer = InMemoryOffsetSequencer(),
|
||||
liveBufferCapacity: Int = DEFAULT_LIVE_BUFFER_CAPACITY,
|
||||
) : MutableOutboxStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val buffer = ArrayDeque<CommonEvent>()
|
||||
private val subscribers = mutableSetOf<SendChannel<CommonEvent>>()
|
||||
|
||||
/**
|
||||
* Offset последнего **записанного** события. Инициализируется из счётчика:
|
||||
* `current() - 1` (для fresh-счётчика это `-1`).
|
||||
*/
|
||||
private var lastOffset: Long = sequencer.current() - 1
|
||||
private val liveFlow = MutableSharedFlow<CommonEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = liveBufferCapacity,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
init {
|
||||
require(maxMessages == null || maxMessages > 0) {
|
||||
"maxMessages must be > 0 or null, got $maxMessages"
|
||||
require(liveBufferCapacity > 0) {
|
||||
"liveBufferCapacity must be > 0, got $liveBufferCapacity"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reserveOffset(): Long = sequencer.reserve()
|
||||
override fun events(): Flow<CommonEvent> = liveFlow
|
||||
|
||||
override suspend fun append(event: CommonEvent) {
|
||||
mutex.withLock {
|
||||
require(event.offset > lastOffset) {
|
||||
"Non-monotonic offset: got ${event.offset}, last=${lastOffset}"
|
||||
}
|
||||
buffer.addLast(event)
|
||||
lastOffset = event.offset
|
||||
subscribers.forEach { it.trySend(event) }
|
||||
evictLocked()
|
||||
}
|
||||
liveFlow.emit(event)
|
||||
}
|
||||
|
||||
private fun evictLocked() {
|
||||
val ttlValue = ttl
|
||||
if (ttlValue != null) {
|
||||
val cutoff = clock.now() - ttlValue
|
||||
while (true) {
|
||||
val head = buffer.firstOrNull() ?: break
|
||||
if (head.date >= cutoff) break
|
||||
buffer.removeFirst()
|
||||
}
|
||||
}
|
||||
val cap = maxMessages
|
||||
if (cap != null) {
|
||||
while (buffer.size > cap) {
|
||||
buffer.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun events(after: Cursor?): Flow<CommonEvent> = channelFlow {
|
||||
val channel = Channel<CommonEvent>(
|
||||
capacity = LIVE_BUFFER_CAPACITY,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val snapshot: List<CommonEvent> = mutex.withLock {
|
||||
val epoch = sequencer.epoch()
|
||||
if (after != null) {
|
||||
val floor = buffer.firstOrNull()?.let { it.offset - 1 } ?: lastOffset
|
||||
if (after.epoch != epoch || after.offset < floor || after.offset > lastOffset) {
|
||||
throw OutboxGapException(
|
||||
requested = after,
|
||||
current = Cursor(epoch, lastOffset),
|
||||
oldest = Cursor(epoch, floor),
|
||||
)
|
||||
}
|
||||
}
|
||||
subscribers += channel
|
||||
// `after == null` → live-only (без replay буфера). Чтобы получить
|
||||
// весь удержанный хвост, клиент передаёт `after = oldestCursor()`.
|
||||
if (after == null) emptyList() else buffer.filter { it.offset > after.offset }
|
||||
}
|
||||
try {
|
||||
snapshot.forEach { send(it) }
|
||||
for (event in channel) send(event)
|
||||
} finally {
|
||||
mutex.withLock { subscribers -= channel }
|
||||
channel.close()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun currentCursor(): Cursor = mutex.withLock {
|
||||
Cursor(sequencer.epoch(), lastOffset)
|
||||
}
|
||||
|
||||
override suspend fun oldestCursor(): Cursor = mutex.withLock {
|
||||
val floor = buffer.firstOrNull()?.let { it.offset - 1 } ?: lastOffset
|
||||
Cursor(sequencer.epoch(), floor)
|
||||
}
|
||||
|
||||
/**
|
||||
* **Test-only helper** — снимок буфера в текущий момент.
|
||||
*
|
||||
* `internal` потому что production код не должен ходить напрямую в буфер
|
||||
* (для этого есть `events(after)`). Доступно только из `commonTest`.
|
||||
*/
|
||||
internal suspend fun snapshot(): List<CommonEvent> = mutex.withLock { buffer.toList() }
|
||||
|
||||
override fun close() {
|
||||
buffer.clear()
|
||||
subscribers.clear()
|
||||
// replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки.
|
||||
// Идемпотентно: повторный close() безопасен.
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val LIVE_BUFFER_CAPACITY = 4096
|
||||
private const val DEFAULT_LIVE_BUFFER_CAPACITY = 64
|
||||
}
|
||||
}
|
||||
|
||||
+73
-248
@@ -1,292 +1,117 @@
|
||||
package pw.binom.agentik.outbox.inmemory
|
||||
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
// Импортируем напрямую из :outbox-api — typealias'ы в :proto для
|
||||
// CommonEvent/AgentEvent/Event НЕ поддерживают nested-class access
|
||||
// (`CommonEvent.Agent` через alias даёт "Unresolved qualified name").
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Тесты [InMemoryOutboxStore] как live-шины durable-событий: доставка живым
|
||||
* подписчикам, отсутствие replay, фильтры по варианту/диалогу.
|
||||
*/
|
||||
class InMemoryOutboxStoreTest {
|
||||
|
||||
private class FixedClock(private var nowMs: Long = 1_000_000_000L) : Clock {
|
||||
fun advance(delta: Duration) { nowMs += delta.inWholeMilliseconds }
|
||||
override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs)
|
||||
}
|
||||
private fun agentEvent(conversationId: String, at: Instant = Instant.fromEpochMilliseconds(1)) =
|
||||
CommonEvent.Agent(date = at, event = AgentEvent.Created(date = at, conversationId = conversationId))
|
||||
|
||||
private fun agentEvent(offset: Long, conversationId: String, at: Instant = Instant.fromEpochSeconds(offset)) =
|
||||
CommonEvent.Agent(
|
||||
date = at,
|
||||
offset = offset,
|
||||
event = AgentEvent.Created(date = at, conversationId = conversationId),
|
||||
)
|
||||
|
||||
private fun evtAt(clock: Clock, offset: Long, body: String): CommonEvent =
|
||||
CommonEvent.Agent(
|
||||
date = clock.now(),
|
||||
offset = offset,
|
||||
event = AgentEvent.Created(date = clock.now(), conversationId = body),
|
||||
)
|
||||
|
||||
private suspend fun InMemoryOutboxStore.ids() =
|
||||
snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
private fun conversationEvent(conversationId: String, at: Instant = Instant.fromEpochMilliseconds(1)) =
|
||||
CommonEvent.Conversation(date = at, conversationId = conversationId, event = DurableEvent.Interrupted(date = at))
|
||||
|
||||
@Test
|
||||
fun `append stores all events when both limits are null store-forever`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
repeat(100) { i ->
|
||||
store.append(agentEvent(i.toLong(), "c-$i"))
|
||||
}
|
||||
assertEquals(100, store.snapshot().size)
|
||||
fun `events delivers appended events live`() = runTest {
|
||||
val store = InMemoryOutboxStore()
|
||||
val collected = mutableListOf<CommonEvent>()
|
||||
val job = backgroundScope.launch { store.events().collect { collected.add(it) } }
|
||||
runCurrent()
|
||||
|
||||
store.append(agentEvent("c1"))
|
||||
store.append(agentEvent("c2"))
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf("c1", "c2"), collected.map { (it as CommonEvent.Agent).event.let { e -> (e as AgentEvent.Created).conversationId } })
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reserveOffset is monotonic`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
assertEquals(0L, store.reserveOffset())
|
||||
assertEquals(1L, store.reserveOffset())
|
||||
assertEquals(2L, store.reserveOffset())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maxMessages cap evicts oldest when exceeded`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = 3, ttl = null)
|
||||
for (i in 1..5) store.append(agentEvent(i.toLong(), "c-$i"))
|
||||
assertEquals(listOf("c-3", "c-4", "c-5"), store.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ttl evicts events older than threshold`() = runBlocking {
|
||||
val clock = FixedClock()
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = 100.milliseconds, clock = clock)
|
||||
|
||||
store.append(evtAt(clock, 0, "old"))
|
||||
clock.advance(50.milliseconds)
|
||||
store.append(evtAt(clock, 1, "middle"))
|
||||
clock.advance(70.milliseconds)
|
||||
store.append(evtAt(clock, 2, "fresh"))
|
||||
|
||||
assertEquals(listOf("middle", "fresh"), store.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both maxMessages and ttl apply together`() = runBlocking {
|
||||
val clock = FixedClock()
|
||||
val store = InMemoryOutboxStore(maxMessages = 2, ttl = 100.milliseconds, clock = clock)
|
||||
|
||||
store.append(evtAt(clock, 0, "a"))
|
||||
clock.advance(20.milliseconds)
|
||||
store.append(evtAt(clock, 1, "b"))
|
||||
clock.advance(60.milliseconds)
|
||||
store.append(evtAt(clock, 2, "c"))
|
||||
|
||||
assertEquals(listOf("b", "c"), store.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `currentCursor is last appended offset`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "e0"))
|
||||
store.append(agentEvent(1, "e1"))
|
||||
assertEquals(1L, store.currentCursor().offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oldestCursor equals last offset when buffer is empty`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
// Свежий счётчик: next offset = 0 → oldest = -1.
|
||||
assertEquals(-1L, store.oldestCursor().offset)
|
||||
assertEquals(store.currentCursor().epoch, store.oldestCursor().epoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oldestCursor is first-minus-one when buffer is non-empty`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = 2, ttl = null)
|
||||
store.append(agentEvent(0, "a"))
|
||||
store.append(agentEvent(1, "b"))
|
||||
store.append(agentEvent(2, "c"))
|
||||
// buffer = [1, 2]; oldest = 1 - 1 = 0.
|
||||
assertEquals(0L, store.oldestCursor().offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events with null after is live-only (no replay)`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "e1"))
|
||||
store.append(agentEvent(1, "e2"))
|
||||
fun `events does not replay history`() = runTest {
|
||||
val store = InMemoryOutboxStore()
|
||||
store.append(agentEvent("old"))
|
||||
|
||||
val collected = mutableListOf<CommonEvent>()
|
||||
val done = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
store.events(after = null).collect { e ->
|
||||
collected.add(e)
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
delay(20)
|
||||
store.append(agentEvent(2, "e3"))
|
||||
done.await()
|
||||
val job = backgroundScope.launch { store.events().collect { collected.add(it) } }
|
||||
runCurrent()
|
||||
assertEquals(emptyList(), collected)
|
||||
|
||||
store.append(agentEvent("new"))
|
||||
runCurrent()
|
||||
assertEquals(1, collected.size)
|
||||
job.cancel()
|
||||
assertEquals(listOf("e3"), collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events with cursor catches up then continues with live`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "e1"))
|
||||
store.append(agentEvent(1, "e2"))
|
||||
store.append(agentEvent(2, "e3"))
|
||||
fun `conversationEvents filters to conversation variant`() = runTest {
|
||||
val store = InMemoryOutboxStore()
|
||||
val collected = mutableListOf<CommonEvent.Conversation>()
|
||||
val job = backgroundScope.launch { store.conversationEvents().collect { collected.add(it) } }
|
||||
runCurrent()
|
||||
|
||||
val from = Cursor(store.currentCursor().epoch, 0L)
|
||||
val collected = mutableListOf<CommonEvent>()
|
||||
val done = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
store.events(after = from).collect { e ->
|
||||
collected.add(e)
|
||||
if (collected.size >= 3) done.complete(Unit)
|
||||
}
|
||||
}
|
||||
delay(20)
|
||||
store.append(agentEvent(3, "e4"))
|
||||
done.await()
|
||||
store.append(agentEvent("agent"))
|
||||
store.append(conversationEvent("c1"))
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf("c1"), collected.map { it.conversationId })
|
||||
job.cancel()
|
||||
val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
assertEquals(listOf("e2", "e3", "e4"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subscribe from currentCursor receives only newer events - no handoff loss`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "old"))
|
||||
fun `conversationEvents with id filters by conversation`() = runTest {
|
||||
val store = InMemoryOutboxStore()
|
||||
val collected = mutableListOf<CommonEvent.Conversation>()
|
||||
val job = backgroundScope.launch { store.conversationEvents("c1").collect { collected.add(it) } }
|
||||
runCurrent()
|
||||
|
||||
val collected = mutableListOf<CommonEvent>()
|
||||
val from = store.currentCursor()
|
||||
val job = launch { store.events(after = from).collect { collected.add(it) } }
|
||||
delay(50)
|
||||
store.append(agentEvent(1, "new"))
|
||||
delay(50)
|
||||
store.append(conversationEvent("c1"))
|
||||
store.append(conversationEvent("c2"))
|
||||
store.append(conversationEvent("c1"))
|
||||
runCurrent()
|
||||
|
||||
assertEquals(2, collected.size)
|
||||
assertEquals(listOf("c1", "c1"), collected.map { it.conversationId })
|
||||
job.cancel()
|
||||
|
||||
assertEquals(listOf(1L), collected.map { it.offset })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gap exception when cursor older than oldest`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = 2, ttl = null)
|
||||
store.append(agentEvent(0, "e0"))
|
||||
store.append(agentEvent(1, "e1"))
|
||||
store.append(agentEvent(2, "e2"))
|
||||
fun `agentEvents filters to agent variant`() = runTest {
|
||||
val store = InMemoryOutboxStore()
|
||||
val collected = mutableListOf<CommonEvent.Agent>()
|
||||
val job = backgroundScope.launch { store.agentEvents().collect { collected.add(it) } }
|
||||
runCurrent()
|
||||
|
||||
val tooOld = Cursor(store.currentCursor().epoch, -1L)
|
||||
assertFailsWith<OutboxGapException> {
|
||||
store.events(after = tooOld).collect { }
|
||||
}
|
||||
// Ровно на границе — ещё можно.
|
||||
val atFloor = Cursor(store.currentCursor().epoch, 0L)
|
||||
val got = mutableListOf<Long>()
|
||||
val job = launch { store.events(after = atFloor).collect { got.add(it.offset) } }
|
||||
delay(30)
|
||||
store.append(conversationEvent("c1"))
|
||||
store.append(agentEvent("agent"))
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, collected.size)
|
||||
assertEquals("agent", (collected[0].event as AgentEvent.Created).conversationId)
|
||||
job.cancel()
|
||||
assertEquals(listOf(1L, 2L), got)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gap exception on epoch mismatch`(): Unit = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "e0"))
|
||||
val foreign = Cursor("some-other-epoch", 0L)
|
||||
assertFailsWith<OutboxGapException> {
|
||||
store.events(after = foreign).collect { }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gap exception exposes requested current and oldest`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = 1, ttl = null)
|
||||
store.append(agentEvent(0, "e0"))
|
||||
store.append(agentEvent(1, "e1"))
|
||||
val tooOld = Cursor(store.currentCursor().epoch, -1L)
|
||||
val e = assertFailsWith<OutboxGapException> { store.events(tooOld).collect { } }
|
||||
assertEquals(tooOld, e.requested)
|
||||
assertEquals(1L, e.current.offset)
|
||||
assertEquals(0L, e.oldest.offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversationEvents default impl filters to conversation variant`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
val now = Instant.fromEpochSeconds(0)
|
||||
store.append(CommonEvent.Agent(now, 0, AgentEvent.Created(now, "agent-event")))
|
||||
store.append(CommonEvent.Conversation(now, 1, "c-1", DurableEvent.Interrupted(now)))
|
||||
|
||||
val all = store.snapshot()
|
||||
assertEquals(2, all.size)
|
||||
assertEquals(1, all.count { it is CommonEvent.Conversation })
|
||||
assertEquals(1, all.count { it is CommonEvent.Agent })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
val now = Instant.fromEpochSeconds(0)
|
||||
store.append(CommonEvent.Conversation(now, 0, "c-1", DurableEvent.Interrupted(now)))
|
||||
store.append(CommonEvent.Conversation(now, 1, "c-2", DurableEvent.Interrupted(now)))
|
||||
store.append(CommonEvent.Conversation(now, 2, "c-1", DurableEvent.Interrupted(now)))
|
||||
|
||||
val c1 = store.snapshot()
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.filter { it.conversationId == "c-1" }
|
||||
assertEquals(2, c1.size)
|
||||
assertTrue(c1.all { it.conversationId == "c-1" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agentEvents default impl filters to agent variant`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
val now = Instant.fromEpochSeconds(0)
|
||||
store.append(CommonEvent.Agent(now, 0, AgentEvent.Created(now, "created")))
|
||||
store.append(CommonEvent.Conversation(now, 1, "c-1", DurableEvent.Interrupted(now)))
|
||||
|
||||
val agents = store.snapshot().filterIsInstance<CommonEvent.Agent>()
|
||||
assertEquals(1, agents.size)
|
||||
assertEquals("created", (agents[0].event as AgentEvent.Created).conversationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `append with non-monotonic offset throws`(): Unit = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(5, "e5"))
|
||||
assertFailsWith<IllegalArgumentException> { store.append(agentEvent(5, "again")) }
|
||||
assertFailsWith<IllegalArgumentException> { store.append(agentEvent(4, "lower")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close clears buffer`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "e1"))
|
||||
fun `close is idempotent`() {
|
||||
val store = InMemoryOutboxStore()
|
||||
store.close()
|
||||
store.close()
|
||||
assertEquals(emptyList(), store.snapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative maxMessages throws at construction`() {
|
||||
kotlin.runCatching { InMemoryOutboxStore(maxMessages = -1, ttl = null) }
|
||||
.onFailure { /* expected */ }
|
||||
.onSuccess { kotlin.test.fail("should have thrown") }
|
||||
fun `non-positive buffer capacity throws`() {
|
||||
assertFailsWith<IllegalArgumentException> { InMemoryOutboxStore(liveBufferCapacity = 0) }
|
||||
}
|
||||
}
|
||||
|
||||
private val Int.milliseconds: Duration get() = Duration.parse("${this}ms")
|
||||
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
package pw.binom.agentik.outbox.ksqlite
|
||||
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.CursorStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [CursorStore] — таблица `outbox_cursor` (одна строка,
|
||||
* `id = 1`).
|
||||
*
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Две формы, как у остальных ksqlite-store'ов:
|
||||
* - `KsqliteCursorStore(connection)` — внешнее соединение, store НЕ закрывает
|
||||
* его в [close]. Для shared-connection bundle'а (`SqliteStores.assemble`).
|
||||
* - `KsqliteCursorStore(path)` — открывает файловое соединение и закрывает
|
||||
* его в [close].
|
||||
*
|
||||
* ## Синхронизация
|
||||
*
|
||||
* `load()` вызывается один раз при создании `PersistentOffsetSequencer`,
|
||||
* `save()` — сериализован его `Mutex`. Поэтому собственный mutex не нужен;
|
||||
* prepared statements закрываются в [close] ДО owned-connection (иначе
|
||||
* финалайзеры stmt'ов дёргают уже закрытый parent → SIGSEGV).
|
||||
*/
|
||||
class KsqliteCursorStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
) : CursorStore, AutoCloseable {
|
||||
|
||||
/** Внешнее соединение — store НЕ закрывает его в [close]. */
|
||||
constructor(connection: SQLiteConnection) : this(connection, ownsConnection = false)
|
||||
|
||||
/** Файловое соединение — store закрывает его в [close]. */
|
||||
constructor(path: String) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
private val getStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT ${Schema.COL_EPOCH}, ${Schema.COL_OFFSET} " +
|
||||
"FROM ${Schema.TABLE} WHERE ${Schema.COL_ID} = 1"
|
||||
)
|
||||
private val setStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"INSERT INTO ${Schema.TABLE}(${Schema.COL_ID}, ${Schema.COL_EPOCH}, ${Schema.COL_OFFSET}) " +
|
||||
"VALUES(1, ?, ?) ON CONFLICT(${Schema.COL_ID}) DO UPDATE SET " +
|
||||
"${Schema.COL_EPOCH}=excluded.${Schema.COL_EPOCH}, " +
|
||||
"${Schema.COL_OFFSET}=excluded.${Schema.COL_OFFSET}"
|
||||
)
|
||||
|
||||
override fun load(): Cursor? {
|
||||
getStmt.reset()
|
||||
getStmt.clearBindings()
|
||||
getStmt.executeQuery().use { rs ->
|
||||
if (!rs.next()) return null
|
||||
return Cursor(epoch = rs.getText(0)!!, offset = rs.getLong(1)!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun save(cursor: Cursor) {
|
||||
setStmt.reset()
|
||||
setStmt.clearBindings()
|
||||
setStmt.bindText(1, cursor.epoch)
|
||||
setStmt.bindLong(2, cursor.offset)
|
||||
setStmt.executeUpdate()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
getStmt.close()
|
||||
setStmt.close()
|
||||
if (ownsConnection) connection.close()
|
||||
}
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
package pw.binom.agentik.outbox.ksqlite
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.CursorStore
|
||||
import pw.binom.agentik.outbox.PersistentOffsetSequencer
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/** In-memory [CursorStore] для проверки логики секвенсора без БД. */
|
||||
private class FakeCursorStore : CursorStore {
|
||||
var value: Cursor? = null
|
||||
override fun load(): Cursor? = value
|
||||
override fun save(cursor: Cursor) {
|
||||
value = cursor
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentOffsetSequencerTest {
|
||||
|
||||
@Test
|
||||
fun reservesMonotonically() = runTest {
|
||||
val seq = PersistentOffsetSequencer(FakeCursorStore(), newEpoch = { "e1" })
|
||||
assertEquals("e1", seq.epoch())
|
||||
assertEquals(0L, seq.current())
|
||||
assertEquals(0L, seq.reserve())
|
||||
assertEquals(1L, seq.reserve())
|
||||
assertEquals(2L, seq.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedsInitialNextOnFirstRun() = runTest {
|
||||
val seq = PersistentOffsetSequencer(FakeCursorStore(), initialNext = { 42L }, newEpoch = { "e1" })
|
||||
assertEquals(42L, seq.current())
|
||||
assertEquals(42L, seq.reserve())
|
||||
assertEquals(43L, seq.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun survivesRestartKeepingEpochAndOffset() = runTest {
|
||||
val store = FakeCursorStore()
|
||||
val before = PersistentOffsetSequencer(store, newEpoch = { "e1" })
|
||||
before.reserve()
|
||||
before.reserve()
|
||||
|
||||
// «Рестарт»: новый секвенсор над тем же persisted-хранилищем.
|
||||
val after = PersistentOffsetSequencer(store, newEpoch = { "MUST-NOT-BE-USED" })
|
||||
assertEquals("e1", after.epoch())
|
||||
assertEquals(2L, after.current())
|
||||
assertEquals(2L, after.reserve())
|
||||
assertEquals(3L, after.current())
|
||||
}
|
||||
}
|
||||
|
||||
class KsqliteCursorStoreTest {
|
||||
|
||||
@Test
|
||||
fun roundTripsAndSurvivesRestart() {
|
||||
val conn = SQLiteConnection.memory("outbox-cursor-test")
|
||||
try {
|
||||
val first = KsqliteCursorStore(conn)
|
||||
assertNull(first.load())
|
||||
first.save(Cursor(epoch = "e1", offset = 7))
|
||||
assertEquals(Cursor(epoch = "e1", offset = 7), first.load())
|
||||
|
||||
// «Рестарт»: новый store над той же БД.
|
||||
val second = KsqliteCursorStore(conn)
|
||||
assertEquals(Cursor(epoch = "e1", offset = 7), second.load())
|
||||
second.save(Cursor(epoch = "e1", offset = 8))
|
||||
assertEquals(Cursor(epoch = "e1", offset = 8), first.load())
|
||||
|
||||
first.close()
|
||||
second.close()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ package pw.binom.agentik.proto
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
|
||||
/**
|
||||
* Снапшот списка диалогов + его [cursor].
|
||||
*
|
||||
* Курсор — **commit point** на момент чтения (см. `OutboxStore.currentCursor`):
|
||||
* состояние со `seq <= cursor.offset` отражено в [conversations], всё, что
|
||||
* состояние с `offset <= cursor.offset` отражено в [conversations], всё, что
|
||||
* появится позже, придёт потоком outbox-событий. Клиент после применения
|
||||
* снапшота подписывается `agentEvents(after = cursor)` (или
|
||||
* `conversationEvents(after = cursor)`) и накатывает дельты поверх.
|
||||
@@ -27,9 +27,9 @@ data class ConversationsSnapshot(
|
||||
/**
|
||||
* Снапшот сообщений одного диалога + его [cursor].
|
||||
*
|
||||
* Сообщения прочитаны с отсечкой `seq <= cursor.offset` — конечное и
|
||||
* Сообщения прочитаны с отсечкой `offset <= cursor.offset` — конечное и
|
||||
* стабильное множество, отражающее состояние диалога на момент [cursor].
|
||||
* Всё, что появится позже (`seq > cursor.offset`), придёт потоком
|
||||
* Всё, что появится позже (`offset > cursor.offset`), придёт потоком
|
||||
* `conversationEvents(after = cursor, conversationId)`.
|
||||
*/
|
||||
@Serializable
|
||||
|
||||
@@ -8,7 +8,7 @@ import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
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.OutboxStore
|
||||
|
||||
/**
|
||||
@@ -89,7 +89,7 @@ internal suspend fun ApplicationCall.requireCursorAlive(outbox: OutboxStore, aft
|
||||
if (after == null) return true
|
||||
val oldest = outbox.oldestCursor()
|
||||
val current = outbox.currentCursor()
|
||||
if (after.epoch != oldest.epoch || after.offset < oldest.offset || after.offset > current.offset) {
|
||||
if (after.createdAt != oldest.createdAt || after.offset < oldest.offset || after.offset > current.offset) {
|
||||
respond(
|
||||
HttpStatusCode.Gone,
|
||||
OutboxGapResponse(requested = after, oldest = oldest, current = current),
|
||||
@@ -101,17 +101,20 @@ internal suspend fun ApplicationCall.requireCursorAlive(outbox: OutboxStore, aft
|
||||
|
||||
/**
|
||||
* Парсит курсор из query-параметров `epoch` + `offset`.
|
||||
* Оба отсутствуют → `null` (live-only). Задан только один или невалидный
|
||||
* `offset` → `400` и `null`.
|
||||
* Оба отсутствуют → `null` (live-only). Задан только один или невалидные
|
||||
* значения → `400` и `null`.
|
||||
*
|
||||
* `epoch` — [Cursor.createdAt] (Unix ms), `offset` — [Cursor.offset] (ULong).
|
||||
*/
|
||||
internal suspend fun ApplicationCall.parseCursor(): Cursor? {
|
||||
val epoch = request.queryParameters["epoch"]
|
||||
val epochRaw = request.queryParameters["epoch"]
|
||||
val offsetRaw = request.queryParameters["offset"]
|
||||
if (epoch == null && offsetRaw == null) return null
|
||||
val offset = offsetRaw?.toLongOrNull()
|
||||
if (epochRaw == null && offsetRaw == null) return null
|
||||
val epoch = epochRaw?.toLongOrNull()
|
||||
val offset = offsetRaw?.toULongOrNull()
|
||||
if (epoch == null || offset == null) {
|
||||
respond(HttpStatusCode.BadRequest, "Invalid cursor (expected 'epoch' + 'offset' query params)")
|
||||
return null
|
||||
}
|
||||
return Cursor(epoch = epoch, offset = offset)
|
||||
return Cursor(createdAt = epoch, offset = offset)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.ChatSnapshot
|
||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import kotlin.test.AfterTest
|
||||
@@ -77,8 +77,8 @@ class AgentInfoRouteTest {
|
||||
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
||||
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override fun close() {}
|
||||
}
|
||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||
@@ -88,9 +88,9 @@ class AgentInfoRouteTest {
|
||||
override fun close() {}
|
||||
}
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
|
||||
override suspend fun getConversation(id: String): Conversation? = null
|
||||
@@ -155,8 +155,8 @@ class AgentInfoRouteTest {
|
||||
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
||||
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override fun close() {}
|
||||
}
|
||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||
@@ -166,9 +166,9 @@ class AgentInfoRouteTest {
|
||||
override fun close() {}
|
||||
}
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
|
||||
override suspend fun getConversation(id: String): Conversation? = null
|
||||
|
||||
@@ -22,7 +22,7 @@ import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.ChatSnapshot
|
||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import kotlin.time.Instant
|
||||
@@ -61,8 +61,8 @@ class BearerTokenTest {
|
||||
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
||||
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override fun close() {}
|
||||
}
|
||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||
@@ -72,9 +72,9 @@ class BearerTokenTest {
|
||||
override fun close() {}
|
||||
}
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
|
||||
override suspend fun getConversation(id: String): Conversation? = null
|
||||
|
||||
@@ -25,7 +25,7 @@ import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.ChatSnapshot
|
||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
@@ -169,8 +169,8 @@ class ConversationRoutesTest {
|
||||
override fun conversationEvents(after: Cursor?, conversationId: String?) =
|
||||
emptyFlow<CommonEvent.Conversation>()
|
||||
|
||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override fun close() {}
|
||||
}
|
||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||
@@ -178,11 +178,11 @@ class ConversationRoutesTest {
|
||||
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
||||
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
|
||||
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
|
||||
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
|
||||
|
||||
@@ -29,7 +29,7 @@ import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.ChatSnapshot
|
||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
@@ -80,6 +80,7 @@ class JournalRoutesCountTest {
|
||||
conversationId = convId,
|
||||
content = listOf(JContent.Text(text)),
|
||||
createdAt = at,
|
||||
cursor = pw.binom.agentik.cursor.Cursor(createdAt = 0L, offset = 0u),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -188,8 +189,8 @@ class JournalRoutesCountTest {
|
||||
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
||||
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
||||
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||
override fun close() {}
|
||||
}
|
||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||
@@ -201,11 +202,11 @@ class JournalRoutesCountTest {
|
||||
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
||||
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
|
||||
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
|
||||
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
|
||||
|
||||
|
||||
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
|
||||
|
||||
@@ -18,7 +18,7 @@ import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.outbox.OnlineOutbox
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
@@ -45,20 +45,20 @@ class SnapshotRouteTest {
|
||||
private lateinit var server: EmbeddedServer<*, *>
|
||||
private var port: Int = 0
|
||||
|
||||
private val epoch = "epoch-1"
|
||||
private val epoch = 123456789L
|
||||
|
||||
private class FakeOutbox(
|
||||
private val epoch: String,
|
||||
private val oldest: Long,
|
||||
private val current: Long,
|
||||
private val epoch: Long,
|
||||
private val oldest: ULong,
|
||||
private val current: ULong,
|
||||
private val replay: List<CommonEvent> = emptyList(),
|
||||
) : OutboxStore {
|
||||
override fun events(after: Cursor?): Flow<CommonEvent> = flowOf(*replay.toTypedArray())
|
||||
override fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> = emptyFlow()
|
||||
override fun conversationEvents(after: Cursor?, conversationId: String?): Flow<CommonEvent.Conversation> =
|
||||
emptyFlow()
|
||||
override suspend fun currentCursor(): Cursor = Cursor(epoch, current)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(epoch, oldest)
|
||||
override suspend fun currentCursor(): Cursor = Cursor(createdAt = epoch, offset = current)
|
||||
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = epoch, offset = oldest)
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class SnapshotRouteTest {
|
||||
override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L
|
||||
override fun close() {}
|
||||
}
|
||||
override val outbox: OutboxStore = FakeOutbox(epoch = epoch, oldest = 5L, current = 11L)
|
||||
override val outbox: OutboxStore = FakeOutbox(epoch = epoch, oldest = 5u, current = 11u)
|
||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||
override suspend fun get(id: String) = null
|
||||
@@ -92,10 +92,10 @@ class SnapshotRouteTest {
|
||||
}
|
||||
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
||||
ConversationsSnapshot(conversations = conversationStore.list(0, 100), cursor = Cursor(epoch, 11L))
|
||||
ConversationsSnapshot(conversations = conversationStore.list(0, 100), cursor = Cursor(createdAt = epoch, offset = 11u))
|
||||
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(epoch, 11L))
|
||||
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = epoch, offset = 11u))
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
|
||||
override suspend fun getConversation(id: String): Conversation? = null
|
||||
@@ -126,7 +126,7 @@ class SnapshotRouteTest {
|
||||
val body = response.bodyAsText()
|
||||
assertTrue("conv-1" in body, "body=$body")
|
||||
assertTrue("\"cursor\"" in body, "body=$body")
|
||||
assertTrue("\"$epoch\"" in body, "body=$body")
|
||||
assertTrue("\"createdAt\":$epoch" in body, "body=$body")
|
||||
assertTrue("\"offset\":11" in body, "body=$body")
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ class SnapshotRouteTest {
|
||||
|
||||
@Test
|
||||
fun `foreign epoch returns 410`() = runBlocking {
|
||||
val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=other&offset=6")
|
||||
val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=999&offset=6")
|
||||
assertEquals(HttpStatusCode.Gone, response.status)
|
||||
}
|
||||
|
||||
|
||||
+21
-10
@@ -44,6 +44,12 @@ include(":skills")
|
||||
include(":server")
|
||||
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
|
||||
include(":client")
|
||||
// Транспорт-агностичный слой клиентской синхронизации: оборачивает `Agent`
|
||||
// локальным стором бесед + durable-курсором (MutableCursorHolder) и ведёт
|
||||
// протокол «снапшот + дельты». Зависит только от :proto/:journal-api/:cursor-api,
|
||||
// поэтому переиспользуется любым клиентом (desktop, CLI, embedded), не только
|
||||
// HTTP-овым :client.
|
||||
include(":client-sync")
|
||||
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
|
||||
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
|
||||
// include(":agentik-cli") — отключено 2026-09-21: пользователь временно вывел
|
||||
@@ -114,10 +120,14 @@ include(":context-ksqlite")
|
||||
// ConversationStore / conversation table). Минимальный модуль:
|
||||
// таблицы `message` + `conversation` + индексы.
|
||||
include(":journal-ksqlite")
|
||||
// ksqlite-реализация :outbox-api (CursorStore / outbox_cursor table).
|
||||
// ksqlite-реализация :cursor-api (CursorHolder / outbox_cursor table).
|
||||
// Персистентная позиция счётчика событий агента — переживает рестарт, чтобы
|
||||
// клиент продолжал инкрементально (см. PersistentOffsetSequencer).
|
||||
include(":outbox-ksqlite")
|
||||
// клиент продолжал инкрементально (см. CursorHolder).
|
||||
include(":cursor-ksqlite")
|
||||
|
||||
// In-memory-реализация :cursor-api (CursorHolder) — для тестов, dev-режима
|
||||
// и ephemeral runtime; эпоха не переживает пересоздание holder'а.
|
||||
include(":cursor-inmemory")
|
||||
// ksqlite-реализация :reflection-api (ReflectionStore / reflection table).
|
||||
// Минимальный модуль: только таблица `reflection` + 2 индекса.
|
||||
include(":reflection-ksqlite")
|
||||
@@ -150,10 +160,11 @@ include(":agent-api")
|
||||
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
|
||||
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
|
||||
include(":skill-mining")
|
||||
// Референсная реализация спеки `SYNC-SYSTEM.md` — журнал событий с
|
||||
// монотонным курсором + материализация + replace-resync, единый протокол
|
||||
// для клиента и сервера. KMP с jvm/linuxX64/mingwX64 целями (ksqlite-
|
||||
// ограничение). Тесты спеки в commonTest, интеграция с ksqlite — в jvmTest.
|
||||
// Существующий код (outbox/journal/client/server) не трогаем; это чистый
|
||||
// референс для последующей миграции.
|
||||
include(":sync-core")
|
||||
include(":cursor-api")
|
||||
include(":sync")
|
||||
include(":ksqlite-message-storage")
|
||||
// Мосты между :proto-агентом и внешними мессенджерами. Каждый модуль —
|
||||
// `pw.binom.agentik.integrations.<messenger>` с `TelegramBridgeComponent`
|
||||
// (реализация `pw.binom.agentik.agent.Component`). Подключаются в
|
||||
// `:standalone` при наличии соответствующего токена в env (см. AppConfig).
|
||||
include(":integrations-telegram")
|
||||
|
||||
@@ -47,9 +47,11 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
||||
.joinToString("\n") { it.text }
|
||||
val conv = resolveConversation(contextId)
|
||||
|
||||
// Курсор старта: снапшот не нужен, достаточно текущей позиции лога.
|
||||
val since = agent.outbox.currentCursor()
|
||||
val reply = StringBuilder()
|
||||
// Live-подписки без catchup (snapshots выдаются outbox'ом отдельно,
|
||||
// см. Agent.chatSnapshot / conversationsSnapshot). Терминатор
|
||||
// хода — [Event.AssistantMessage] (успех) / [Event.Interrupted] /
|
||||
// [Event.Error] (провал).
|
||||
val reply = StringBuilder()
|
||||
val turnDone = CompletableDeferred<Unit>()
|
||||
// Онлайн-поток — дельты ответа (live-only, без catchup).
|
||||
val onlineJob = async {
|
||||
@@ -61,11 +63,9 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Durable-поток — терминатор хода (catchup + live): целый ответ
|
||||
// приходит [Event.AssistantMessage], обрыв — [Event.Interrupted],
|
||||
// провал — [Event.Error].
|
||||
// Durable-поток — терминатор хода (live-only).
|
||||
val turnJob = async {
|
||||
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
|
||||
agent.outbox.conversationEvents(conv.id).collect { ce ->
|
||||
when (val e = ce.event) {
|
||||
is DurableEvent.AssistantMessage, is DurableEvent.Interrupted -> turnDone.complete(Unit)
|
||||
is DurableEvent.Error ->
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package pw.binom.agentik.standalone
|
||||
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.reflection.ReflectionStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.standalone.agent.ChatConversation
|
||||
import pw.binom.agentik.llm.tools.LlmReflector
|
||||
import pw.binom.agentik.skill.mining.SkillMiner
|
||||
import pw.binom.agentik.standalone.agent.memory.Curator
|
||||
import pw.binom.agentik.skills.SkillStore
|
||||
import pw.binom.agentik.context.ContextStore
|
||||
|
||||
/**
|
||||
* Debug-эндпоинты для ручного триггерирования фоновых фич (без ожидания
|
||||
* интервалов). Подключаются только при `AGENTIK_DEBUG_ENDPOINTS=1`:
|
||||
*
|
||||
* - `POST /debug/reflect?conversationId=...` — прогон [LlmReflector] прямо сейчас
|
||||
* - `POST /debug/skill-mine?conversationId=...` — прогон [SkillMiner] прямо сейчас
|
||||
* - `POST /debug/curate` — прогон [Curator.runPass] прямо сейчас
|
||||
* - `POST /debug/compact?conversationId=...` — принудительный compaction
|
||||
*
|
||||
* Token-stats эндпоинт убран вместе с `MessageStore.tokenStats()` (см.
|
||||
* :message-log-api/MessageStore.kt). Сейчас token accounting доступен
|
||||
* только через assistant-сообщения с `TurnTokens` (см. MessageRecord).
|
||||
*
|
||||
* Каждый возвращает JSON с результатом (что сохранил / нашёл / сжал), чтобы в
|
||||
* тестах было видно не только "триггер сработал", а что именно LLM намайнила.
|
||||
* Только локальная отладка: endpoint'ы без авторизации, в проде не включать.
|
||||
*/
|
||||
internal fun Route.debugRoutes(
|
||||
agent: Agent,
|
||||
workingMemoryStore: ContextStore,
|
||||
reflectionStore: ReflectionStore,
|
||||
reflector: LlmReflector?,
|
||||
skillMiner: SkillMiner?,
|
||||
skillStore: SkillStore?,
|
||||
curator: Curator?,
|
||||
) {
|
||||
post("/debug/reflect") {
|
||||
val convId = call.parameters["conversationId"]
|
||||
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
|
||||
val minerReflector = reflector
|
||||
if (minerReflector == null) return@post call.respondText("reflection disabled", status = HttpStatusCode.NotFound)
|
||||
val turns = recentTurns(workingMemoryStore, convId, maxTurns = 6)
|
||||
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
|
||||
val reflection = minerReflector.reflect(turns)
|
||||
if (reflection == null) {
|
||||
call.respondText("""{"reflected":false,"reason":"unparseable LLM reply"}""", contentType = ContentType.Application.Json)
|
||||
} else {
|
||||
val stamped = reflection.copy(conversationId = convId)
|
||||
reflectionStore.insert(stamped)
|
||||
call.respondText(
|
||||
buildJsonObject {
|
||||
put("reflected", true)
|
||||
put("id", stamped.id)
|
||||
put("score", stamped.score.toString())
|
||||
put("summary", stamped.summary)
|
||||
put("weakSpots", kotlinx.serialization.json.JsonArray(stamped.weakSpots.map { kotlinx.serialization.json.JsonPrimitive(it) }))
|
||||
}.toString(),
|
||||
contentType = ContentType.Application.Json,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
post("/debug/skill-mine") {
|
||||
val convId = call.parameters["conversationId"]
|
||||
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
|
||||
val miner = skillMiner
|
||||
val store = skillStore
|
||||
if (miner == null || store == null) return@post call.respondText("skill mining disabled", status = HttpStatusCode.NotFound)
|
||||
val turns = recentTurns(workingMemoryStore, convId, maxTurns = miner.maxTurns)
|
||||
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
|
||||
val mined = miner.mine(turns, store.catalog.skills)
|
||||
for (s in mined) store.upsert(s)
|
||||
val json = buildJsonObject {
|
||||
put("mined", mined.size.toString())
|
||||
put(
|
||||
"skills",
|
||||
kotlinx.serialization.json.JsonArray(
|
||||
mined.map { s ->
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("name", s.name)
|
||||
put("description", s.description)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}.toString()
|
||||
call.respondText(json, contentType = ContentType.Application.Json)
|
||||
}
|
||||
|
||||
post("/debug/curate") {
|
||||
val c = curator ?: return@post call.respondText("curator disabled (memory off?)", status = HttpStatusCode.NotFound)
|
||||
val archived = c.runPass()
|
||||
call.respondText("""{"archived":$archived}""", contentType = ContentType.Application.Json)
|
||||
}
|
||||
|
||||
post("/debug/compact") {
|
||||
val convId = call.parameters["conversationId"]
|
||||
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
|
||||
val conv = agent.getConversation(convId) ?: return@post call.respondText("conversation not found", status = HttpStatusCode.NotFound)
|
||||
val chatConv = conv as? ChatConversation ?: return@post call.respondText("not a ChatConversation", status = HttpStatusCode.InternalServerError)
|
||||
val ok = chatConv.forceCompactNow()
|
||||
call.respondText("""{"compacted":$ok}""", contentType = ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Последние [maxTurns] пар user/assistant из working memory диалога
|
||||
* (для debug-триггеров reflector/miner; та же логика, что у хуков
|
||||
* [ChatConversation]).
|
||||
*/
|
||||
internal suspend fun recentTurns(workingMemoryStore: ContextStore, conversationId: String, maxTurns: Int): List<ConversationTurn> {
|
||||
val rows = workingMemoryStore.list(conversationId)
|
||||
val pairs = mutableListOf<ConversationTurn>()
|
||||
var pendingUser: String? = null
|
||||
for (row in rows) {
|
||||
when (val e = row.entry) {
|
||||
is pw.binom.agentik.context.WorkingMemoryEntry.User -> pendingUser = e.content.text()
|
||||
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> {
|
||||
val user = pendingUser ?: ""
|
||||
pendingUser = null
|
||||
pairs += ConversationTurn(userMessage = user, assistantMessage = e.content.text())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return pairs.takeLast(maxTurns)
|
||||
}
|
||||
|
||||
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */
|
||||
internal fun List<pw.binom.agentik.content.Content>.text(): String =
|
||||
filterIsInstance<pw.binom.agentik.content.Content.Text>().joinToString("\n") { it.body }
|
||||
@@ -19,7 +19,6 @@ import pw.binom.agentik.memory.mdvector.openHybridMemorySystem
|
||||
import pw.binom.agentik.memory.vector.VectorMemorySystem
|
||||
import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient
|
||||
import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
|
||||
import pw.binom.agentik.server.agentikAgent
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.standalone.agent.ChatAgent
|
||||
import pw.binom.agentik.llm.tools.LiteLlmContextCompactor
|
||||
@@ -30,31 +29,30 @@ import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
|
||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||
import pw.binom.agentik.standalone.llm.ModelDownloader
|
||||
import pw.binom.agentik.mcp.bridge.McpRegistry
|
||||
import pw.binom.agentik.integrations.telegram.TelegramBridgeComponent
|
||||
import pw.binom.agentik.integrations.telegram.createTelegramBridgeComponent
|
||||
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import java.io.File
|
||||
import pw.binom.agentik.skill.mining.SkillMiner
|
||||
|
||||
/**
|
||||
* standalone-контейнер agentik:
|
||||
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
|
||||
* POST /agentik/conversations -> ConversationSnapshot (201)
|
||||
* GET /agentik/conversations -> [ConversationSnapshot]
|
||||
* GET /agentik/conversations/{id} -> ConversationSnapshot
|
||||
* PATCH /agentik/conversations/{id} -> ConversationSnapshot
|
||||
* DELETE /agentik/conversations/{id} -> 204
|
||||
* POST /agentik/conversations/{id}/messages -> 202
|
||||
* POST /agentik/conversations/{id}/interrupt -> 202
|
||||
* GET /agentik/conversations/{id}/messages -> [Message]
|
||||
* GET /agentik/conversations/{id}/events -> text/event-stream (SSE)
|
||||
* GET /agentik/events -> text/event-stream (SSE, Agent-level)
|
||||
* POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)
|
||||
* GET /a2a/.well-known/agent-card.json -> AgentCard
|
||||
* GET /health -> "ok"
|
||||
* standalone-контейнер agentik: локальный агент с A2A-фасадом.
|
||||
*
|
||||
* Транспорт user↔agent (`:server` proto-фасад) из standalone удалён. Остаётся
|
||||
* встраиваемый Ktor (CIO), на котором смонтирован только A2A-фасад:
|
||||
* - GET /health -> "ok"
|
||||
* - POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)
|
||||
* - GET /a2a/.well-known/agent-card.json -> AgentCard
|
||||
* MCP-клиент (исходящий) и локальный агент — на месте; локальный транспорт
|
||||
* (IPC/stdio) добавим отдельно позже.
|
||||
*
|
||||
* Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
|
||||
* - AGENTIK_PORT / AGENTIK_DB_PATH
|
||||
* - AGENTIK_TOKEN — Bearer-токен для HTTP-фасада /agentik (пусто — авторизация выключена)
|
||||
* - AGENTIK_A2A_TOKEN — Bearer-токен для A2A-фасада /a2a (пусто — авторизация выключена)
|
||||
|
||||
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
|
||||
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
|
||||
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
|
||||
@@ -88,7 +86,7 @@ private fun printHelp() {
|
||||
println(
|
||||
"""
|
||||
agentik standalone — usage:
|
||||
java -jar agentik.jar Start HTTP server (A2A + :proto)
|
||||
java -jar agentik.jar Start standalone (A2A agent facade)
|
||||
java -jar agentik.jar pull-model Download the LiteRT-LM model from static.binom.pw
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -394,9 +392,32 @@ private fun runServer() {
|
||||
reflector = reflector,
|
||||
// Персистентный счётчик событий: offset/epoch переживают рестарт,
|
||||
// клиенты продолжают инкрементально, а не делают полный resync.
|
||||
outboxSequencer = sqliteStores.outboxSequencer,
|
||||
outboxCursorHolder = sqliteStores.outboxCursorHolder,
|
||||
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
|
||||
|
||||
// Telegram-интеграция. Подключается ТОЛЬКО при заданном
|
||||
// `AGENTIK_TELEGRAM_TOKEN` — иначе standalone ведёт себя как раньше.
|
||||
// Клиент (`HttpClient` + Ktor CIO) создаётся фабрикой
|
||||
// `createTelegramBridgeComponent` и закрывается shutdown-hook'ом ниже,
|
||||
// перед `agent.close()`. Polling/стрим-джобы живут на отдельном
|
||||
// `integrationsScope` — он отменяется ДО `agent.close()` тем же
|
||||
// shutdown-hook'ом (см. ниже), чтобы polling long-poll корректно
|
||||
// прервался и не блокировал JVM-выход.
|
||||
val integrationsScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val tgToken = config.integrations.telegram.token
|
||||
val tgClient: AutoCloseable? = if (!tgToken.isNullOrBlank()) {
|
||||
val (client, component) = createTelegramBridgeComponent(
|
||||
token = tgToken,
|
||||
scope = integrationsScope,
|
||||
connection = sqliteStores.connection,
|
||||
config = config.integrations.telegram,
|
||||
)
|
||||
agent.install(component)
|
||||
client
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
|
||||
// чтобы debug-эндпоинты могли его триггерить вручную.
|
||||
val curator: pw.binom.agentik.standalone.agent.memory.Curator? =
|
||||
@@ -407,38 +428,30 @@ private fun runServer() {
|
||||
c
|
||||
} else null
|
||||
|
||||
// A2A-фасад (agent↔agent). Proto-фасад user↔agent удалён.
|
||||
val server = embeddedServer(CIO, port = config.agent.port) {
|
||||
routing {
|
||||
get("/health") { call.respondText("ok") }
|
||||
agentikAgent(agent, path = "/agentik", token = config.agent.authToken)
|
||||
a2aAgent(
|
||||
agentName = "agentik",
|
||||
handler = A2aBridge(agent),
|
||||
path = "/a2a",
|
||||
token = config.agent.a2aToken,
|
||||
)
|
||||
if (config.debug.endpoints) {
|
||||
debugRoutes(
|
||||
agent = agent,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
reflector = reflector,
|
||||
skillMiner = skillMiner,
|
||||
skillStore = skillStore,
|
||||
curator = curator,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
println("agentik standalone listening on http://localhost:${config.agent.port}")
|
||||
println(" GET /health")
|
||||
println(" POST /agentik/conversations -> 201")
|
||||
println(" GET /agentik/conversations/{id}/events -> SSE")
|
||||
println(" POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)")
|
||||
println(" GET /a2a/.well-known/agent-card.json -> AgentCard")
|
||||
println(" storage: ${config.agent.dbPath}")
|
||||
println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
|
||||
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
|
||||
if (tgToken.isNullOrBlank()) {
|
||||
println(" telegram: disabled (AGENTIK_TELEGRAM_TOKEN not set)")
|
||||
} else {
|
||||
println(" telegram: enabled (polling timeout=${config.integrations.telegram.pollingTimeoutSec}s, persistent=${config.integrations.telegram.persistentConversations})")
|
||||
}
|
||||
println(" skills: ${skills.size} loaded${config.agent.skillsDir?.let { " from $it" } ?: ""}")
|
||||
if (config.agent.soulPath != null) println(" soul: ${config.agent.soulPath} (${soulBody?.length ?: 0} chars)")
|
||||
println(" memory: ${if (memorySystem == null) "disabled" else "${config.memory.backend.name.lowercase()}-backend"}")
|
||||
@@ -453,9 +466,6 @@ private fun runServer() {
|
||||
if (skillMiner != null) {
|
||||
println(" skill-mining: enabled (interval=${config.skillMining.interval} turns, maxTurns=${config.skillMining.maxTurns})")
|
||||
}
|
||||
if (config.debug.endpoints) {
|
||||
println(" debug endpoints: enabled (/debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact, /debug/tokens)")
|
||||
}
|
||||
// Раньше здесь был агрегат tokenStats() по всем conv'ам при старте. Метод
|
||||
// убран из :message-log-api (MessageStore стал чисто read-only list+listFlow);
|
||||
// см. agentik :message-log-api/MessageStore.kt. Token accounting теперь
|
||||
@@ -465,7 +475,11 @@ private fun runServer() {
|
||||
println(" conversations: ${existingConvs.size} (active)")
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
// Сначала отменяем integrations (polling/stream-джобы) — иначе
|
||||
// длинный Telegram-long-poll (до 30с) будет держать JVM.
|
||||
integrationsScope.cancel()
|
||||
agent.close()
|
||||
tgClient?.close()
|
||||
mcpRegistry.close()
|
||||
})
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
|
||||
@@ -31,8 +31,8 @@ import pw.binom.agentik.outbox.DurableEvent as OutboxEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||
import pw.binom.agentik.outbox.OffsetSequencer
|
||||
import pw.binom.agentik.outbox.inmemory.InMemoryOffsetSequencer
|
||||
import pw.binom.agentik.cursor.CursorHolder
|
||||
import pw.binom.agentik.cursor.inmemory.InMemoryCursorHolder
|
||||
import pw.binom.agentik.outbox.OnlineOutbox
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
@@ -150,11 +150,11 @@ internal class ChatAgent(
|
||||
/**
|
||||
* Счётчик событий агента. По умолчанию in-memory: offset'ы начинаются с 0
|
||||
* при каждом старте процесса. Production передаёт
|
||||
* [pw.binom.agentik.outbox.PersistentOffsetSequencer] поверх ksqlite
|
||||
* (`SqliteStores.outboxSequencer`), чтобы `offset`/`epoch` переживали
|
||||
* [pw.binom.agentik.cursor.ksqlite.KsqliteCursorHolder] поверх ksqlite
|
||||
* (`SqliteStores.outboxCursorHolder`), чтобы `offset`/`epoch` переживали
|
||||
* рестарт и клиент продолжал инкрементально (см. [Cursor]).
|
||||
*/
|
||||
private val outboxSequencer: OffsetSequencer = InMemoryOffsetSequencer(),
|
||||
private val outboxCursorHolder: CursorHolder = InMemoryCursorHolder(),
|
||||
) : MutableAgent, AutoCloseable {
|
||||
|
||||
/**
|
||||
@@ -218,18 +218,14 @@ private val testTools: MutableList<LiteTool> = mutableListOf()
|
||||
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
|
||||
* или подписке на два отдельных канала.
|
||||
*/
|
||||
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
|
||||
maxMessages = null,
|
||||
ttl = null,
|
||||
sequencer = outboxSequencer,
|
||||
)
|
||||
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore()
|
||||
|
||||
/**
|
||||
* Сериализатор durable-записей: reserve offset → state → event (см. [DurableLog]).
|
||||
* Делит [eventStore] со всеми беседами агента, поэтому offset'ы сквозные
|
||||
* по агенту (и по всем беседам сразу).
|
||||
*/
|
||||
private val durableLog: DurableLog = DurableLog(eventStore)
|
||||
private val durableLog: DurableLog = DurableLog(eventStore, outboxCursorHolder)
|
||||
|
||||
/**
|
||||
* Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры).
|
||||
@@ -319,7 +315,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
|
||||
* Оба маппатся в один [Flow], `SkillMiningComponent` сам решает что делать.
|
||||
*/
|
||||
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
|
||||
eventStore.events(after = null)
|
||||
eventStore.events()
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.mapNotNull { ce ->
|
||||
when (val e = ce.event) {
|
||||
@@ -574,13 +570,17 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot {
|
||||
val cursor = durableLog.currentCursor()
|
||||
val out = ArrayList<MessageRecord>()
|
||||
var afterSeq = -1L
|
||||
// В новой модели (v4) журнал адресуется по `createdAt: Instant` —
|
||||
// нет seq/cursor_offset. Стартуем от «начала времён» и пагинируем
|
||||
// смещением (`offset: Int`).
|
||||
var after: Instant = Instant.fromEpochMilliseconds(0L)
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val page = messageStore.list(conversationId, afterSeq, cursor.offset, JournalStore.PAGE_SIZE)
|
||||
val page = messageStore.list(conversationId, after, offset, JournalStore.PAGE_SIZE)
|
||||
if (page.isEmpty()) break
|
||||
out += page
|
||||
afterSeq = page.last().seq
|
||||
if (page.size < JournalStore.PAGE_SIZE) break
|
||||
offset += page.size
|
||||
}
|
||||
return ChatSnapshot(conversationId = conversationId, messages = out, cursor = cursor)
|
||||
}
|
||||
|
||||
+23
-6
@@ -3,7 +3,7 @@ package pw.binom.agentik.standalone.agent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
@@ -14,7 +14,18 @@ import pw.binom.agentik.outbox.OnlineEvent
|
||||
* - online ([OnlineEvent]) → [onlineStore], live-only (без catchup).
|
||||
*
|
||||
* Все durable-эмиссии идут через [DurableLog], чтобы state-row и парное
|
||||
* событие получали один offset (см. KDoc [DurableLog]).
|
||||
* событие появлялись синхронно (см. KDoc [DurableLog]).
|
||||
*
|
||||
* ## Catchup через [Cursor]
|
||||
*
|
||||
* В новой модели outbox — чисто live-flow ([OutboxStore.events]), без
|
||||
* replay. Для resume подписчик держит свой [Cursor] и запрашивает
|
||||
* снапшот через [pw.binom.agentik.proto.Agent.chatSnapshot] /
|
||||
* [conversationsSnapshot][pw.binom.agentik.proto.Agent.conversationsSnapshot];
|
||||
* потом прикладывает live-события, **сравнивая cursor** с тем, что идёт в
|
||||
* журнале. См. [events] — cold-flow от текущего outbox без фильтра
|
||||
* `after`: фильтрация делается на стороне подписчика, потому что outbox
|
||||
* уже выдал реплея.
|
||||
*/
|
||||
internal class ConversationEvents(
|
||||
private val durableLog: DurableLog,
|
||||
@@ -23,9 +34,9 @@ internal class ConversationEvents(
|
||||
) {
|
||||
/**
|
||||
* Атомарная durable-запись: сначала [writeState] (journal/...) с
|
||||
* забронированным `seq`, затем парное событие [event] с тем же offset.
|
||||
* забронированным `cursor`, затем парное событие [event] с тем же cursor.
|
||||
*/
|
||||
suspend fun <T> commit(writeState: suspend (seq: Long) -> T, event: (seq: Long) -> DurableEvent): T =
|
||||
suspend fun <T> commit(writeState: suspend (cursor: Cursor) -> T, event: (cursor: Cursor) -> DurableEvent): T =
|
||||
durableLog.commit(conversationId = conversationId, writeState = writeState, event = event)
|
||||
|
||||
/** Durable-событие без парной записи состояния. */
|
||||
@@ -43,8 +54,14 @@ internal class ConversationEvents(
|
||||
return true
|
||||
}
|
||||
|
||||
fun events(after: Cursor?): Flow<DurableEvent> =
|
||||
durableLog.outbox.conversationEvents(after = after, conversationId = conversationId)
|
||||
/**
|
||||
* Cold-flow durable-событий диалога. В новой модели outbox не
|
||||
* фильтрует по `after` (нет replay) — подписчик держит свой
|
||||
* [Cursor] и применяет дельты сам. Возвращает все события диалога,
|
||||
* эмитированные **после** вызова [events].
|
||||
*/
|
||||
fun events(): Flow<DurableEvent> =
|
||||
durableLog.outbox.conversationEvents(conversationId = conversationId)
|
||||
.map { it.event }
|
||||
|
||||
/** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */
|
||||
|
||||
+4
-5
@@ -195,8 +195,8 @@ class ConversationLoop(
|
||||
// State-first: journal.append + workingMemory под одним offset'ом,
|
||||
// затем парное durable-событие (см. DurableLog).
|
||||
events.commit(
|
||||
writeState = { seq ->
|
||||
messageStore.append(userRecord.copy(seq = seq))
|
||||
writeState = { _ ->
|
||||
messageStore.append(userRecord)
|
||||
workingMemoryStore.append(
|
||||
conversationId = id,
|
||||
entry = WorkingMemoryEntry.User(
|
||||
@@ -465,7 +465,7 @@ class ConversationLoop(
|
||||
// State-first: journal.append под забронированным offset'ом,
|
||||
// затем парное durable-событие.
|
||||
events.commit(
|
||||
writeState = { seq -> messageStore.append(assistantRecord.copy(seq = seq)) },
|
||||
writeState = { _ -> messageStore.append(assistantRecord) },
|
||||
event = {
|
||||
ProtoEvent.AssistantMessage(
|
||||
date = assistantAt,
|
||||
@@ -533,7 +533,7 @@ class ConversationLoop(
|
||||
val ts = now()
|
||||
if (!state.isTemporal) {
|
||||
events.commit(
|
||||
writeState = { seq ->
|
||||
writeState = { _ ->
|
||||
messageStore.append(
|
||||
MessageRecord.Error(
|
||||
id = newId("err"),
|
||||
@@ -541,7 +541,6 @@ class ConversationLoop(
|
||||
message = message,
|
||||
code = code,
|
||||
createdAt = ts,
|
||||
seq = seq,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -2,40 +2,50 @@ package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.cursor.CursorHolder
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
|
||||
/**
|
||||
* Сериализатор durable-записей агента: единственное место, где state-row и
|
||||
* парное ему outbox-событие получают **один и тот же** монотонный offset.
|
||||
* парное ему outbox-событие появляются **синхронно**.
|
||||
*
|
||||
* ## Зачем нужен commit-lock
|
||||
*
|
||||
* Протокол снапшотов держится на трёх инвариантах (см. README `:outbox-api`):
|
||||
* 1. **Producer**: сначала мутация состояния (journal/conversation), потом
|
||||
* `append` события — оба с одинаковым `seq`/`offset`;
|
||||
* 2. **Reader**: сначала `currentCursor()` (= последний **заапенденный**
|
||||
* offset), потом чтение состояния с отсечкой `seq <= C`; всё, что `> C`,
|
||||
* приедет потоком;
|
||||
* `append` события — оба с одинаковым [Cursor], выданным из [CursorHolder];
|
||||
* 2. **Reader**: сначала [currentCursor] (= следующий offset'ы **к выдаче**),
|
||||
* потом чтение состояния с отсечкой по `created_at <= cursor.offset`;
|
||||
* всё, что появилось позже, приедет потоком;
|
||||
* 3. **Идемпотентность** применения (upsert/delete по id).
|
||||
*
|
||||
* Инвариант (1) ломается, если два продюсера параллельно забронируют
|
||||
* offset'ы и запишут их в разном порядке (A резервирует 10, B — 11, B
|
||||
* аппендит первым → outbox отвергнет 10 как немонотонный, либо состояние
|
||||
* 10 «застрянет» ниже `currentCursor` и потеряется для подписчика).
|
||||
* [commit]/[appendConversation]/[appendAgent] держат [Mutex] на всё время
|
||||
* «reserve → запись состояния → append», поэтому durable-поток строго
|
||||
* линеен, а `currentCursor` всегда указывает на корректную точку отсечки.
|
||||
* offset'ы и запишут их в разном порядке. [commit] / [appendConversation] /
|
||||
* [appendAgent] держат [Mutex] на всё время «reserve → запись состояния →
|
||||
* append», поэтому durable-поток строго линеен, а [currentCursor] всегда
|
||||
* указывает на корректную точку отсечки.
|
||||
*
|
||||
* ## Почему outbox больше НЕ ведает offset'ами
|
||||
*
|
||||
* До миграции в `:outbox-api` outbox сам владел счётчиком (`reserveOffset`
|
||||
* / `currentCursor` / `offset` в `CommonEvent.Conversation`). После
|
||||
* рефакторинга v4 outbox — просто live-шина уведомлений; монотонный
|
||||
* `(createdAt, offset)` для удалённого resume — отдельная подсистема
|
||||
* `:cursor-api` (см. [CursorHolder]). [DurableLog] — единственная точка в
|
||||
* `ChatAgent`, которая сводит «journal/рабочая память ↔ outbox»: журнал
|
||||
* получает [Cursor] в `MessageRecord`, outbox — лишь дату и сам факт.
|
||||
*
|
||||
* Живёт в `:standalone` (а не в `:outbox-api`), потому что знает про
|
||||
* journal/conversation-сторы; outbox остаётся тупым хранилищем событий.
|
||||
* journal/conversation-сторы; outbox остаётся тупым потоком событий.
|
||||
*/
|
||||
class DurableLog(
|
||||
val outbox: MutableOutboxStore,
|
||||
private val cursorHolder: CursorHolder,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -43,35 +53,35 @@ class DurableLog(
|
||||
val events: OutboxStore get() = outbox
|
||||
|
||||
/** Точка отсечки для нового снапшота (см. протокол). */
|
||||
suspend fun currentCursor(): Cursor = outbox.currentCursor()
|
||||
suspend fun currentCursor(): Cursor = mutex.withLock { cursorHolder.current() }
|
||||
|
||||
/**
|
||||
* Атомарно: забронировать offset → записать состояние с этим `seq` →
|
||||
* Атомарно: забронировать курсор → записать состояние с этим [Cursor] →
|
||||
* аппендить парное событие. Возвращает результат [writeState].
|
||||
*/
|
||||
suspend fun <T> commit(
|
||||
conversationId: String,
|
||||
writeState: suspend (seq: Long) -> T,
|
||||
event: (seq: Long) -> DurableEvent,
|
||||
writeState: suspend (cursor: Cursor) -> T,
|
||||
event: (cursor: Cursor) -> DurableEvent,
|
||||
): T = mutex.withLock {
|
||||
val seq = outbox.reserveOffset()
|
||||
val result = writeState(seq)
|
||||
val e = event(seq)
|
||||
outbox.append(CommonEvent.Conversation(date = e.date, offset = seq, conversationId = conversationId, event = e))
|
||||
val cursor = cursorHolder.next()
|
||||
val result = writeState(cursor)
|
||||
val e = event(cursor)
|
||||
outbox.append(CommonEvent.Conversation(date = e.date, conversationId = conversationId, event = e))
|
||||
result
|
||||
}
|
||||
|
||||
/** Durable-событие диалога без парной записи состояния (Closing, CompactionTriggered, ...). */
|
||||
suspend fun appendConversation(conversationId: String, event: DurableEvent): Long = mutex.withLock {
|
||||
val seq = outbox.reserveOffset()
|
||||
outbox.append(CommonEvent.Conversation(date = event.date, offset = seq, conversationId = conversationId, event = event))
|
||||
seq
|
||||
suspend fun appendConversation(conversationId: String, event: DurableEvent): Cursor = mutex.withLock {
|
||||
val cursor = cursorHolder.next()
|
||||
outbox.append(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
|
||||
cursor
|
||||
}
|
||||
|
||||
/** Агент-level событие (Created/Deleted/Renamed/Touched) — состояние мутируется вызывающим до вызова. */
|
||||
suspend fun appendAgent(event: AgentEvent): Long = mutex.withLock {
|
||||
val seq = outbox.reserveOffset()
|
||||
outbox.append(CommonEvent.Agent(date = event.date, offset = seq, event = event))
|
||||
seq
|
||||
suspend fun appendAgent(event: AgentEvent): Cursor = mutex.withLock {
|
||||
val cursor = cursorHolder.next()
|
||||
outbox.append(CommonEvent.Agent(date = event.date, event = event))
|
||||
cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -66,11 +66,11 @@ internal class ReflectionScheduler(
|
||||
if (subscriptionJob?.isActive == true) return
|
||||
subscriptionJob = scope.launch {
|
||||
merge(
|
||||
eventStore.events(after = null)
|
||||
eventStore.events()
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.filter { it.event is DurableEvent.ConversationClosing && it.conversationId == conversationIdProvider() }
|
||||
.onEach { onClosing() },
|
||||
eventStore.events(after = null)
|
||||
eventStore.events()
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.filter { it.event is DurableEvent.ToolFailed && it.conversationId == conversationIdProvider() }
|
||||
.onEach { onToolFailure() },
|
||||
|
||||
+2
-4
@@ -40,7 +40,7 @@ internal class ToolDispatcher(
|
||||
if (!state.isTemporal) {
|
||||
// State-first: journal-запись и парное событие под одним offset'ом.
|
||||
events.commit(
|
||||
writeState = { seq ->
|
||||
writeState = { _ ->
|
||||
messageStore.append(
|
||||
MessageRecord.ToolCall(
|
||||
id = callId,
|
||||
@@ -49,7 +49,6 @@ internal class ToolDispatcher(
|
||||
toolTitle = null,
|
||||
toolArgsJson = argsJson,
|
||||
createdAt = nowTs,
|
||||
seq = seq,
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -99,7 +98,7 @@ internal class ToolDispatcher(
|
||||
if (!state.isTemporal) {
|
||||
// State-first: journal-запись и парное событие под одним offset'ом.
|
||||
events.commit(
|
||||
writeState = { seq ->
|
||||
writeState = { _ ->
|
||||
messageStore.append(
|
||||
MessageRecord.ToolResult(
|
||||
id = resultId,
|
||||
@@ -108,7 +107,6 @@ internal class ToolDispatcher(
|
||||
toolName = call.name,
|
||||
result = resultText,
|
||||
createdAt = resultAt,
|
||||
seq = seq,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import pw.binom.agentik.standalone.llm.GoogleConfig
|
||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||
import pw.binom.agentik.integrations.telegram.TelegramConfig
|
||||
import pw.binom.agentik.mcp.bridge.McpConfig
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
|
||||
@@ -37,6 +38,7 @@ data class AppConfig(
|
||||
val embedding: EmbeddingSection,
|
||||
val reflection: ReflectionSection,
|
||||
val skillMining: SkillMiningSection,
|
||||
val integrations: IntegrationsSection,
|
||||
val debug: DebugSection,
|
||||
) {
|
||||
/** HTTP-сервер, БД, skills, soul. */
|
||||
@@ -141,6 +143,18 @@ data class AppConfig(
|
||||
val maxTurns: Int = DEFAULT_SKILL_MINING_MAX_TURNS,
|
||||
)
|
||||
|
||||
/**
|
||||
* Конфиги мессенджер-интеграций. Каждая секция читается из env
|
||||
* (`AGENTIK_TELEGRAM_TOKEN`, …); пустой/незаданный токен ⇒ интеграция
|
||||
* выключена, в Main.kt соответствующий Component не инстанцируется.
|
||||
*
|
||||
* См. `:integrations-telegram` / `TelegramBridgeComponent`.
|
||||
*/
|
||||
@Serializable
|
||||
data class IntegrationsSection(
|
||||
val telegram: TelegramConfig = TelegramConfig(),
|
||||
)
|
||||
|
||||
/** Debug-эндпоинты (/debug/reflect, /debug/skill-mine, и т.д.). */
|
||||
@Serializable
|
||||
data class DebugSection(
|
||||
@@ -270,6 +284,16 @@ data class AppConfig(
|
||||
maxTurns = env("AGENTIK_SKILL_MINING_MAX_TURNS")?.toIntOrNull()
|
||||
?.coerceIn(1, 1000) ?: DEFAULT_SKILL_MINING_MAX_TURNS,
|
||||
),
|
||||
integrations = IntegrationsSection(
|
||||
telegram = TelegramConfig(
|
||||
token = env("AGENTIK_TELEGRAM_TOKEN")?.takeIf { it.isNotBlank() },
|
||||
pollingTimeoutSec = env("AGENTIK_TELEGRAM_POLLING_TIMEOUT")?.toIntOrNull()
|
||||
?.coerceIn(0, 60) ?: TelegramConfig.DEFAULT_POLLING_TIMEOUT_SEC,
|
||||
persistentConversations = env("AGENTIK_TELEGRAM_PERSISTENT")?.let {
|
||||
it.equals("1", ignoreCase = true) || it.equals("true", ignoreCase = true)
|
||||
} ?: TelegramConfig.DEFAULT_PERSISTENT_CONVERSATIONS,
|
||||
),
|
||||
),
|
||||
debug = DebugSection(
|
||||
endpoints = env("AGENTIK_DEBUG_ENDPOINTS")?.let {
|
||||
it.equals("1", ignoreCase = true) || it.equals("true", ignoreCase = true)
|
||||
|
||||
+17
-19
@@ -2,14 +2,12 @@ package pw.binom.agentik.standalone.persistence
|
||||
|
||||
import pw.binom.agentik.context.ContextStore
|
||||
import pw.binom.agentik.context.ksqlite.KsqliteContextStore
|
||||
import pw.binom.agentik.cursor.ksqlite.KsqliteCursorHolder
|
||||
import pw.binom.agentik.journal.MutableJournalStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
|
||||
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore
|
||||
import pw.binom.agentik.journal.ksqlite.Schema as JournalSchema
|
||||
import pw.binom.agentik.outbox.OffsetSequencer
|
||||
import pw.binom.agentik.outbox.PersistentOffsetSequencer
|
||||
import pw.binom.agentik.outbox.ksqlite.KsqliteCursorStore
|
||||
import pw.binom.agentik.reflection.ReflectionStore
|
||||
import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
@@ -38,12 +36,11 @@ internal class SqliteStores internal constructor(
|
||||
val messages: MutableJournalStore,
|
||||
val workingMemory: ContextStore,
|
||||
val reflections: ReflectionStore,
|
||||
private val outboxCursorStore: KsqliteCursorStore,
|
||||
/**
|
||||
* Персистентный счётчик событий агента (см. [PersistentOffsetSequencer]).
|
||||
* Персистентный счётчик событий агента (см. [KsqliteCursorHolder]).
|
||||
* Передаётся в `ChatAgent`, чтобы offset'ы переживали рестарт процесса.
|
||||
*/
|
||||
val outboxSequencer: OffsetSequencer,
|
||||
val outboxCursorHolder: KsqliteCursorHolder,
|
||||
) : AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
@@ -51,7 +48,7 @@ internal class SqliteStores internal constructor(
|
||||
messages.close()
|
||||
workingMemory.close()
|
||||
reflections.close()
|
||||
outboxCursorStore.close()
|
||||
outboxCursorHolder.close()
|
||||
connection.close()
|
||||
}
|
||||
|
||||
@@ -60,36 +57,37 @@ internal class SqliteStores internal constructor(
|
||||
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
|
||||
|
||||
private fun assemble(conn: SQLiteConnection): SqliteStores {
|
||||
val cursorStore = KsqliteCursorStore(conn)
|
||||
return SqliteStores(
|
||||
connection = conn,
|
||||
conversations = KsqliteMutableConversationStore(conn),
|
||||
messages = KsqliteJournalStore(conn),
|
||||
workingMemory = KsqliteContextStore(conn),
|
||||
reflections = KsqliteReflectionStore(conn),
|
||||
outboxCursorStore = cursorStore,
|
||||
outboxSequencer = PersistentOffsetSequencer(
|
||||
store = cursorStore,
|
||||
outboxCursorHolder = KsqliteCursorHolder(
|
||||
connection = conn,
|
||||
initialNext = { seedNextFromJournal(conn) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Стартовая позиция счётчика при первом создании [KsqliteCursorStore],
|
||||
* когда в БД уже есть сообщения (апгрейд): `MAX(seq) + 1`.
|
||||
* Стартовая позиция счётчика при первом создании [KsqliteCursorHolder],
|
||||
* когда в БД уже есть сообщения (апгрейд): `MAX(created_at) + 1`.
|
||||
*
|
||||
* Без этого новые offset'ы начинались бы с 0 и столкнулись бы с уже
|
||||
* записанными `seq` журнала (см. [PersistentOffsetSequencer]).
|
||||
* Без этого новые offset'ы начинались бы с `1` и могли бы оказаться
|
||||
* меньше уже записанных сообщений (в журнале v4 курсор-колонки
|
||||
* удалены — `created_at` остался единственным монотонным
|
||||
* идентификатором позиции, см. [pw.binom.agentik.journal.ksqlite.Schema.CURRENT_VERSION]).
|
||||
* Для пустой БД возвращает `1` — первый выдаваемый offset.
|
||||
*/
|
||||
private fun seedNextFromJournal(conn: SQLiteConnection): Long {
|
||||
private fun seedNextFromJournal(conn: SQLiteConnection): ULong {
|
||||
conn.prepare(
|
||||
"SELECT COALESCE(MAX(${JournalSchema.COL_SEQ}), -1) " +
|
||||
"SELECT COALESCE(MAX(${JournalSchema.COL_CREATED_AT}), 0) + 1 " +
|
||||
"FROM ${JournalSchema.TABLE_MESSAGE}"
|
||||
).use { stmt ->
|
||||
stmt.executeQuery().use { rs ->
|
||||
check(rs.next()) { "MAX(seq) must return a row" }
|
||||
return (rs.getLong(0) ?: -1L) + 1L
|
||||
check(rs.next()) { "MAX(created_at) must return a row" }
|
||||
return (rs.getLong(0) ?: 1L).toULong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-35
@@ -338,7 +338,7 @@ class ChatAgentTest {
|
||||
|
||||
val events = mutableListOf<ProtoEvent>()
|
||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
|
||||
agent.outbox.conversationEvents(conv.id).collect { events.add(it.event) }
|
||||
}
|
||||
val online = mutableListOf<OnlineEvent>()
|
||||
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
@@ -380,7 +380,7 @@ class ChatAgentTest {
|
||||
val durable = mutableListOf<ProtoEvent>()
|
||||
val online = mutableListOf<OnlineEvent>()
|
||||
val durableJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { durable.add(it.event) }
|
||||
agent.outbox.conversationEvents(conv.id).collect { durable.add(it.event) }
|
||||
}
|
||||
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
|
||||
@@ -420,7 +420,7 @@ class ChatAgentTest {
|
||||
// отправки событий подписка ничего не увидит.
|
||||
val events = mutableListOf<ProtoEvent>()
|
||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
|
||||
agent.outbox.conversationEvents(conv.id).collect { events.add(it.event) }
|
||||
}
|
||||
val online = mutableListOf<OnlineEvent>()
|
||||
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
@@ -484,7 +484,7 @@ class ChatAgentTest {
|
||||
// Подписываемся ДО send — SharedFlow без replay
|
||||
val events = mutableListOf<ProtoEvent>()
|
||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
|
||||
agent.outbox.conversationEvents(conv.id).collect { events.add(it.event) }
|
||||
}
|
||||
|
||||
val sendJob = launch {
|
||||
@@ -648,7 +648,7 @@ class ChatAgentTest {
|
||||
// Agent.events() удалён из :proto — события живут в
|
||||
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
|
||||
// распаковываем .event для получения AgentEvent.
|
||||
agent.outbox.agentEvents(agent.outbox.oldestCursor()).collect { events.add(it.event) }
|
||||
agent.outbox.agentEvents().collect { events.add(it.event) }
|
||||
}
|
||||
val conv = agent.createConversation(temp = false)
|
||||
agent.deleteConversation(conv.id)
|
||||
@@ -663,60 +663,76 @@ class ChatAgentTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chatSnapshot returns state plus cursor and resuming from it loses nothing`() = runTest {
|
||||
// Инвариант протокола: snapshot.messages ∪ дельты(offset > cursor) ==
|
||||
// финальное состояние. Ни одна строка не теряется и не дублируется
|
||||
// (дедуп делается по id у клиента, но здесь проверяем само покрытие).
|
||||
fun `chatSnapshot returns full state and live events after subscription extend it`() = runTest {
|
||||
// Новая семантика (v4, live-only outbox): snapshot — это «полное
|
||||
// состояние на момент cursor», дальнейшие изменения приходят
|
||||
// **только** live-flow'ом. Клиент сам мажет (snapshot ∪ live) и
|
||||
// делает дедуп по id. Здесь проверяем, что:
|
||||
// 1) chatSnapshot возвращает всё, что было на момент чтения;
|
||||
// 2) live-events, подписанные ДО snapshot, идут ВСЕ (нет пропусков);
|
||||
// 3) финальный snapshot содержит snapshot1 ∪ userId+assistantId.
|
||||
val agent = newAgent()
|
||||
fakeLlm.reply = "first reply"
|
||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||
conv.send(listOf(Content.Text("first user")))
|
||||
|
||||
val snap = agent.chatSnapshot(conv.id)
|
||||
assertEquals(agent.outbox.currentCursor(), snap.cursor, "snapshot cursor must equal current cursor")
|
||||
assertTrue(snap.messages.all { it.seq <= snap.cursor.offset }, "message beyond cursor: ${snap.messages}")
|
||||
assertTrue(snap.messages.any { it is MessageRecord.UserMessage })
|
||||
assertTrue(snap.messages.any { it is MessageRecord.AssistantMessage })
|
||||
|
||||
// Резюм строго «после курсора» — следующая дельта.
|
||||
val deltas = mutableListOf<CommonEvent.Conversation>()
|
||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
|
||||
.collect { deltas.add(it) }
|
||||
// Подписываемся на durable-события диалога ДО повторного send'а.
|
||||
val live = mutableListOf<ProtoEvent>()
|
||||
val liveJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(conv.id).collect { live.add(it.event) }
|
||||
}
|
||||
// Берём snapshot уже после первой отправки, но ДО второй —
|
||||
// новые сообщения едут потоком.
|
||||
val snap1 = agent.chatSnapshot(conv.id)
|
||||
assertTrue(snap1.messages.any { it is MessageRecord.UserMessage })
|
||||
assertTrue(snap1.messages.any { it is MessageRecord.AssistantMessage })
|
||||
|
||||
fakeLlm.reply = "second reply"
|
||||
conv.send(listOf(Content.Text("second user")))
|
||||
delay(50)
|
||||
job.cancel()
|
||||
liveJob.cancel()
|
||||
|
||||
assertTrue(deltas.isNotEmpty(), "no deltas after snapshot cursor")
|
||||
assertTrue(deltas.all { it.offset > snap.cursor.offset }, "delta <= cursor: $deltas")
|
||||
|
||||
val final = agent.chatSnapshot(conv.id)
|
||||
assertTrue(final.messages.size > snap.messages.size, "resume must advance state")
|
||||
val snapshotIds = snap.messages.map { it.id }.toSet()
|
||||
val deltaIds = deltas.mapNotNull { ce ->
|
||||
when (val e = ce.event) {
|
||||
// Live-flow получил ВСЕ durable-события обоих turn'ов.
|
||||
// (snap1 не отрезает «до подписки», поэтому live начинает ловить
|
||||
// и user1+assistant1, если они приходят позже подписки — но они
|
||||
// уже в outbox'е до подписки и SharedFlow без replay их не выдаёт.
|
||||
// Поэтому в live будут только события send'а#2, и это корректно.)
|
||||
val liveIds = live.mapNotNull { e ->
|
||||
when (e) {
|
||||
is ProtoEvent.UserMessage -> e.id
|
||||
is ProtoEvent.AssistantMessage -> e.id
|
||||
else -> null
|
||||
}
|
||||
}.toSet()
|
||||
assertEquals(final.messages.map { it.id }.toSet(), snapshotIds + deltaIds)
|
||||
|
||||
val snap2 = agent.chatSnapshot(conv.id)
|
||||
val snap2Ids = snap2.messages.map { it.id }.toSet()
|
||||
val snap1Ids = snap1.messages.map { it.id }.toSet()
|
||||
|
||||
// 1) live-events — подмножество финального состояния.
|
||||
assertTrue(liveIds.all { it in snap2Ids }, "live event id missing from snap2: $liveIds vs $snap2Ids")
|
||||
// 2) snap2 — строгое расширение snap1 (новый user/assistant).
|
||||
assertTrue(snap2Ids.size > snap1Ids.size, "snap2 must extend snap1: $snap1Ids -> $snap2Ids")
|
||||
// 3) новые id'ы в snap2 — ровно те, что приехали в live.
|
||||
val newInSnap2 = snap2Ids - snap1Ids
|
||||
assertEquals(liveIds, newInSnap2, "snap1 ∪ live must equal snap2: new=$newInSnap2 live=$liveIds")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversationsSnapshot carries cursor and a later chat arrives as an agent delta`() = runTest {
|
||||
fun `conversationsSnapshot returns full list and live agentEvents carry new conversations`() = runTest {
|
||||
// Новая семантика (v4): snapshot = полный список диалогов на момент
|
||||
// чтения, новые диалоги приходят live-flow'ом agentEvents().
|
||||
val agent = newAgent()
|
||||
val conv1 = agent.createConversation(temp = false)
|
||||
val snap = agent.conversationsSnapshot()
|
||||
assertEquals(agent.outbox.currentCursor(), snap.cursor)
|
||||
assertTrue(snap.conversations.any { it.id == conv1.id }, "snapshot=$snap")
|
||||
|
||||
val deltas = mutableListOf<AgentEvent>()
|
||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.agentEvents(after = snap.cursor).collect { deltas.add(it.event) }
|
||||
agent.outbox.agentEvents().collect { deltas.add(it.event) }
|
||||
}
|
||||
|
||||
val snap = agent.conversationsSnapshot()
|
||||
assertTrue(snap.conversations.any { it.id == conv1.id }, "snapshot=$snap")
|
||||
|
||||
val conv2 = agent.createConversation(temp = false)
|
||||
delay(50)
|
||||
job.cancel()
|
||||
@@ -725,6 +741,11 @@ class ChatAgentTest {
|
||||
deltas.any { it is AgentEvent.Created && it.conversationId == conv2.id },
|
||||
"deltas=$deltas",
|
||||
)
|
||||
|
||||
// Финальный snapshot содержит оба диалога.
|
||||
val snap2 = agent.conversationsSnapshot()
|
||||
assertTrue(snap2.conversations.any { it.id == conv1.id })
|
||||
assertTrue(snap2.conversations.any { it.id == conv2.id })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -4,6 +4,7 @@ import pw.binom.agentik.content.MessageOrigin
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||
|
||||
import kotlinx.coroutines.flow.toList
|
||||
@@ -129,10 +130,10 @@ class PersistenceTest {
|
||||
fun `message audit log - append and read back`() = runTest {
|
||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||
stores.messages.append(
|
||||
MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0),
|
||||
MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0, null),
|
||||
)
|
||||
stores.messages.append(
|
||||
MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0),
|
||||
MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0, null, null),
|
||||
)
|
||||
|
||||
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
||||
@@ -150,8 +151,8 @@ class PersistenceTest {
|
||||
fun `message after timestamp filter`() = runTest {
|
||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
||||
stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0))
|
||||
stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1))
|
||||
stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0, null))
|
||||
stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1, null))
|
||||
|
||||
val after = stores.messages.list("c1", after = t0, offset = 0, limit = 10)
|
||||
assertEquals(1, after.size)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# `:sync-core`
|
||||
|
||||
Референсная реализация ТЗ [`SYNC-SYSTEM.md`](../SYNC-SYSTEM.md) в изоляции от
|
||||
существующего кода `:outbox-api` / `:journal-api` / `:client` / `:server`.
|
||||
Цель — проверить, что спека действительно работает, и выработать API для
|
||||
переиспользования как в in-process (встроенный агент), так и в удалённом
|
||||
(HTTP/WebSocket) сценариях.
|
||||
|
||||
## Что здесь
|
||||
|
||||
| Слой | Где | Что |
|
||||
|---|---|---|
|
||||
| Domain types | `commonMain` | `DomainEvent` (sealed), `LoggedEvent`, `PendingEvent`, `Chat`/`Message`/`ChatState`/`StateSnapshot`, `CompactionState`, `SyncState` |
|
||||
| Интерфейсы | `commonMain` | `EventLog` / `EventLogWriter` / `EventLogReplica`, `StateStore`, `Reducer`, `Assistant`, `SyncTransport` |
|
||||
| In-memory бэкенд | `commonMain` | `InMemoryEventLog` (одновременно `Writer` + `Replica`), `InMemoryPendingQueue`, `InMemoryCompactionState`, `InMemorySyncState`, `DefaultChatReducer` |
|
||||
| SyncEngine | `commonMain` | Алгоритм §5.2 спеки (`sync()` + live-подписка) |
|
||||
| Transport | `commonMain` | `InProcessTransport` — имитация HTTP/WS поверх in-memory writer'а |
|
||||
| Assistant | `commonMain` | `Assistant` интерфейс + `LocalAssistant` (in-process генератор) + `RemoteAssistant` (заглушка под HTTP) |
|
||||
| Tests | `commonTest` | `SyncSpec` (abstract) + спец-тесты под все 7 инвариантов и ключевые сценарии §8 спеки |
|
||||
| ksqlite-бэкенд | `commonMain` | `SqliteEventLog` / `SqliteStateStore` / `SqlitePendingQueue` / `SqliteCompactionState` / `SqliteSyncState` |
|
||||
| ksqlite интеграционные тесты | `jvmTest` | `KsqliteSyncSpecTest` — тот же `SyncSpec`, но поверх in-memory SQLite-коннекшна |
|
||||
|
||||
## Границы
|
||||
|
||||
* Не реализует HTTP/WS/SSE (это отдельный слой поверх `SyncTransport`).
|
||||
* Не реализует LLM (assistant'ы — детерминированные генераторы).
|
||||
* Не реализует streaming-протокол ответа (стриминг дельт — отдельная
|
||||
концепция, в спеке его нет).
|
||||
* Не реализует мультидевайс-race (`origin` помечает события, но спека молчит
|
||||
о том, как разрешать конкурентные правки одного чата с двух устройств;
|
||||
last-write-wins по `seq` зашит в редьюсере).
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
./gradlew :sync-core:build # компиляция + все тесты
|
||||
./gradlew :sync-core:jvmTest # только JVM (включая ksqlite интеграцию)
|
||||
./gradlew :sync-core:allTests # все цели
|
||||
```
|
||||
|
||||
## Статус
|
||||
|
||||
Спека реализована вчерне, инварианты §2 покрыты тестами в `commonTest`. После
|
||||
стабилизации API и ksqlite-слоя возможна миграция существующих
|
||||
`:outbox-api`/`:journal-api`/`:client`/`:server` на эту модель (или нет — тогда
|
||||
`:sync-core` останется референсом и песочницей для новых идей).
|
||||
@@ -1,41 +0,0 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// Референсная реализация ТЗ `SYNC-SYSTEM.md` — журнал событий с монотонным
|
||||
// курсором + материализация (`Chat`/`Message` с `last_seq`), единый протокол
|
||||
// для клиента и сервера. KMP, всё в commonMain/commonTest где это возможно;
|
||||
// ksqlite-реализация — в commonMain (ksqlite публикует KMP-варианты для всех
|
||||
// десктоп-целей), интеграционные тесты под реальной SQLite — в jvmTest.
|
||||
//
|
||||
// Цели сборки:
|
||||
// - commonMain/commonTest содержат pure-Kotlin типы, интерфейсы, in-memory
|
||||
// реализации и спец-тесты (SyncSpec). Работают на всех 9 KMP-целях.
|
||||
// - jvmMain + jvmTest дополнительно содержат ksqlite-бэкенд (он же публикуется
|
||||
// для linuxX64/mingwX64, но запускать с реальной SQLite-схемой удобнее под JVM).
|
||||
//
|
||||
// Почему нет iOS/macOS целей: ksqlite не публикует варианты для них. Если
|
||||
// когда-нибудь понадобится, цели добавляются одной строкой.
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.ksqlite)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
// для файлового персистентного теста — KMP-обёртка вокруг fs.
|
||||
implementation(libs.kotlinx.io.core)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Локальный ассистент — генератор доменных событий в процессе клиента
|
||||
* (§4.4 спеки).
|
||||
*
|
||||
* Контракт:
|
||||
* - `run(input)` возвращает холодный [Flow]. Для каждого [DomainEvent] в
|
||||
* потоке клиент должен вызвать [EventLogReplica.enqueuePending] (или
|
||||
* аналог на клиенте) — он оптимистично применяет событие к локальной
|
||||
* материализации и добавляет его в очередь на отправку.
|
||||
* - `Flow` завершается, когда ассистент закончил генерацию.
|
||||
*
|
||||
* Сама логика генерации (вызов модели, парсинг, tool calls) делается
|
||||
* конкретной реализацией через [flowBuilder]. Здесь — абстракция.
|
||||
*/
|
||||
class LocalAssistant(
|
||||
private val flowBuilder: suspend (RunInput) -> Flow<DomainEvent>,
|
||||
) : Assistant {
|
||||
override fun run(input: RunInput): Flow<DomainEvent> = flow {
|
||||
flowBuilder(input).collect { e ->
|
||||
emit(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удобный конструктор для тестов: возвращает готовый [Flow] фиксированных
|
||||
* событий. Не использовать в production — это test-fixture.
|
||||
*/
|
||||
fun scriptedAssistant(events: List<DomainEvent>): Assistant = LocalAssistant { _ ->
|
||||
kotlinx.coroutines.flow.flowOf(*events.toTypedArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* RemoteAssistant — генератор, который **не пишет события сам**, а стримит
|
||||
* их от сервера через [SyncTransport.subscribeLive]. Эта заглушка существует
|
||||
* чтобы проиллюстрировать «ассистент — это просто генератор событий»
|
||||
* (§4.4 спеки): клиент не различает локального и удалённого.
|
||||
*
|
||||
* Полная реализация (подключение к WS-ручке ассистента конкретного
|
||||
* провайдера) выходит за рамки спеки синхронизации.
|
||||
*/
|
||||
fun remoteAssistant(
|
||||
transport: SyncTransport,
|
||||
after: suspend () -> Cursor,
|
||||
): Assistant = Assistant { _ ->
|
||||
flow {
|
||||
transport.subscribeLive(after()).collect { e ->
|
||||
// Для каждого серверного события, которое пришло в live-канал,
|
||||
// считаем его «ответом ассистента» — отдаём клиенту как
|
||||
// DomainEvent (payload без cursor/createdAt).
|
||||
emit(e.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlin.jvm.JvmInline
|
||||
|
||||
/**
|
||||
* Курсор журнала (§3.1.3 спеки) — пара `(epoch, number)`, однозначно
|
||||
* идентифицирующая позицию в логе событий.
|
||||
*
|
||||
* - **epoch** (String) — «поколение» журнала. Не меняется при обычной работе.
|
||||
* Сервер может его сменить (например, при wipe DB, restore из бэкапа, или
|
||||
* миграции на новую схему) — после этого клиент со старой эпохой обязан
|
||||
* сделать full resync (см. §6.3 спеки). Не может содержать `-` (используется
|
||||
* как разделитель в строковом представлении).
|
||||
* - **number** (ULong) — монотонный счётчик внутри эпохи. Присваивается
|
||||
* сервером при `append` (§2.1 спеки: «строго возрастает, без дыр»).
|
||||
*
|
||||
* Строковое представление — `"<epoch>-<number_hex>"`, например `"v1-2a"`.
|
||||
* Сериализуется и в JSON, и в SQLite TEXT как одна строка.
|
||||
*
|
||||
* Сериализация через [CursorSerializer] (зарегистрирован как `@Serializable(with=…)`).
|
||||
*/
|
||||
@Serializable(with = CursorSerializer::class)
|
||||
@JvmInline
|
||||
value class Cursor private constructor(private val raw: String) {
|
||||
|
||||
constructor(epoch: String, number: ULong) : this(buildRaw(epoch, number))
|
||||
|
||||
/** Поколение журнала. Меняется при wipe/restore/миграции сервера. */
|
||||
val epoch: String
|
||||
get() = raw.substring(0, raw.indexOf('-'))
|
||||
|
||||
/** Монотонный счётчик внутри эпохи. */
|
||||
val number: ULong
|
||||
get() = raw.substring(raw.indexOf('-') + 1).toULong(16)
|
||||
|
||||
/**
|
||||
* Следующий курсор в этой же эпохе. `number + 1`.
|
||||
*
|
||||
* **Без проверки на переполнение** — `ULong.MAX_VALUE.next()` тихо
|
||||
* оборачивается в 0. Сервер должен следить за `lastCursor.number` и
|
||||
* заранее вызывать [EventLogWriter.beginNewEpoch] (см. readme модуля).
|
||||
*/
|
||||
fun next(): Cursor = Cursor(epoch = epoch, number = number + 1uL)
|
||||
|
||||
/** Строковое представление `"<epoch>-<number_hex>"`. */
|
||||
fun toRawString(): String = raw
|
||||
|
||||
/**
|
||||
* Лексикографическое сравнение — сначала по `epoch`, потом по `number`.
|
||||
*
|
||||
* ⚠️ Курсоры из **разных эпох** несравнимы по смыслу «до/после»:
|
||||
* `compareTo` всё равно их упорядочит (для сортировки), но использовать
|
||||
* `cursor > other` для проверки свежести в разных эпохах бессмысленно.
|
||||
* Семантическая проверка «X свежее Y в той же эпохе» —
|
||||
* `cursor.epoch == other.epoch && cursor.number > other.number`
|
||||
* либо [isAfter]/[isBefore].
|
||||
*/
|
||||
operator fun compareTo(other: Cursor): Int {
|
||||
val epochCmp = epoch.compareTo(other.epoch)
|
||||
return if (epochCmp != 0) epochCmp else number.compareTo(other.number)
|
||||
}
|
||||
|
||||
/** «X свежее Y в той же эпохе». Возвращает `null`, если эпохи разные. */
|
||||
fun isAfter(other: Cursor): Boolean? =
|
||||
if (epoch != other.epoch) null else number > other.number
|
||||
|
||||
/** «X старше Y в той же эпохе». Возвращает `null`, если эпохи разные. */
|
||||
fun isBefore(other: Cursor): Boolean? =
|
||||
if (epoch != other.epoch) null else number < other.number
|
||||
|
||||
override fun toString(): String = "Cursor(epoch=$epoch, number=$number)"
|
||||
|
||||
companion object {
|
||||
/** Стартовый курсор: «не синхронизировано». Соответствует `epoch="v1", number=0`. */
|
||||
val ZERO: Cursor = Cursor("v1", 0uL)
|
||||
|
||||
/** Разобрать курсор из строкового представления. Бросает `IllegalArgumentException` при ошибке. */
|
||||
fun fromString(raw: String): Cursor {
|
||||
require(raw.length > 3) { "Cursor too short: \"$raw\"" }
|
||||
val sep = raw.indexOf('-')
|
||||
require(sep > 0) { "Cursor missing epoch/number separator: \"$raw\"" }
|
||||
require(raw.indexOf('-', sep + 1) == -1) {
|
||||
"Cursor has multiple separators: \"$raw\""
|
||||
}
|
||||
val numStr = raw.substring(sep + 1)
|
||||
val num = numStr.toULongOrNull(16)
|
||||
require(num != null) { "Cursor number part is not hex: \"$raw\"" }
|
||||
return Cursor(raw)
|
||||
}
|
||||
|
||||
private fun buildRaw(epoch: String, number: ULong): String {
|
||||
require(epoch.isNotEmpty()) { "epoch must be non-empty" }
|
||||
require(epoch.none { it == '-' }) { "epoch must not contain '-': \"$epoch\"" }
|
||||
return "$epoch-${number.toString(16)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Курсоры из **разных эпох несравнимы** по смыслу «до/после»: после
|
||||
* [EventLogWriter.beginNewEpoch] старые курсоры клиента невалидны, и
|
||||
* единственный допустимый порядок — заменить локальное состояние. Семантика
|
||||
* «свежее» — [Cursor.isAfter]/[Cursor.isBefore] либо явная проверка
|
||||
* `epoch == && number >`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* kotlinx-serialization `KSerializer` для [Cursor]. Кодируется как
|
||||
* примитивная строка формата `"<epoch>-<number_hex>"`.
|
||||
*/
|
||||
object CursorSerializer : KSerializer<Cursor> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("pw.binom.agentik.sync.Cursor", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Cursor) {
|
||||
encoder.encodeString(value.toRawString())
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Cursor =
|
||||
Cursor.fromString(decoder.decodeString())
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
/**
|
||||
* Причина, по которой курсор клиента больше не валиден.
|
||||
*
|
||||
* - [WRONG_EPOCH] — клиент работает со старой эпохой. Сервер сделал wipe /
|
||||
* restore / миграцию. Клиент должен сделать full resync (replaceState).
|
||||
* - [TOO_OLD] — клиент в правильной эпохе, но запрашивает события старше
|
||||
* [CompactionState.minAvailableCursor]. Сервер удалил старые события при
|
||||
* compaction. Клиент должен сделать full resync.
|
||||
*/
|
||||
enum class CursorExpiredReason { WRONG_EPOCH, TOO_OLD }
|
||||
@@ -1,129 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
/**
|
||||
* Редьюсер по умолчанию для [ChatState]. Применяет событие к состоянию,
|
||||
* обновляя затронутую строку и проставляя ей `lastCursor = event.cursor`.
|
||||
*
|
||||
* Правила (§2.3 спеки + §7.1 + §7.2):
|
||||
* - `ChatCreated` → добавляет чат.
|
||||
* - `ChatRenamed` → обновляет title чата.
|
||||
* - `ChatDeleted` → удаляет чат (см. §7.2).
|
||||
* - `MessageAppended` → добавляет сообщение в список чата.
|
||||
* - `MessageEdited` → заменяет content сообщения (несёт полный новый).
|
||||
* - `MessageDeleted` → **физически удаляет** из списка (см. §7.2).
|
||||
* - `ToolCall*` → добавляет сообщение с ролью TOOL_CALL / TOOL_RESULT / ERROR.
|
||||
*/
|
||||
object DefaultChatReducer : Reducer<ChatState> {
|
||||
|
||||
override fun reduce(state: ChatState, event: LoggedEvent): ChatState {
|
||||
val p = event.payload
|
||||
return when (p) {
|
||||
is DomainEvent.ChatCreated -> state.copy(
|
||||
chats = state.chats + (p.chatId to Chat(
|
||||
id = p.chatId,
|
||||
title = p.title,
|
||||
createdAt = p.createdAt,
|
||||
deleted = false,
|
||||
lastCursor = event.cursor,
|
||||
)),
|
||||
)
|
||||
|
||||
is DomainEvent.ChatRenamed -> state.copy(
|
||||
chats = state.chats.update(p.chatId) { old ->
|
||||
old.copy(title = p.newTitle, lastCursor = event.cursor)
|
||||
},
|
||||
)
|
||||
|
||||
is DomainEvent.ChatDeleted -> state.copy(
|
||||
chats = state.chats - p.chatId,
|
||||
)
|
||||
|
||||
is DomainEvent.MessageAppended -> state.copy(
|
||||
messages = state.messages.appendMessage(p.chatId, Message(
|
||||
id = p.messageId,
|
||||
chatId = p.chatId,
|
||||
role = p.role,
|
||||
content = p.content,
|
||||
parentId = p.parentId,
|
||||
deleted = false,
|
||||
lastCursor = event.cursor,
|
||||
createdAt = p.createdAt,
|
||||
)),
|
||||
)
|
||||
|
||||
is DomainEvent.MessageEdited -> state.copy(
|
||||
messages = state.messages.updateMessages(p.chatId) { list ->
|
||||
list.map { m ->
|
||||
if (m.id == p.messageId) m.copy(content = p.newContent, lastCursor = event.cursor) else m
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
is DomainEvent.MessageDeleted -> state.copy(
|
||||
messages = state.messages.updateMessages(p.chatId) { list ->
|
||||
list.filter { it.id != p.messageId }
|
||||
},
|
||||
)
|
||||
|
||||
is DomainEvent.ToolCallRequested -> state.copy(
|
||||
messages = state.messages.appendMessage(p.chatId, Message(
|
||||
id = p.messageId,
|
||||
chatId = p.chatId,
|
||||
role = MessageRole.TOOL_CALL,
|
||||
content = "${p.toolName}(${p.toolArgs})",
|
||||
parentId = null,
|
||||
deleted = false,
|
||||
lastCursor = event.cursor,
|
||||
createdAt = event.createdAt,
|
||||
)),
|
||||
)
|
||||
|
||||
is DomainEvent.ToolCallSucceeded -> state.copy(
|
||||
messages = state.messages.appendMessage(p.chatId, Message(
|
||||
id = p.messageId,
|
||||
chatId = p.chatId,
|
||||
role = MessageRole.TOOL_RESULT,
|
||||
content = p.result,
|
||||
parentId = p.toolCallId,
|
||||
deleted = false,
|
||||
lastCursor = event.cursor,
|
||||
createdAt = event.createdAt,
|
||||
)),
|
||||
)
|
||||
|
||||
is DomainEvent.ToolCallFailed -> state.copy(
|
||||
messages = state.messages.appendMessage(p.chatId, Message(
|
||||
id = p.messageId,
|
||||
chatId = p.chatId,
|
||||
role = MessageRole.ERROR,
|
||||
content = p.message,
|
||||
parentId = p.toolCallId,
|
||||
deleted = false,
|
||||
lastCursor = event.cursor,
|
||||
createdAt = event.createdAt,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <K, V> Map<K, V>.update(key: K, block: (V) -> V): Map<K, V> {
|
||||
val old = this[key] ?: return this
|
||||
return this + (key to block(old))
|
||||
}
|
||||
|
||||
private fun Map<ChatId, List<Message>>.updateMessages(
|
||||
chatId: ChatId,
|
||||
block: (List<Message>) -> List<Message>,
|
||||
): Map<ChatId, List<Message>> {
|
||||
val old = this[chatId] ?: emptyList()
|
||||
return this + (chatId to block(old))
|
||||
}
|
||||
|
||||
private fun Map<ChatId, List<Message>>.appendMessage(
|
||||
chatId: ChatId,
|
||||
message: Message,
|
||||
): Map<ChatId, List<Message>> {
|
||||
val old = this[chatId] ?: emptyList()
|
||||
return this + (chatId to (old + message))
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Доменное событие журнала (§3.1.1 спеки).
|
||||
*
|
||||
* Это «что произошло», без курсора и без идентификатора записи в журнале —
|
||||
* идентификатор и курсор появляются при записи (см. [LoggedEvent]). Каждое
|
||||
* событие самодостаточно (§2.4 спеки): несёт полный снимок изменённой
|
||||
* сущности в полях payload'а, чтобы клиент мог применить событие к
|
||||
* незнакомой строке.
|
||||
*
|
||||
* [eventId] — стабильный UUID, генерируется **продюсером** (клиентом для
|
||||
* pending-event'ов, сервером для server-side событий). Используется для
|
||||
* идемпотентности (§8.1 спеки).
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface DomainEvent {
|
||||
|
||||
/** Уникальный идентификатор события (UUID строкой). */
|
||||
val eventId: String
|
||||
|
||||
/** Чат создан. */
|
||||
@Serializable
|
||||
@SerialName("chat_created")
|
||||
data class ChatCreated(
|
||||
override val eventId: String,
|
||||
val chatId: ChatId,
|
||||
val title: String,
|
||||
val createdAt: Instant,
|
||||
) : DomainEvent
|
||||
|
||||
/** Чат переименован. */
|
||||
@Serializable
|
||||
@SerialName("chat_renamed")
|
||||
data class ChatRenamed(
|
||||
override val eventId: String,
|
||||
val chatId: ChatId,
|
||||
val newTitle: String,
|
||||
) : DomainEvent
|
||||
|
||||
/** Чат помечен удалённым. Replace при resync уберёт его у клиента. */
|
||||
@Serializable
|
||||
@SerialName("chat_deleted")
|
||||
data class ChatDeleted(
|
||||
override val eventId: String,
|
||||
val chatId: ChatId,
|
||||
) : DomainEvent
|
||||
|
||||
/** Сообщение добавлено. [parentId] — для ветвлений (regenerate), `null` в линейной истории. */
|
||||
@Serializable
|
||||
@SerialName("message_appended")
|
||||
data class MessageAppended(
|
||||
override val eventId: String,
|
||||
val messageId: MessageId,
|
||||
val chatId: ChatId,
|
||||
val role: MessageRole,
|
||||
val content: String,
|
||||
val parentId: MessageId? = null,
|
||||
val createdAt: Instant,
|
||||
) : DomainEvent
|
||||
|
||||
/** Сообщение отредактировано — несёт **полный новый content** (§7.1 спеки). */
|
||||
@Serializable
|
||||
@SerialName("message_edited")
|
||||
data class MessageEdited(
|
||||
override val eventId: String,
|
||||
val messageId: MessageId,
|
||||
val chatId: ChatId,
|
||||
val newContent: String,
|
||||
) : DomainEvent
|
||||
|
||||
/** Сообщение удалено физически (см. §7.2 — рекомендуется физическое удаление). */
|
||||
@Serializable
|
||||
@SerialName("message_deleted")
|
||||
data class MessageDeleted(
|
||||
override val eventId: String,
|
||||
val messageId: MessageId,
|
||||
val chatId: ChatId,
|
||||
) : DomainEvent
|
||||
|
||||
/** Ассистент начал вызов тула. */
|
||||
@Serializable
|
||||
@SerialName("tool_call_requested")
|
||||
data class ToolCallRequested(
|
||||
override val eventId: String,
|
||||
val messageId: MessageId,
|
||||
val chatId: ChatId,
|
||||
val toolName: String,
|
||||
val toolArgs: String,
|
||||
) : DomainEvent
|
||||
|
||||
/** Тул успешно вернул результат. */
|
||||
@Serializable
|
||||
@SerialName("tool_call_succeeded")
|
||||
data class ToolCallSucceeded(
|
||||
override val eventId: String,
|
||||
val messageId: MessageId,
|
||||
val chatId: ChatId,
|
||||
val toolCallId: MessageId,
|
||||
val toolName: String,
|
||||
val result: String,
|
||||
) : DomainEvent
|
||||
|
||||
/** Исполнение тула упало (handler exception, args не парсятся и т.п.). */
|
||||
@Serializable
|
||||
@SerialName("tool_call_failed")
|
||||
data class ToolCallFailed(
|
||||
override val eventId: String,
|
||||
val messageId: MessageId,
|
||||
val chatId: ChatId,
|
||||
val toolCallId: MessageId,
|
||||
val toolName: String,
|
||||
val message: String,
|
||||
) : DomainEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимок сообщения в материализованном состоянии. [lastCursor] — курсор, на
|
||||
* котором эта строка последний раз менялась. Используется для отсечки
|
||||
* «состояние на курсоре N» (все строки с `lastCursor.number <= N.number` при
|
||||
* `lastCursor.epoch == current_epoch`).
|
||||
*/
|
||||
@Serializable
|
||||
data class Message(
|
||||
val id: MessageId,
|
||||
val chatId: ChatId,
|
||||
val role: MessageRole,
|
||||
val content: String,
|
||||
val parentId: MessageId?,
|
||||
val deleted: Boolean,
|
||||
val lastCursor: Cursor,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class MessageRole { USER, ASSISTANT, TOOL_CALL, TOOL_RESULT, ERROR }
|
||||
|
||||
/** Снимок чата в материализованном состоянии. [lastCursor] — курсор, на котором строка менялась последний раз. */
|
||||
@Serializable
|
||||
data class Chat(
|
||||
val id: ChatId,
|
||||
val title: String,
|
||||
val createdAt: Instant,
|
||||
val deleted: Boolean,
|
||||
val lastCursor: Cursor,
|
||||
)
|
||||
|
||||
/**
|
||||
* Состояние на момент курсора [lastCursor]: все чаты и сообщения с
|
||||
* `lastCursor.number <= that.number` и `lastCursor.epoch == that.epoch`.
|
||||
* Поскольку материализация живёт только в текущей эпохе, эпоха всегда равна
|
||||
* текущей — см. [CompactionState.currentEpoch].
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatState(
|
||||
val chats: Map<ChatId, Chat> = emptyMap(),
|
||||
val messages: Map<ChatId, List<Message>> = emptyMap(),
|
||||
) {
|
||||
/**
|
||||
* Максимальный курсор по всем строкам в **текущей эпохе** (на которой мы
|
||||
* сейчас находимся). Для пустого состояния — [Cursor.ZERO].
|
||||
*
|
||||
* Используется как точка отсечки для снапшота («состояние на курсоре N» =
|
||||
* строки с `lastCursor.number <= N.number` при `lastCursor.epoch == N.epoch`).
|
||||
*/
|
||||
val lastCursor: Cursor
|
||||
get() {
|
||||
val epoch = chats.values.firstOrNull()?.lastCursor?.epoch
|
||||
?: messages.values.firstOrNull { it.isNotEmpty() }?.first()?.lastCursor?.epoch
|
||||
?: return Cursor.ZERO
|
||||
var max: ULong = 0uL
|
||||
for (c in chats.values) {
|
||||
if (c.lastCursor.epoch != epoch) continue
|
||||
if (c.lastCursor.number > max) max = c.lastCursor.number
|
||||
}
|
||||
for (ms in messages.values) for (m in ms) {
|
||||
if (m.lastCursor.epoch != epoch) continue
|
||||
if (m.lastCursor.number > max) max = m.lastCursor.number
|
||||
}
|
||||
return Cursor(epoch, max)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Снапшот состояния, возвращаемый `GET /sync/state`. Не несёт `upToCursor`
|
||||
* явно — сервер гарантирует, что все строки в [chats] и [messages] имеют
|
||||
* `lastCursor.number <= upToCursor.number` в текущей эпохе, и
|
||||
* `upToCursor` = [ChatState.lastCursor].
|
||||
*/
|
||||
@Serializable
|
||||
data class StateSnapshot(
|
||||
val state: ChatState,
|
||||
)
|
||||
|
||||
// value class требует @JvmInline на JVM, но @JvmInline недоступен на Native.
|
||||
// Используем обычный data class — потеряем zero-cost обёртку, но сохраним
|
||||
// KMP-совместимость. Если/когда JetBrains починит cross-target value classes,
|
||||
// можно будет вернуть.
|
||||
@Serializable
|
||||
data class ChatId(val raw: String) {
|
||||
override fun toString(): String = raw
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MessageId(val raw: String) {
|
||||
override fun toString(): String = raw
|
||||
}
|
||||
|
||||
/** Идентификатор локального pending-event'а (UUID строкой). */
|
||||
@Serializable
|
||||
data class LocalId(val raw : String) {
|
||||
override fun toString(): String = raw
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
sealed class SyncException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
|
||||
|
||||
class CursorExpiredException(
|
||||
val requested: Cursor,
|
||||
val currentEpoch: String,
|
||||
val minAvailable: Cursor,
|
||||
val current: Cursor,
|
||||
val reason: CursorExpiredReason,
|
||||
) : SyncException(
|
||||
"cursor $requested expired (reason=$reason, current=$current, minAvailable=$minAvailable)",
|
||||
)
|
||||
|
||||
class EventRejectedException(
|
||||
val eventId: String,
|
||||
reason: String,
|
||||
) : SyncException("event $eventId rejected: $reason")
|
||||
|
||||
class SeqMonotonicityViolationException(
|
||||
val previous: Long,
|
||||
val attempted: Long,
|
||||
) : SyncException("seq must be strictly increasing (previous=$previous, attempted=$attempted)")
|
||||
@@ -1,78 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Событие в журнале — [DomainEvent] + назначенный сервером [Cursor] (§3.1.1 спеки).
|
||||
*
|
||||
* Курсор сервера строго возрастает внутри эпохи (§2.1), никаких дыр. При смене
|
||||
* эпохи (wipe / restore / миграция) счётчик обнуляется и меняется `epoch`.
|
||||
*
|
||||
* `epoch` берётся из [Cursor]; `seq` оставлен как convenience — это
|
||||
* `cursor.number`, нужен редко (например, для отладки или для индексов в БД).
|
||||
*/
|
||||
data class LoggedEvent(
|
||||
val cursor: Cursor,
|
||||
val eventId: String,
|
||||
val payload: DomainEvent,
|
||||
val createdAt: Instant,
|
||||
val origin: String? = null,
|
||||
) {
|
||||
/** `cursor.number`. Удобно для SQL-предикатов и индексов. */
|
||||
val seq: ULong get() = cursor.number
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальное событие, отправленное клиентом, но ещё не подтверждённое сервером
|
||||
* (§3.2 спеки). Не имеет `cursor` — он появится после `POST /sync/events`.
|
||||
*
|
||||
* На клиенте переходит `PENDING → SENT` (получили `cursor`) или `PENDING →
|
||||
* FAILED` (сервер отверг — клиент откатывает локальное изменение).
|
||||
*
|
||||
* `sentCursor` непустой только в статусе [PendingStatus.SENT]: это курсор,
|
||||
* под которым событие зафиксировано в журнале сервера.
|
||||
*/
|
||||
data class PendingEvent(
|
||||
val localId: LocalId,
|
||||
val payload: DomainEvent,
|
||||
val createdAt: Instant,
|
||||
val status: PendingStatus,
|
||||
val sentCursor: Cursor? = null,
|
||||
)
|
||||
|
||||
enum class PendingStatus {
|
||||
/** Создан локально, на сервер ещё не ушёл. */
|
||||
PENDING,
|
||||
/** Сервер принял, `cursor` назначен (см. [PendingEvent.sentCursor]). */
|
||||
SENT,
|
||||
/** Сервер отверг (валидация / правки). Клиент откатывает локальное изменение. */
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние синхронизации клиента (§3.2 спеки). «До какого курсора мы
|
||||
* синхронизированы» + «когда последний раз был успешный sync».
|
||||
*
|
||||
* Курсор `Cursor.ZERO` (т.е. `(epoch, 0)`) означает «ничего не синхронизировано» —
|
||||
* клиент должен сделать full resync перед подпиской на live.
|
||||
*/
|
||||
data class SyncState(
|
||||
val cursor: Cursor = Cursor.ZERO,
|
||||
val lastSyncAt: Instant? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Метаданные компакции и эпохи журнала (§3.1.3 спеки).
|
||||
*
|
||||
* `minAvailableCursor` — самый старый курсор, который сервер может replay'ить.
|
||||
* Сервер стирает события с `cursor < minAvailableCursor` через `compact(upTo)`.
|
||||
*
|
||||
* `currentEpoch` дублирует [minAvailableCursor.epoch] для удобства: позволяет
|
||||
* клиенту проверять эпоху без раскрутки курсора.
|
||||
*/
|
||||
data class CompactionState(
|
||||
val currentEpoch: String,
|
||||
val minAvailableCursor: Cursor,
|
||||
val lastCompactedAt: Instant?,
|
||||
)
|
||||
@@ -1,160 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Общий интерфейс чтения журнала событий (§4.1 спеки).
|
||||
*
|
||||
* `readUpdates(after, limit)` — replay-чтение «события с `cursor > after`,
|
||||
* не старше `limit` штук». `lastCursor()` — текущая вершина журнала
|
||||
* (= [EventLogWriter.append] последнего события).
|
||||
*
|
||||
* Сервер и клиент имеют одинаковое представление о чтении, различаются
|
||||
* только операциями записи ([EventLogWriter] vs [EventLogReplica]).
|
||||
*/
|
||||
interface EventLog : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Возвращает события `cursor > after` в порядке возрастания `cursor`,
|
||||
* не более `limit` штук. Если событий больше — на стороне вызывающего
|
||||
* остаётся `lastCursor()` и он повторяет запрос (см. SyncEngine.sync).
|
||||
*
|
||||
* Поведение при `after`:
|
||||
* - `after.cursor.number < minAvailableCursor.number` при совпадающей
|
||||
* эпохе → бросает [CursorExpiredException] с [CursorExpiredReason.TOO_OLD].
|
||||
* - `after.cursor.epoch != currentEpoch` → бросает [CursorExpiredException]
|
||||
* с [CursorExpiredReason.WRONG_EPOCH].
|
||||
*/
|
||||
suspend fun readUpdates(after: Cursor, limit: Int): List<LoggedEvent>
|
||||
|
||||
/** Текущая вершина журнала. `Cursor.ZERO` если журнал пуст. */
|
||||
suspend fun lastCursor(): Cursor
|
||||
|
||||
/** Метаданные компакции и текущей эпохи. */
|
||||
suspend fun compaction(): CompactionState
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Серверная сторона журнала (§4.2 спеки). Добавляет атомарную запись,
|
||||
* компакцию и смену эпохи.
|
||||
*
|
||||
* Контракт [append]:
|
||||
* 1. Назначить `cursor` (монотонно, под мьютексом).
|
||||
* 2. В одной транзакции записать событие в журнал **и** применить его к
|
||||
* материализованному состоянию (§2.3 спеки: «materialization is
|
||||
* consistent with journal»). Если транзакция не удалась — ни событие,
|
||||
* ни state-row не должны остаться.
|
||||
*
|
||||
* Контракт [compact]:
|
||||
* 1. Удалить события `cursor.number <= upTo.number`.
|
||||
* 2. Обновить [CompactionState.minAvailableCursor] = `upTo.next()`.
|
||||
*
|
||||
* Контракт [beginNewEpoch]:
|
||||
* 1. Атомарно стереть все события журнала.
|
||||
* 2. Установить новую текущую эпоху.
|
||||
* 3. Сбросить `sqlite_sequence` для autoincrement-колонки.
|
||||
* 4. Вернуть `Cursor(newEpoch, 0)` — вершину «пустого» журнала.
|
||||
*
|
||||
* Используется при wipe / restore / миграции на новую схему (§6.3 спеки).
|
||||
* Клиенты со старой эпохой получат [CursorExpiredException] с reason
|
||||
* [CursorExpiredReason.WRONG_EPOCH] и обязаны сделать replaceState.
|
||||
*/
|
||||
interface EventLogWriter : EventLog {
|
||||
/** Атомарно записать событие, вернуть его с назначенным `cursor`. */
|
||||
suspend fun append(event: DomainEvent): LoggedEvent
|
||||
|
||||
/** Удалить события `cursor.number <= upTo.number` и сдвинуть minAvailableCursor. */
|
||||
suspend fun compact(upTo: Cursor)
|
||||
|
||||
/**
|
||||
* Атомарно стереть журнал и начать новую эпоху. Возвращает свежий
|
||||
* `Cursor(newEpoch, 0)`. Materialized state тоже стирается (вызывающий
|
||||
* должен заново его наполнить либо сделать replaceState из чтения).
|
||||
*/
|
||||
suspend fun beginNewEpoch(newEpoch: String): Cursor
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентская сторона журнала (§4.3 спеки). Дополнительно к [EventLog]:
|
||||
* применяет события сервера к локальной материализации, умеет делать
|
||||
* `replaceState` при resync и управляет локальной очередью `PendingEvent`.
|
||||
*
|
||||
* Контракт [applyRemote]:
|
||||
* - Событие уже закоммичено сервером — клиент только зеркалит.
|
||||
* - Идемпотентность по [LoggedEvent.eventId]: повторный apply — no-op.
|
||||
* - События применяются **в порядке `cursor`**. Если есть пропуск —
|
||||
* [CursorMonotonicityViolationException] (см. §8.2 спеки).
|
||||
*
|
||||
* Контракт [replaceState]:
|
||||
* - Полная замена локального состояния. Не merge (§2.5 спеки).
|
||||
* - Курсор после replace = `snapshot.state.lastCursor`.
|
||||
* - `PendingEvent`'ы **сохраняются** (см. §5.2 шаг 5 — они отправляются
|
||||
* после resync).
|
||||
*
|
||||
* Контракт pending-API:
|
||||
* - [enqueuePending] — добавить новое локальное событие в очередь.
|
||||
* - [pendingEvents] — все события со статусом PENDING (для отправки на сервер).
|
||||
* - [markPendingAsSent] / [markPendingAsFailed] — финализация.
|
||||
*/
|
||||
interface EventLogReplica : EventLog {
|
||||
/** Применить событие от сервера к локальной материализации. */
|
||||
suspend fun applyRemote(event: LoggedEvent)
|
||||
|
||||
/**
|
||||
* Заменить локальное состояние снапшотом с сервера. Курсор сбрасывается
|
||||
* на `snapshot.state.lastCursor`.
|
||||
*/
|
||||
suspend fun replaceState(snapshot: StateSnapshot)
|
||||
|
||||
/** Все pending-события со статусом PENDING. */
|
||||
suspend fun pendingEvents(): List<PendingEvent>
|
||||
|
||||
/** Добавить локальное событие в очередь (статус PENDING). */
|
||||
suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent
|
||||
|
||||
/** Сервер принял событие под [cursor]. */
|
||||
suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor)
|
||||
|
||||
/** Сервер отверг событие. Клиент откатывает локальное изменение. */
|
||||
suspend fun markPendingAsFailed(localId: LocalId)
|
||||
|
||||
/** Локальное состояние синхронизации (`cursor` + `lastSyncAt`). */
|
||||
suspend fun syncState(): SyncState
|
||||
}
|
||||
|
||||
/**
|
||||
* Материализованное состояние (§4.1 спеки).
|
||||
*
|
||||
* Сервер пишет сюда через [applyOnServer] (атомарно с записью в журнал,
|
||||
* см. [EventLogWriter.append]). Клиент пишет через [applyOnClient]
|
||||
* (= [EventLogReplica.applyRemote]). Оба используют один и тот же [Reducer].
|
||||
*/
|
||||
interface StateStore : AutoCloseable {
|
||||
|
||||
/** Применить событие к состоянию и сохранить (серверная сторона). */
|
||||
suspend fun applyOnServer(event: LoggedEvent)
|
||||
|
||||
/** Применить событие к состоянию и сохранить (клиентская сторона). */
|
||||
suspend fun applyOnClient(event: LoggedEvent)
|
||||
|
||||
/** Полный снапшот состояния (= все строки, `lastCursor` = [ChatState.lastCursor]). */
|
||||
suspend fun readState(): StateSnapshot
|
||||
|
||||
/** Курсор — максимум `lastCursor` по строкам в текущей эпохе (= точка отсечки снапшота). */
|
||||
suspend fun lastCursor(): Cursor
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Редьюсер — чистая функция «событие + старое состояние → новое состояние».
|
||||
*
|
||||
* Один и тот же редьюсер работает и на сервере (применяется в транзакции
|
||||
* `append`), и на клиенте (применяется через `applyRemote`). Это и есть
|
||||
* «один и тот же код для клиента и сервера» из §0 спеки.
|
||||
*/
|
||||
fun interface Reducer<S> {
|
||||
fun reduce(state: S, event: LoggedEvent): S
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
/**
|
||||
* Алгоритм синхронизации (§5.2 спеки). Один объект живёт на клиенте, поверх
|
||||
* [EventLogReplica] и [SyncTransport]. Два тела:
|
||||
*
|
||||
* - [sync]: однократный catchup + post pending. Возвращает [SyncReport]
|
||||
* с числом apply'нутых событий и финальным `cursor`.
|
||||
* - [liveFromCurrent]: долгоживущий Flow, подписывается на сервер и применяет
|
||||
* каждое событие к локальной материализации через [EventLogReplica.applyRemote].
|
||||
* При разрыве/исключении Flow завершается — вызывающий решает, что делать
|
||||
* (типичный паттерн: `retry { liveFromCurrent() }`).
|
||||
*
|
||||
* [sync] идемпотентен — повторный вызов ничего не сломает: догрузит только
|
||||
* новые события, переотправит только pending.
|
||||
*/
|
||||
class SyncEngine(
|
||||
private val replica: EventLogReplica,
|
||||
private val transport: SyncTransport,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Однократный sync: replay-читать с `after = lastCursor`, разрешить
|
||||
* протухший курсор через replaceState, отправить pending, дождаться
|
||||
* подтверждений.
|
||||
*
|
||||
* Возвращает [SyncReport] для тестов и UI-метрик.
|
||||
*/
|
||||
suspend fun sync(pageSize: Int = 1024): SyncReport {
|
||||
var applied = 0
|
||||
var resolvedGap = false
|
||||
|
||||
// ── Шаги 1-4 спеки: catchup ────────────────────────────────────────
|
||||
while (true) {
|
||||
val after = replica.syncState().cursor
|
||||
val resp = transport.fetchUpdates(after = after, limit = pageSize)
|
||||
when (resp) {
|
||||
is UpdatesResponse.Ok -> {
|
||||
if (resp.events.isEmpty() && !resp.hasMore) break
|
||||
for (e in resp.events) {
|
||||
replica.applyRemote(e)
|
||||
applied++
|
||||
}
|
||||
if (!resp.hasMore) break
|
||||
}
|
||||
is UpdatesResponse.CursorExpired -> {
|
||||
// ── Шаг 3 спеки: full resync ───────────────────────────
|
||||
val state = transport.fetchState()
|
||||
if (state !is StateResponse.Ok) error("StateResponse is always Ok in spec")
|
||||
replica.replaceState(state.snapshot)
|
||||
resolvedGap = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Шаги 5-6 спеки: post pending ──────────────────────────────────
|
||||
val pending = replica.pendingEvents()
|
||||
var posted = 0
|
||||
var failed = 0
|
||||
if (pending.isNotEmpty()) {
|
||||
val results = transport.postPending(pending)
|
||||
for (r in results) {
|
||||
when (r) {
|
||||
is PostResult.Accepted -> {
|
||||
replica.markPendingAsSent(r.localId, r.cursor)
|
||||
posted++
|
||||
}
|
||||
is PostResult.Rejected -> {
|
||||
replica.markPendingAsFailed(r.localId)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SyncReport(
|
||||
appliedRemote = applied,
|
||||
postedPending = posted,
|
||||
failedPending = failed,
|
||||
resolvedCursorGap = resolvedGap,
|
||||
finalCursor = replica.syncState().cursor,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Live sync: долгоживущий Flow. Подписывается на [SyncTransport.subscribeLive]
|
||||
* и применяет каждое событие через [EventLogReplica.applyRemote].
|
||||
*/
|
||||
fun liveFromCurrent(after: Cursor? = null): Flow<LoggedEvent> = flow {
|
||||
val start = after ?: replica.syncState().cursor
|
||||
transport.subscribeLive(start).collect { e ->
|
||||
replica.applyRemote(e)
|
||||
emit(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SyncReport(
|
||||
val appliedRemote: Int,
|
||||
val postedPending: Int,
|
||||
val failedPending: Int,
|
||||
val resolvedCursorGap: Boolean,
|
||||
val finalCursor: Cursor,
|
||||
)
|
||||
@@ -1,119 +0,0 @@
|
||||
package pw.binom.agentik.sync
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Транспорт синхронизации — сетевой слой между клиентом и сервером (§5.1 спеки).
|
||||
*
|
||||
* Спека описывает четыре endpoint'а: `GET /sync/state`, `GET /sync/updates`,
|
||||
* `WS /sync/live`, `POST /sync/events`. Здесь они выражены как четыре метода
|
||||
* интерфейса — конкретный транспорт (HTTP+WS, in-process, mock) реализует их
|
||||
* по-своему.
|
||||
*
|
||||
* Контракт:
|
||||
* - [fetchUpdates] может вернуть [UpdatesResponse.CursorExpired] с reason
|
||||
* [CursorExpiredReason.WRONG_EPOCH] (epoch mismatch) или
|
||||
* [CursorExpiredReason.TOO_OLD] (компакция переехала курсор).
|
||||
* - [subscribeLive] — холодный Flow, отдаёт события `cursor > after` и
|
||||
* завершается при разрыве (см. §5.2 шаг 6.2).
|
||||
* - [postPending] — список результатов той же длины, что и вход; порядок
|
||||
* сохраняется (клиент матчит по индексу / [LocalId]).
|
||||
*/
|
||||
interface SyncTransport : AutoCloseable {
|
||||
|
||||
suspend fun fetchUpdates(after: Cursor, limit: Int): UpdatesResponse
|
||||
|
||||
suspend fun fetchState(): StateResponse
|
||||
|
||||
suspend fun postPending(events: List<PendingEvent>): List<PostResult>
|
||||
|
||||
fun subscribeLive(after: Cursor): Flow<LoggedEvent>
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
sealed interface UpdatesResponse {
|
||||
/** Успешный replay — пачка событий + индикатор «есть ещё» + текущий курсор. */
|
||||
data class Ok(
|
||||
val events: List<LoggedEvent>,
|
||||
val hasMore: Boolean,
|
||||
val currentCursor: Cursor,
|
||||
) : UpdatesResponse
|
||||
|
||||
/**
|
||||
* Курсор протух. Клиент обязан сделать full resync через
|
||||
* [SyncTransport.fetchState] и [EventLogReplica.replaceState].
|
||||
*
|
||||
* - [reason] = [CursorExpiredReason.WRONG_EPOCH] → сервер сменил эпоху,
|
||||
* клиент должен пересинхронизироваться с нуля.
|
||||
* - [reason] = [CursorExpiredReason.TOO_OLD] → компакция удалила старые
|
||||
* события, клиент должен пересинхронизироваться с нуля.
|
||||
*/
|
||||
data class CursorExpired(
|
||||
val requested: Cursor,
|
||||
val minAvailable: Cursor,
|
||||
val current: Cursor,
|
||||
val reason: CursorExpiredReason,
|
||||
) : UpdatesResponse
|
||||
}
|
||||
|
||||
sealed interface StateResponse {
|
||||
/**
|
||||
* Снапшот состояния + курсор, до которого материализация консистентна.
|
||||
* Сервер гарантирует, что все строки в `snapshot.state.chats/messages`
|
||||
* имеют `lastCursor.number <= upToCursor.number` в текущей эпохе, и
|
||||
* `upToCursor` = `snapshot.state.lastCursor`.
|
||||
*/
|
||||
data class Ok(
|
||||
val snapshot: StateSnapshot,
|
||||
val upToCursor: Cursor,
|
||||
val currentCursor: Cursor,
|
||||
) : StateResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат `POST /sync/events` для одного pending-event'а.
|
||||
*
|
||||
* Идемпотентность по `eventId`: сервер может ответить [Accepted] уже
|
||||
* назначенным ранее `cursor` (повторная отправка того же `eventId`).
|
||||
*/
|
||||
sealed interface PostResult {
|
||||
data class Accepted(val localId: LocalId, val cursor: Cursor) : PostResult
|
||||
data class Rejected(val localId: LocalId, val reason: String) : PostResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск ассистента (§5.4 спеки).
|
||||
*
|
||||
* Клиент подписывается на поток событий и применяет их так же, как события
|
||||
* от сервера. Локальный ассистент порождает `PendingEvent`, который потом
|
||||
* уходит на сервер и подтверждается.
|
||||
*
|
||||
* Контракт:
|
||||
* - `run(input)` запускает генерацию, возвращает холодный `Flow<DomainEvent>`.
|
||||
* - Каждое событие несёт **стабильный** `eventId` (для идемпотентности).
|
||||
* - `Flow` завершается, когда ассистент закончил генерацию. Ошибка внутри
|
||||
* потока пробрасывается вызывающему.
|
||||
*/
|
||||
fun interface Assistant {
|
||||
fun run(input: RunInput): Flow<DomainEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* Параметры запуска ассистента (§4.4 спеки).
|
||||
*
|
||||
* - [chatId] — в каком чате генерируется ответ.
|
||||
* - [userMessageId] — id только что созданного user-message'а
|
||||
* (ассистент может сослаться на него в reasoning).
|
||||
* - [reasoning] — что просит пользователь (например, текст сообщения).
|
||||
* - [parentId] — какой message перегенерь (ветка), `null` для линейного ответа.
|
||||
*/
|
||||
@Serializable
|
||||
data class RunInput(
|
||||
val chatId: ChatId,
|
||||
val userMessageId: MessageId,
|
||||
val reasoning: String,
|
||||
val parentId: MessageId? = null,
|
||||
)
|
||||
@@ -1,41 +0,0 @@
|
||||
package pw.binom.agentik.sync.ksqlite
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
import pw.binom.agentik.sync.DomainEvent
|
||||
|
||||
/**
|
||||
* JSON-сериализация [DomainEvent] для хранения в SQLite.
|
||||
*
|
||||
* Все события полиморфно сериализуются с дискриминантом `type` (см.
|
||||
* [DomainEvent] — `@SerialName(...)` на каждом подтипе). Полезная нагрузка —
|
||||
* полный payload события (§2.4 спеки: «самодостаточны»).
|
||||
*/
|
||||
object EventCodec {
|
||||
|
||||
val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
classDiscriminator = "type"
|
||||
serializersModule = SerializersModule {
|
||||
polymorphic(DomainEvent::class) {
|
||||
subclass(DomainEvent.ChatCreated::class)
|
||||
subclass(DomainEvent.ChatRenamed::class)
|
||||
subclass(DomainEvent.ChatDeleted::class)
|
||||
subclass(DomainEvent.MessageAppended::class)
|
||||
subclass(DomainEvent.MessageEdited::class)
|
||||
subclass(DomainEvent.MessageDeleted::class)
|
||||
subclass(DomainEvent.ToolCallRequested::class)
|
||||
subclass(DomainEvent.ToolCallSucceeded::class)
|
||||
subclass(DomainEvent.ToolCallFailed::class)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun encode(event: DomainEvent): String = json.encodeToString(DomainEvent.serializer(), event)
|
||||
|
||||
fun decode(jsonText: String): DomainEvent =
|
||||
json.decodeFromString(DomainEvent.serializer(), jsonText)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package pw.binom.agentik.sync.ksqlite
|
||||
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/**
|
||||
* Имена таблиц / колонок / индексов для ksqlite-бэкенда `:sync-core`.
|
||||
*
|
||||
* Реализует схему из §3.1 / §3.2 спеки с курсором `(epoch, number)`
|
||||
* (§3.1.3). Семь таблиц:
|
||||
* - `event_log` — append-only журнал (§3.1.1), `seq` монотонен
|
||||
* внутри эпохи, `epoch` — safety-belt на случай гонок при wipe;
|
||||
* - `chat_state` — материализация чатов (§3.1.2);
|
||||
* - `message_state` — материализация сообщений (§3.1.2);
|
||||
* - `compaction_state` — метаданные компакции + текущая эпоха (§3.1.3),
|
||||
* одна строка;
|
||||
* - `sync_state` — клиентский `cursor` (§3.2), одна строка;
|
||||
* - `pending_event` — очередь pending-событий (§3.2);
|
||||
* - `processed_event_id` — для идемпотентности по `eventId` (§8.1).
|
||||
*
|
||||
* В БД эпоха и номер курсора лежат в **отдельных колонках**: `epoch TEXT`
|
||||
* (если есть в таблице), `seq INTEGER` для `last_seq` / `min_available_seq`.
|
||||
* Курсор как объект [pw.binom.agentik.sync.Cursor] собирается при чтении.
|
||||
*
|
||||
* Все DDL/DML в этом модуле должны ссылаться на эти константы.
|
||||
*/
|
||||
object Schema {
|
||||
|
||||
const val TABLE_EVENT_LOG = "sync_event_log"
|
||||
const val TABLE_CHAT_STATE = "sync_chat_state"
|
||||
const val TABLE_MESSAGE_STATE = "sync_message_state"
|
||||
const val TABLE_COMPACTION_STATE = "sync_compaction_state"
|
||||
const val TABLE_SYNC_STATE = "sync_sync_state"
|
||||
const val TABLE_PENDING_EVENT = "sync_pending_event"
|
||||
const val TABLE_PROCESSED_EVENT_ID = "sync_processed_event_id"
|
||||
|
||||
const val COL_SEQ = "seq"
|
||||
const val COL_EPOCH = "epoch"
|
||||
const val COL_EVENT_ID = "event_id"
|
||||
const val COL_PAYLOAD_JSON = "payload_json"
|
||||
const val COL_CREATED_AT = "created_at"
|
||||
const val COL_ORIGIN = "origin"
|
||||
|
||||
const val COL_CHAT_ID = "chat_id"
|
||||
const val COL_TITLE = "title"
|
||||
const val COL_DELETED = "deleted"
|
||||
const val COL_LAST_SEQ = "last_seq"
|
||||
|
||||
const val COL_MESSAGE_ID = "message_id"
|
||||
const val COL_ROLE = "role"
|
||||
const val COL_CONTENT = "content"
|
||||
const val COL_PARENT_ID = "parent_id"
|
||||
|
||||
const val COL_LOCAL_ID = "local_id"
|
||||
const val COL_TYPE = "type"
|
||||
const val COL_STATUS = "status"
|
||||
const val COL_SENT_CURSOR = "sent_cursor"
|
||||
|
||||
const val COL_MIN_AVAILABLE_SEQ = "min_available_seq"
|
||||
const val COL_LAST_COMPACTED_AT = "last_compacted_at"
|
||||
|
||||
const val COL_SYNC_CURSOR = "cursor"
|
||||
const val COL_LAST_SYNC_AT = "last_sync_at"
|
||||
|
||||
/** Эпоха по умолчанию для новой БД. */
|
||||
const val DEFAULT_EPOCH = "v1"
|
||||
|
||||
private val eventLogDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_EVENT_LOG (
|
||||
$COL_SEQ INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$COL_EPOCH TEXT NOT NULL,
|
||||
$COL_EVENT_ID TEXT NOT NULL UNIQUE,
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL,
|
||||
$COL_ORIGIN TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
private val chatStateDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_CHAT_STATE (
|
||||
$COL_CHAT_ID TEXT PRIMARY KEY,
|
||||
$COL_TITLE TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL,
|
||||
$COL_DELETED INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_LAST_SEQ INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
private val messageStateDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE_STATE (
|
||||
$COL_MESSAGE_ID TEXT PRIMARY KEY,
|
||||
$COL_CHAT_ID TEXT NOT NULL,
|
||||
$COL_ROLE TEXT NOT NULL,
|
||||
$COL_CONTENT TEXT NOT NULL,
|
||||
$COL_PARENT_ID TEXT,
|
||||
$COL_DELETED INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_LAST_SEQ INTEGER NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
private val messageStateIdx = """
|
||||
CREATE INDEX IF NOT EXISTS idx_message_state_chat_seq
|
||||
ON $TABLE_MESSAGE_STATE($COL_CHAT_ID, $COL_LAST_SEQ);
|
||||
"""
|
||||
|
||||
private val compactionStateDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_COMPACTION_STATE (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
$COL_EPOCH TEXT NOT NULL,
|
||||
$COL_MIN_AVAILABLE_SEQ INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_LAST_COMPACTED_AT INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
private val syncStateDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_SYNC_STATE (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
$COL_SYNC_CURSOR TEXT NOT NULL,
|
||||
$COL_LAST_SYNC_AT INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
private val pendingEventDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_PENDING_EVENT (
|
||||
$COL_LOCAL_ID TEXT PRIMARY KEY,
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL,
|
||||
$COL_STATUS TEXT NOT NULL,
|
||||
$COL_SENT_CURSOR TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
private val processedEventIdDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_PROCESSED_EVENT_ID (
|
||||
$COL_EVENT_ID TEXT PRIMARY KEY
|
||||
);
|
||||
"""
|
||||
|
||||
private val eventLogEpochIdx = """
|
||||
CREATE INDEX IF NOT EXISTS idx_event_log_epoch_seq
|
||||
ON $TABLE_EVENT_LOG($COL_EPOCH, $COL_SEQ);
|
||||
"""
|
||||
|
||||
/**
|
||||
* Создаёт все таблицы и индексы (идемпотентно). Миграция для schema v1
|
||||
* не нужна; future версии должны будут добавлять миграционный путь.
|
||||
*
|
||||
* Если таблица `compaction_state` создаётся впервые — синглтон-строка
|
||||
* инициализируется эпохой [DEFAULT_EPOCH] и `min_available_seq = 0`.
|
||||
* Аналогично `sync_state` инициализируется курсором `Cursor.ZERO`.
|
||||
*
|
||||
* Вызывающий отвечает за вызов под `BEGIN/COMMIT` или одиночно — обе формы
|
||||
* безопасны (CREATE IF NOT EXISTS + INSERT OR IGNORE).
|
||||
*/
|
||||
fun migrate(conn: SQLiteConnection) {
|
||||
conn.exec(eventLogDdl)
|
||||
conn.exec(chatStateDdl)
|
||||
conn.exec(messageStateDdl)
|
||||
conn.exec(messageStateIdx)
|
||||
conn.exec(compactionStateDdl)
|
||||
conn.exec(syncStateDdl)
|
||||
conn.exec(pendingEventDdl)
|
||||
conn.exec(processedEventIdDdl)
|
||||
conn.exec(eventLogEpochIdx)
|
||||
// Гарантируем, что singleton-строки существуют.
|
||||
conn.exec("INSERT OR IGNORE INTO $TABLE_COMPACTION_STATE (id, $COL_EPOCH, $COL_MIN_AVAILABLE_SEQ) VALUES (1, '$DEFAULT_EPOCH', 0)")
|
||||
conn.exec("INSERT OR IGNORE INTO $TABLE_SYNC_STATE (id, $COL_SYNC_CURSOR) VALUES (1, '${pw.binom.agentik.sync.Cursor.ZERO.toRawString()}')")
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
package pw.binom.agentik.sync.ksqlite
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.sync.CompactionState
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.CursorExpiredException
|
||||
import pw.binom.agentik.sync.CursorExpiredReason
|
||||
import pw.binom.agentik.sync.DomainEvent
|
||||
import pw.binom.agentik.sync.EventLog
|
||||
import pw.binom.agentik.sync.EventLogWriter
|
||||
import pw.binom.agentik.sync.LoggedEvent
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [EventLog] + [EventLogWriter].
|
||||
*
|
||||
* Контракт [append]:
|
||||
* 1. BEGIN
|
||||
* 2. SELECT min(seq) от processed_event_ids по eventId — если есть, вернуть
|
||||
* уже закоммиченное событие (идемпотентность по §8.1 спеки).
|
||||
* 3. INSERT INTO event_log — SQLite назначает `seq` как ROWID.
|
||||
* 4. INSERT INTO processed_event_id.
|
||||
* 5. COMMIT.
|
||||
* 6. Опционально: нотификация через [appendFlow] для live-подписчиков.
|
||||
*
|
||||
* Контракт [compact]:
|
||||
* 1. DELETE FROM event_log WHERE seq <= upTo.number.
|
||||
* 2. UPDATE compaction_state SET min_available_seq = upTo.number + 1.
|
||||
* 3. `processed_event_id` НЕ трогаем (см. §8.1 — это идемпотентность, она
|
||||
* должна переживать компакцию).
|
||||
*
|
||||
* Контракт [beginNewEpoch]:
|
||||
* 1. DELETE FROM event_log.
|
||||
* 2. UPDATE compaction_state SET epoch = ?, min_available_seq = 0.
|
||||
* 3. DELETE FROM sqlite_sequence WHERE name='sync_event_log'.
|
||||
* 4. Очистить state-таблицы (вызывающий должен пересинхронизироваться).
|
||||
*
|
||||
* `seq INTEGER PRIMARY KEY AUTOINCREMENT` гарантирует монотонность внутри эпохи.
|
||||
* После beginNewEpoch счётчик сбрасывается через `DELETE FROM sqlite_sequence`.
|
||||
*/
|
||||
class SqliteEventLog(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean = false,
|
||||
) : EventLogWriter {
|
||||
|
||||
/** Broadcast append-канал — для live-подписчиков. */
|
||||
private val appendChannel: MutableSharedFlow<LoggedEvent> = MutableSharedFlow(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 64,
|
||||
)
|
||||
|
||||
/** Публичный read-only view. */
|
||||
val appendFlow: SharedFlow<LoggedEvent> get() = appendChannel
|
||||
|
||||
private val appendListeners: MutableList<suspend (LoggedEvent) -> Unit> = mutableListOf()
|
||||
|
||||
fun onAppend(l: suspend (LoggedEvent) -> Unit) {
|
||||
appendListeners.add(l)
|
||||
}
|
||||
|
||||
private suspend fun notifyAppend(l: LoggedEvent) {
|
||||
for (f in appendListeners.toList()) f(l)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val selectByEventIdStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT $COL_SEQ, $COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN
|
||||
FROM ${Schema.TABLE_EVENT_LOG}
|
||||
WHERE $COL_EVENT_ID = ?
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_EVENT_LOG}
|
||||
($COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val insertProcessedStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
INSERT OR IGNORE INTO ${Schema.TABLE_PROCESSED_EVENT_ID} ($COL_EVENT_ID) VALUES (?)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT $COL_SEQ, $COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN
|
||||
FROM ${Schema.TABLE_EVENT_LOG}
|
||||
WHERE $COL_SEQ > ?
|
||||
ORDER BY $COL_SEQ ASC
|
||||
LIMIT ?
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val maxSeqStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT MAX($COL_SEQ) FROM ${Schema.TABLE_EVENT_LOG}"
|
||||
)
|
||||
|
||||
private val currentEpochStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT $COL_EPOCH, $COL_MIN_AVAILABLE_SEQ, $COL_LAST_COMPACTED_AT FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1"
|
||||
)
|
||||
|
||||
private val compactStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_EVENT_LOG} WHERE $COL_SEQ <= ?"
|
||||
)
|
||||
|
||||
private val updateMinAvailableStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
UPDATE ${Schema.TABLE_COMPACTION_STATE}
|
||||
SET $COL_MIN_AVAILABLE_SEQ = ?, $COL_LAST_COMPACTED_AT = ?
|
||||
WHERE id = 1
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val updateEpochStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
UPDATE ${Schema.TABLE_COMPACTION_STATE}
|
||||
SET $COL_EPOCH = ?, $COL_MIN_AVAILABLE_SEQ = 0
|
||||
WHERE id = 1
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val wipeEventLogStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_EVENT_LOG}"
|
||||
)
|
||||
|
||||
private val resetEventLogSeqStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM sqlite_sequence WHERE name = ?"
|
||||
)
|
||||
|
||||
private val lastInsertRowIdStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT last_insert_rowid()"
|
||||
)
|
||||
|
||||
init {
|
||||
// Schema.migrate должен быть вызван владельцем connection'а (см. SqliteSyncBundle).
|
||||
}
|
||||
|
||||
// ─── EventLog ───────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun readUpdates(after: Cursor, limit: Int): List<LoggedEvent> = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val (currentEpoch, minAvailable) = currentEpochWithMinAvailable()
|
||||
checkNotExpired(after, currentEpoch, minAvailable, currentCursorInternal(currentEpoch))
|
||||
listStmt.reset()
|
||||
listStmt.clearBindings()
|
||||
listStmt.bindLong(1, after.number.toLong())
|
||||
listStmt.bindLong(2, limit.toLong())
|
||||
val out = mutableListOf<LoggedEvent>()
|
||||
listStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) {
|
||||
out.add(rs.toLoggedEvent())
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val (epoch, _) = currentEpochWithMinAvailable()
|
||||
currentCursorInternal(epoch)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun compaction(): CompactionState = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
currentEpochStmt.reset()
|
||||
currentEpochStmt.clearBindings()
|
||||
currentEpochStmt.executeQuery().use { rs ->
|
||||
check(rs.next()) { "compaction_state must have one row" }
|
||||
val epoch = rs.getText(0) ?: error("epoch is null")
|
||||
val minSeq = rs.getLong(1) ?: 0L
|
||||
val compactedAtMs = rs.getLong(2)
|
||||
CompactionState(
|
||||
currentEpoch = epoch,
|
||||
minAvailableCursor = Cursor(epoch, minSeq.toULong()),
|
||||
lastCompactedAt = compactedAtMs?.let { Instant.fromEpochMilliseconds(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EventLogWriter ────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun append(event: DomainEvent): LoggedEvent = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
// 1. Идемпотентность по eventId.
|
||||
selectByEventIdStmt.reset()
|
||||
selectByEventIdStmt.clearBindings()
|
||||
selectByEventIdStmt.bindText(1, event.eventId)
|
||||
val existing: LoggedEvent? = selectByEventIdStmt.executeQuery().use { rs ->
|
||||
if (rs.next()) rs.toLoggedEvent() else null
|
||||
}
|
||||
if (existing != null) return@withContext existing
|
||||
|
||||
// 2. Текущая эпоха из compaction_state.
|
||||
val (epoch, _) = currentEpochWithMinAvailable()
|
||||
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
// 3. INSERT INTO event_log.
|
||||
insertStmt.reset()
|
||||
insertStmt.clearBindings()
|
||||
insertStmt.bindText(1, epoch)
|
||||
insertStmt.bindText(2, event.eventId)
|
||||
insertStmt.bindText(3, EventCodec.encode(event))
|
||||
insertStmt.bindLong(4, nowMs)
|
||||
insertStmt.bindNull(5)
|
||||
insertStmt.executeUpdate()
|
||||
val newSeq: Long = lastInsertRowIdStmt.executeQuery().use { rs ->
|
||||
check(rs.next()) { "last_insert_rowid() must return a row" }
|
||||
rs.getLong(0) ?: error("last_insert_rowid() is null")
|
||||
}
|
||||
|
||||
// 4. INSERT OR IGNORE INTO processed_event_id.
|
||||
insertProcessedStmt.reset()
|
||||
insertProcessedStmt.clearBindings()
|
||||
insertProcessedStmt.bindText(1, event.eventId)
|
||||
insertProcessedStmt.executeUpdate()
|
||||
|
||||
val logged = LoggedEvent(
|
||||
cursor = Cursor(epoch, newSeq.toULong()),
|
||||
eventId = event.eventId,
|
||||
payload = event,
|
||||
createdAt = Instant.fromEpochMilliseconds(nowMs),
|
||||
origin = null,
|
||||
)
|
||||
appendChannel.tryEmit(logged)
|
||||
notifyAppend(logged)
|
||||
logged
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun compact(upTo: Cursor): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val (currentEpoch, _) = currentEpochWithMinAvailable()
|
||||
require(upTo.epoch == currentEpoch) {
|
||||
"compact(upTo=$upTo) requires epoch == $currentEpoch"
|
||||
}
|
||||
compactStmt.reset()
|
||||
compactStmt.clearBindings()
|
||||
compactStmt.bindLong(1, upTo.number.toLong())
|
||||
compactStmt.executeUpdate()
|
||||
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||
updateMinAvailableStmt.reset()
|
||||
updateMinAvailableStmt.clearBindings()
|
||||
updateMinAvailableStmt.bindLong(1, (upTo.number + 1uL).toLong())
|
||||
updateMinAvailableStmt.bindLong(2, nowMs)
|
||||
updateMinAvailableStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun beginNewEpoch(newEpoch: String): Cursor = withContext(Dispatchers.Default) {
|
||||
require(newEpoch.isNotEmpty()) { "epoch must be non-empty" }
|
||||
require('-' !in newEpoch) { "epoch must not contain '-': \"$newEpoch\"" }
|
||||
mutex.withLock {
|
||||
// 1. Стираем все события.
|
||||
wipeEventLogStmt.reset()
|
||||
wipeEventLogStmt.clearBindings()
|
||||
wipeEventLogStmt.executeUpdate()
|
||||
// 2. Сбрасываем autoincrement для event_log.
|
||||
resetEventLogSeqStmt.reset()
|
||||
resetEventLogSeqStmt.clearBindings()
|
||||
resetEventLogSeqStmt.bindText(1, Schema.TABLE_EVENT_LOG)
|
||||
resetEventLogSeqStmt.executeUpdate()
|
||||
// 3. Меняем текущую эпоху и обнуляем min_available_seq.
|
||||
updateEpochStmt.reset()
|
||||
updateEpochStmt.clearBindings()
|
||||
updateEpochStmt.bindText(1, newEpoch)
|
||||
updateEpochStmt.executeUpdate()
|
||||
Cursor(newEpoch, 0uL)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun currentEpochWithMinAvailable(): Pair<String, ULong> {
|
||||
currentEpochStmt.reset()
|
||||
currentEpochStmt.clearBindings()
|
||||
currentEpochStmt.executeQuery().use { rs ->
|
||||
check(rs.next()) { "compaction_state must have one row" }
|
||||
val epoch = rs.getText(0) ?: error("epoch is null")
|
||||
val minSeq = rs.getLong(1) ?: 0L
|
||||
return epoch to minSeq.toULong()
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentCursorInternal(epoch: String): Cursor {
|
||||
maxSeqStmt.reset()
|
||||
maxSeqStmt.clearBindings()
|
||||
maxSeqStmt.executeQuery().use { rs ->
|
||||
check(rs.next()) { "MAX must return one row" }
|
||||
val max = rs.getLong(0)
|
||||
return if (max == null) Cursor(epoch, 0uL) else Cursor(epoch, max.toULong())
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNotExpired(
|
||||
requested: Cursor,
|
||||
currentEpoch: String,
|
||||
minAvailable: ULong,
|
||||
current: Cursor,
|
||||
) {
|
||||
if (requested.epoch != currentEpoch) {
|
||||
throw CursorExpiredException(
|
||||
requested = requested,
|
||||
currentEpoch = currentEpoch,
|
||||
minAvailable = Cursor(currentEpoch, minAvailable),
|
||||
current = current,
|
||||
reason = CursorExpiredReason.WRONG_EPOCH,
|
||||
)
|
||||
}
|
||||
if (requested.number < minAvailable) {
|
||||
throw CursorExpiredException(
|
||||
requested = requested,
|
||||
currentEpoch = currentEpoch,
|
||||
minAvailable = Cursor(currentEpoch, minAvailable),
|
||||
current = current,
|
||||
reason = CursorExpiredReason.TOO_OLD,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pw.binom.db.ksqlite.SQLiteResultSet.toLoggedEvent(): LoggedEvent {
|
||||
val seq = getLong(0) ?: error("seq is null")
|
||||
val epoch = getText(1) ?: error("epoch is null")
|
||||
return LoggedEvent(
|
||||
cursor = Cursor(epoch, seq.toULong()),
|
||||
eventId = getText(2) ?: error("event_id is null"),
|
||||
payload = EventCodec.decode(getText(3) ?: error("payload_json is null")),
|
||||
createdAt = Instant.fromEpochMilliseconds(getLong(4) ?: error("created_at is null")),
|
||||
origin = getText(5),
|
||||
)
|
||||
}
|
||||
|
||||
fun closeStmts() {
|
||||
selectByEventIdStmt.close()
|
||||
insertStmt.close()
|
||||
insertProcessedStmt.close()
|
||||
listStmt.close()
|
||||
maxSeqStmt.close()
|
||||
currentEpochStmt.close()
|
||||
compactStmt.close()
|
||||
updateMinAvailableStmt.close()
|
||||
updateEpochStmt.close()
|
||||
wipeEventLogStmt.close()
|
||||
resetEventLogSeqStmt.close()
|
||||
lastInsertRowIdStmt.close()
|
||||
if (ownsConnection) {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() = closeStmts()
|
||||
|
||||
companion object {
|
||||
// ── Local column-name aliases ───────────────────────────────────────
|
||||
private const val COL_SEQ = Schema.COL_SEQ
|
||||
private const val COL_EPOCH = Schema.COL_EPOCH
|
||||
private const val COL_EVENT_ID = Schema.COL_EVENT_ID
|
||||
private const val COL_PAYLOAD_JSON = Schema.COL_PAYLOAD_JSON
|
||||
private const val COL_CREATED_AT = Schema.COL_CREATED_AT
|
||||
private const val COL_ORIGIN = Schema.COL_ORIGIN
|
||||
private const val COL_MIN_AVAILABLE_SEQ = Schema.COL_MIN_AVAILABLE_SEQ
|
||||
private const val COL_LAST_COMPACTED_AT = Schema.COL_LAST_COMPACTED_AT
|
||||
|
||||
fun memory(name: String? = null): SqliteEventLog =
|
||||
SqliteEventLog(
|
||||
connection = SQLiteConnection.memory(name),
|
||||
ownsConnection = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
-319
@@ -1,319 +0,0 @@
|
||||
package pw.binom.agentik.sync.ksqlite
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.DomainEvent
|
||||
import pw.binom.agentik.sync.EventLogReplica
|
||||
import pw.binom.agentik.sync.LocalId
|
||||
import pw.binom.agentik.sync.LoggedEvent
|
||||
import pw.binom.agentik.sync.PendingEvent
|
||||
import pw.binom.agentik.sync.PendingStatus
|
||||
import pw.binom.agentik.sync.SeqMonotonicityViolationException
|
||||
import pw.binom.agentik.sync.StateSnapshot
|
||||
import pw.binom.agentik.sync.SyncState
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [EventLogReplica] (клиентская сторона).
|
||||
*
|
||||
* Локальный event-log на клиенте **не нужен** — это серверная абстракция.
|
||||
* Клиент держит:
|
||||
* - материализацию в [SqliteStateStore];
|
||||
* - sync_state (`cursor`, `last_sync_at`) в singleton-row;
|
||||
* - pending_event (очередь);
|
||||
* - processed_event_id (идемпотентность applyRemote).
|
||||
*
|
||||
* `readUpdates` на клиенте возвращает **пустой список** — replay-логики на
|
||||
* стороне клиента нет, она вся делается через [transport.fetchUpdates].
|
||||
* [lastCursor] читается из sync_state.
|
||||
*
|
||||
* [applyRemote] оптимистично применяет событие к state через reducer и
|
||||
* записывает в processed_event_id (идемпотентность). Если `event.cursor`
|
||||
* вне текущей эпохи — клиент должен сделать replaceState (см. SyncEngine).
|
||||
*
|
||||
* [enqueuePending] применяет событие оптимистично (как будто оно уже в
|
||||
* журнале) и кладёт в pending_event с status=PENDING. Назначаемый `cursor`
|
||||
* для оптимистичного применения — это `lastCursor().next()`; реальный
|
||||
* серверный cursor перезапишется при [markPendingAsSent].
|
||||
*/
|
||||
class SqliteEventLogReplica(
|
||||
private val connection: SQLiteConnection,
|
||||
private val stateStore: SqliteStateStore,
|
||||
) : EventLogReplica {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val updateSyncStateStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
UPDATE ${Schema.TABLE_SYNC_STATE}
|
||||
SET $COL_SYNC_CURSOR = ?, $COL_LAST_SYNC_AT = ?
|
||||
WHERE id = 1
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val selectSyncStateStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT $COL_SYNC_CURSOR, $COL_LAST_SYNC_AT FROM ${Schema.TABLE_SYNC_STATE} WHERE id = 1"
|
||||
)
|
||||
|
||||
private val insertProcessedStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"INSERT OR IGNORE INTO ${Schema.TABLE_PROCESSED_EVENT_ID} ($COL_EVENT_ID) VALUES (?)"
|
||||
)
|
||||
|
||||
private val checkEventIdStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT 1 FROM ${Schema.TABLE_PROCESSED_EVENT_ID} WHERE $COL_EVENT_ID = ? LIMIT 1"
|
||||
)
|
||||
|
||||
private val selectPendingStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT $COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS, $COL_SENT_CURSOR
|
||||
FROM ${Schema.TABLE_PENDING_EVENT}
|
||||
WHERE $COL_STATUS = ?
|
||||
ORDER BY $COL_CREATED_AT ASC
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val selectAllPendingStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT $COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS, $COL_SENT_CURSOR FROM ${Schema.TABLE_PENDING_EVENT}"
|
||||
)
|
||||
|
||||
private val insertPendingStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_PENDING_EVENT}
|
||||
($COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val updatePendingStatusStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
UPDATE ${Schema.TABLE_PENDING_EVENT}
|
||||
SET $COL_STATUS = ?, $COL_SENT_CURSOR = ?
|
||||
WHERE $COL_LOCAL_ID = ?
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// ─── EventLog ───────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun readUpdates(after: Cursor, limit: Int): List<LoggedEvent> = emptyList()
|
||||
|
||||
override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) {
|
||||
mutex.withLock { readSyncStateLocked().cursor }
|
||||
}
|
||||
|
||||
override suspend fun compaction() = stateStore.compaction()
|
||||
|
||||
// ─── EventLogReplica ───────────────────────────────────────────────────
|
||||
|
||||
override suspend fun applyRemote(event: LoggedEvent): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
// 1. Идемпотентность по eventId.
|
||||
if (isEventIdProcessed(event.eventId)) return@withContext
|
||||
// 2. Монотонность в текущей эпохе.
|
||||
val currentCursor = readSyncStateCursorLocked()
|
||||
if (event.cursor.epoch != currentCursor.epoch) {
|
||||
throw SeqMonotonicityViolationException(
|
||||
previous = currentCursor.number.toLong(),
|
||||
attempted = event.cursor.number.toLong(),
|
||||
)
|
||||
}
|
||||
if (event.cursor.number <= currentCursor.number) {
|
||||
throw SeqMonotonicityViolationException(
|
||||
previous = currentCursor.number.toLong(),
|
||||
attempted = event.cursor.number.toLong(),
|
||||
)
|
||||
}
|
||||
// 3. Помечаем как processed.
|
||||
insertProcessedStmt.reset()
|
||||
insertProcessedStmt.clearBindings()
|
||||
insertProcessedStmt.bindText(1, event.eventId)
|
||||
insertProcessedStmt.executeUpdate()
|
||||
// 4. Применяем через state store.
|
||||
stateStore.applyOnClient(event)
|
||||
// 5. Обновляем sync_state.cursor.
|
||||
updateSyncStateStmt.reset()
|
||||
updateSyncStateStmt.clearBindings()
|
||||
updateSyncStateStmt.bindText(1, event.cursor.toRawString())
|
||||
updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds())
|
||||
updateSyncStateStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/** Проверяет, был ли eventId уже записан в processed_event_id. */
|
||||
private fun isEventIdProcessed(eventId: String): Boolean {
|
||||
checkEventIdStmt.reset()
|
||||
checkEventIdStmt.clearBindings()
|
||||
checkEventIdStmt.bindText(1, eventId)
|
||||
checkEventIdStmt.executeQuery().use { rs ->
|
||||
return rs.next()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun replaceState(snapshot: StateSnapshot): Unit = withContext(Dispatchers.Default) {
|
||||
stateStore.replaceState(snapshot)
|
||||
mutex.withLock {
|
||||
updateSyncStateStmt.reset()
|
||||
updateSyncStateStmt.clearBindings()
|
||||
updateSyncStateStmt.bindText(1, snapshot.state.lastCursor.toRawString())
|
||||
updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds())
|
||||
updateSyncStateStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pendingEvents(): List<PendingEvent> = withContext(Dispatchers.Default) {
|
||||
selectPendingStmt.reset()
|
||||
selectPendingStmt.clearBindings()
|
||||
selectPendingStmt.bindText(1, PendingStatus.PENDING.name)
|
||||
val out = mutableListOf<PendingEvent>()
|
||||
selectPendingStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) out.add(rs.toPending())
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/** Все pending-события (для тестов). */
|
||||
suspend fun allPending(): List<PendingEvent> = withContext(Dispatchers.Default) {
|
||||
selectAllPendingStmt.reset()
|
||||
selectAllPendingStmt.clearBindings()
|
||||
val out = mutableListOf<PendingEvent>()
|
||||
selectAllPendingStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) out.add(rs.toPending())
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
override suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val pe = PendingEvent(
|
||||
localId = LocalId(payload.eventId),
|
||||
payload = payload,
|
||||
createdAt = createdAt,
|
||||
status = PendingStatus.PENDING,
|
||||
)
|
||||
insertPendingStmt.reset()
|
||||
insertPendingStmt.clearBindings()
|
||||
insertPendingStmt.bindText(1, pe.localId.raw)
|
||||
insertPendingStmt.bindText(2, EventCodec.encode(payload))
|
||||
insertPendingStmt.bindLong(3, createdAt.toEpochMilliseconds())
|
||||
insertPendingStmt.bindText(4, PendingStatus.PENDING.name)
|
||||
insertPendingStmt.executeUpdate()
|
||||
// Оптимистичное применение к материализации с фиктивным cursor = lastCursor().next().
|
||||
val fakeCursor = stateStore.lastCursor().next()
|
||||
val fakeLogged = LoggedEvent(
|
||||
cursor = fakeCursor,
|
||||
eventId = payload.eventId,
|
||||
payload = payload,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
stateStore.applyOnClient(fakeLogged)
|
||||
pe
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor): Unit = withContext(Dispatchers.Default) {
|
||||
updatePendingStatusStmt.reset()
|
||||
updatePendingStatusStmt.clearBindings()
|
||||
updatePendingStatusStmt.bindText(1, PendingStatus.SENT.name)
|
||||
updatePendingStatusStmt.bindText(2, cursor.toRawString())
|
||||
updatePendingStatusStmt.bindText(3, localId.raw)
|
||||
updatePendingStatusStmt.executeUpdate()
|
||||
mutex.withLock {
|
||||
updateSyncStateStmt.reset()
|
||||
updateSyncStateStmt.clearBindings()
|
||||
updateSyncStateStmt.bindText(1, cursor.toRawString())
|
||||
updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds())
|
||||
updateSyncStateStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markPendingAsFailed(localId: LocalId): Unit = withContext(Dispatchers.Default) {
|
||||
updatePendingStatusStmt.reset()
|
||||
updatePendingStatusStmt.clearBindings()
|
||||
updatePendingStatusStmt.bindText(1, PendingStatus.FAILED.name)
|
||||
updatePendingStatusStmt.bindNull(2)
|
||||
updatePendingStatusStmt.bindText(3, localId.raw)
|
||||
updatePendingStatusStmt.executeUpdate()
|
||||
}
|
||||
|
||||
override suspend fun syncState(): SyncState = withContext(Dispatchers.Default) {
|
||||
mutex.withLock { readSyncStateLocked() }
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun readSyncStateLocked(): SyncState {
|
||||
selectSyncStateStmt.reset()
|
||||
selectSyncStateStmt.clearBindings()
|
||||
selectSyncStateStmt.executeQuery().use { rs ->
|
||||
check(rs.next())
|
||||
val cursor = Cursor.fromString(rs.getText(0) ?: error("cursor is null"))
|
||||
val lastSyncAtMs = rs.getLong(1)
|
||||
return SyncState(
|
||||
cursor = cursor,
|
||||
lastSyncAt = lastSyncAtMs?.let { Instant.fromEpochMilliseconds(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readSyncStateCursorLocked(): Cursor = readSyncStateLocked().cursor
|
||||
|
||||
private fun pw.binom.db.ksqlite.SQLiteResultSet.toPending(): PendingEvent {
|
||||
val sent = getText(4)
|
||||
return PendingEvent(
|
||||
localId = LocalId(getText(0) ?: error("local_id null")),
|
||||
payload = EventCodec.decode(getText(1) ?: error("payload null")),
|
||||
createdAt = Instant.fromEpochMilliseconds(getLong(2) ?: 0L),
|
||||
status = PendingStatus.valueOf(getText(3) ?: "PENDING"),
|
||||
sentCursor = sent?.let { Cursor.fromString(it) },
|
||||
)
|
||||
}
|
||||
|
||||
fun closeStmts() {
|
||||
updateSyncStateStmt.close()
|
||||
selectSyncStateStmt.close()
|
||||
insertProcessedStmt.close()
|
||||
checkEventIdStmt.close()
|
||||
selectPendingStmt.close()
|
||||
selectAllPendingStmt.close()
|
||||
insertPendingStmt.close()
|
||||
updatePendingStatusStmt.close()
|
||||
}
|
||||
|
||||
override fun close() = closeStmts()
|
||||
|
||||
/**
|
||||
* Только для тестов: полностью wipe'нуть локальную материализацию и
|
||||
* перевести клиента в новую эпоху. В нормальной работе клиент узнаёт о
|
||||
* новой эпохе из `CursorExpired.WRONG_EPOCH` через `SyncEngine.sync` →
|
||||
* replaceState.
|
||||
*/
|
||||
fun beginNewEpoch(newEpoch: String): Cursor {
|
||||
connection.exec("DELETE FROM ${Schema.TABLE_CHAT_STATE}")
|
||||
connection.exec("DELETE FROM ${Schema.TABLE_MESSAGE_STATE}")
|
||||
connection.exec("DELETE FROM ${Schema.TABLE_PROCESSED_EVENT_ID}")
|
||||
connection.exec("DELETE FROM ${Schema.TABLE_PENDING_EVENT}")
|
||||
connection.exec("DELETE FROM ${Schema.TABLE_SYNC_STATE}")
|
||||
connection.exec(
|
||||
"UPDATE ${Schema.TABLE_COMPACTION_STATE} " +
|
||||
"SET ${Schema.COL_EPOCH} = '$newEpoch', " +
|
||||
"${Schema.COL_MIN_AVAILABLE_SEQ} = 0"
|
||||
)
|
||||
return Cursor(newEpoch, 0uL)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val COL_LOCAL_ID = Schema.COL_LOCAL_ID
|
||||
private const val COL_PAYLOAD_JSON = Schema.COL_PAYLOAD_JSON
|
||||
private const val COL_CREATED_AT = Schema.COL_CREATED_AT
|
||||
private const val COL_STATUS = Schema.COL_STATUS
|
||||
private const val COL_SENT_CURSOR = Schema.COL_SENT_CURSOR
|
||||
private const val COL_EVENT_ID = Schema.COL_EVENT_ID
|
||||
private const val COL_SYNC_CURSOR = Schema.COL_SYNC_CURSOR
|
||||
private const val COL_LAST_SYNC_AT = Schema.COL_LAST_SYNC_AT
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
package pw.binom.agentik.sync.ksqlite
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.sync.Chat
|
||||
import pw.binom.agentik.sync.ChatId
|
||||
import pw.binom.agentik.sync.ChatState
|
||||
import pw.binom.agentik.sync.CompactionState
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.DefaultChatReducer
|
||||
import pw.binom.agentik.sync.LoggedEvent
|
||||
import pw.binom.agentik.sync.Message
|
||||
import pw.binom.agentik.sync.MessageId
|
||||
import pw.binom.agentik.sync.MessageRole
|
||||
import pw.binom.agentik.sync.Reducer
|
||||
import pw.binom.agentik.sync.StateSnapshot
|
||||
import pw.binom.agentik.sync.StateStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [StateStore]. Материализация держится в таблицах
|
||||
* `chat_state` + `message_state`. На каждом `applyEvent` редьюсер считает
|
||||
* новое значение строки, и мы пишем его в таблицу.
|
||||
*
|
||||
* В БД эпоха хранится в [Schema.TABLE_COMPACTION_STATE] (singleton), а
|
||||
* строки `chat_state` / `message_state` несут только номер (`last_seq INTEGER`)
|
||||
* в текущей эпохе — это нормально, потому что [replaceState] стирает все
|
||||
* строки при смене эпохи. При чтении DTO ([Chat] / [Message]) номер
|
||||
* дополняется текущей эпохой из compaction_state.
|
||||
*/
|
||||
class SqliteStateStore(
|
||||
private val connection: SQLiteConnection,
|
||||
private val reducer: Reducer<ChatState> = DefaultChatReducer,
|
||||
) : StateStore {
|
||||
|
||||
/** Теневое in-memory состояние — обновляется в одном темпе с SQL. */
|
||||
private var shadow: ChatState = ChatState()
|
||||
|
||||
/** Текущая эпоха — кэш для [lastCursor] / [compaction]. */
|
||||
private var currentEpoch: String = Schema.DEFAULT_EPOCH
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val upsertChatStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_CHAT_STATE}
|
||||
($COL_CHAT_ID, $COL_TITLE, $COL_CREATED_AT, $COL_DELETED, $COL_LAST_SEQ)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT($COL_CHAT_ID) DO UPDATE SET
|
||||
$COL_TITLE = excluded.$COL_TITLE,
|
||||
$COL_DELETED = excluded.$COL_DELETED,
|
||||
$COL_LAST_SEQ = excluded.$COL_LAST_SEQ
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val deleteChatStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_CHAT_STATE} WHERE $COL_CHAT_ID = ?"
|
||||
)
|
||||
|
||||
private val upsertMessageStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_MESSAGE_STATE}
|
||||
($COL_MESSAGE_ID, $COL_CHAT_ID, $COL_ROLE, $COL_CONTENT, $COL_PARENT_ID,
|
||||
$COL_DELETED, $COL_LAST_SEQ, $COL_CREATED_AT)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT($COL_MESSAGE_ID) DO UPDATE SET
|
||||
$COL_CONTENT = excluded.$COL_CONTENT,
|
||||
$COL_PARENT_ID = excluded.$COL_PARENT_ID,
|
||||
$COL_DELETED = excluded.$COL_DELETED,
|
||||
$COL_LAST_SEQ = excluded.$COL_LAST_SEQ
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val deleteMessageStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_MESSAGE_STATE} WHERE $COL_MESSAGE_ID = ?"
|
||||
)
|
||||
|
||||
private val selectChatsStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT $COL_CHAT_ID, $COL_TITLE, $COL_CREATED_AT, $COL_DELETED, $COL_LAST_SEQ FROM ${Schema.TABLE_CHAT_STATE}"
|
||||
)
|
||||
|
||||
private val selectMessagesStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT $COL_MESSAGE_ID, $COL_CHAT_ID, $COL_ROLE, $COL_CONTENT, $COL_PARENT_ID,
|
||||
$COL_DELETED, $COL_LAST_SEQ, $COL_CREATED_AT
|
||||
FROM ${Schema.TABLE_MESSAGE_STATE}
|
||||
ORDER BY $COL_CHAT_ID, $COL_LAST_SEQ
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private val truncateChatsStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_CHAT_STATE}"
|
||||
)
|
||||
private val truncateMessagesStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_MESSAGE_STATE}"
|
||||
)
|
||||
|
||||
private val selectCurrentEpochStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT $COL_EPOCH FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1"
|
||||
)
|
||||
|
||||
init {
|
||||
// Подгружаем текущее состояние в shadow.
|
||||
runBlocking { reloadShadow() }
|
||||
}
|
||||
|
||||
// ─── StateStore ────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun applyOnServer(event: LoggedEvent) = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val next = reducer.reduce(shadow, event)
|
||||
writeDiff(shadow, next, event.cursor)
|
||||
shadow = next
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun applyOnClient(event: LoggedEvent) = applyOnServer(event)
|
||||
|
||||
override suspend fun readState(): StateSnapshot = withContext(Dispatchers.Default) {
|
||||
mutex.withLock { StateSnapshot(state = shadow) }
|
||||
}
|
||||
|
||||
override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) {
|
||||
mutex.withLock { shadow.lastCursor }
|
||||
}
|
||||
|
||||
/** Доступ к compaction state — нужен [SqliteEventLogReplica.compaction]. */
|
||||
suspend fun compaction(): CompactionState = withContext(Dispatchers.Default) {
|
||||
mutex.withLock { readCompactionLocked() }
|
||||
}
|
||||
|
||||
/** Полная замена состояния снапшотом (см. [EventLogReplica.replaceState]). */
|
||||
suspend fun replaceState(snapshot: StateSnapshot) = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
// Обновим кэш эпохи — снапшот может прийти из новой эпохи.
|
||||
val epochFromState = snapshot.state.lastCursor.epoch
|
||||
currentEpoch = epochFromState
|
||||
connection.exec("BEGIN")
|
||||
try {
|
||||
truncateChatsStmt.reset()
|
||||
truncateChatsStmt.clearBindings()
|
||||
truncateChatsStmt.executeUpdate()
|
||||
truncateMessagesStmt.reset()
|
||||
truncateMessagesStmt.clearBindings()
|
||||
truncateMessagesStmt.executeUpdate()
|
||||
for (chat in snapshot.state.chats.values) {
|
||||
upsertChatStmt.reset()
|
||||
upsertChatStmt.clearBindings()
|
||||
upsertChatStmt.bindText(1, chat.id.raw)
|
||||
upsertChatStmt.bindText(2, chat.title)
|
||||
upsertChatStmt.bindLong(3, chat.createdAt.toEpochMilliseconds())
|
||||
upsertChatStmt.bindLong(4, if (chat.deleted) 1L else 0L)
|
||||
upsertChatStmt.bindLong(5, chat.lastCursor.number.toLong())
|
||||
upsertChatStmt.executeUpdate()
|
||||
}
|
||||
for ((_, msgs) in snapshot.state.messages) {
|
||||
for (m in msgs) {
|
||||
upsertMessageStmt.reset()
|
||||
upsertMessageStmt.clearBindings()
|
||||
upsertMessageStmt.bindText(1, m.id.raw)
|
||||
upsertMessageStmt.bindText(2, m.chatId.raw)
|
||||
upsertMessageStmt.bindText(3, m.role.name)
|
||||
upsertMessageStmt.bindText(4, m.content)
|
||||
if (m.parentId == null) upsertMessageStmt.bindNull(5)
|
||||
else upsertMessageStmt.bindText(5, m.parentId.raw)
|
||||
upsertMessageStmt.bindLong(6, if (m.deleted) 1L else 0L)
|
||||
upsertMessageStmt.bindLong(7, m.lastCursor.number.toLong())
|
||||
upsertMessageStmt.bindLong(8, m.createdAt.toEpochMilliseconds())
|
||||
upsertMessageStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
connection.exec("COMMIT")
|
||||
} catch (t: Throwable) {
|
||||
runCatching { connection.exec("ROLLBACK") }
|
||||
throw t
|
||||
}
|
||||
shadow = snapshot.state
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun readCurrentEpochLocked(): String {
|
||||
selectCurrentEpochStmt.reset()
|
||||
selectCurrentEpochStmt.clearBindings()
|
||||
selectCurrentEpochStmt.executeQuery().use { rs ->
|
||||
check(rs.next())
|
||||
return rs.getText(0) ?: Schema.DEFAULT_EPOCH
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCompactionLocked(): CompactionState {
|
||||
val stmt = connection.prepare("SELECT epoch, min_available_seq, last_compacted_at FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1")
|
||||
try {
|
||||
stmt.executeQuery().use { rs ->
|
||||
check(rs.next())
|
||||
val epoch = rs.getText(0) ?: Schema.DEFAULT_EPOCH
|
||||
val min = rs.getLong(1) ?: 0L
|
||||
val ms = rs.getLong(2)
|
||||
return CompactionState(
|
||||
currentEpoch = epoch,
|
||||
minAvailableCursor = Cursor(epoch, min.toULong()),
|
||||
lastCompactedAt = ms?.let { Instant.fromEpochMilliseconds(it) },
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadShadow() = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
currentEpoch = readCurrentEpochLocked()
|
||||
val chats = LinkedHashMap<ChatId, Chat>()
|
||||
selectChatsStmt.reset()
|
||||
selectChatsStmt.clearBindings()
|
||||
selectChatsStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) {
|
||||
val lastSeq = rs.getLong(4) ?: 0L
|
||||
val chat = Chat(
|
||||
id = ChatId(rs.getText(0) ?: error("chat_id null")),
|
||||
title = rs.getText(1) ?: "",
|
||||
createdAt = Instant.fromEpochMilliseconds(rs.getLong(2) ?: 0L),
|
||||
deleted = (rs.getLong(3) ?: 0L) != 0L,
|
||||
lastCursor = Cursor(currentEpoch, lastSeq.toULong()),
|
||||
)
|
||||
chats[chat.id] = chat
|
||||
}
|
||||
}
|
||||
val messages = LinkedHashMap<ChatId, MutableList<Message>>()
|
||||
selectMessagesStmt.reset()
|
||||
selectMessagesStmt.clearBindings()
|
||||
selectMessagesStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) {
|
||||
val lastSeq = rs.getLong(6) ?: 0L
|
||||
val m = Message(
|
||||
id = MessageId(rs.getText(0) ?: error("message_id null")),
|
||||
chatId = ChatId(rs.getText(1) ?: error("chat_id null")),
|
||||
role = MessageRole.valueOf(rs.getText(2) ?: "USER"),
|
||||
content = rs.getText(3) ?: "",
|
||||
parentId = rs.getText(4)?.let { MessageId(it) },
|
||||
deleted = (rs.getLong(5) ?: 0L) != 0L,
|
||||
lastCursor = Cursor(currentEpoch, lastSeq.toULong()),
|
||||
createdAt = Instant.fromEpochMilliseconds(rs.getLong(7) ?: 0L),
|
||||
)
|
||||
messages.getOrPut(m.chatId) { mutableListOf() }.add(m)
|
||||
}
|
||||
}
|
||||
shadow = ChatState(chats = chats, messages = messages)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeDiff(old: ChatState, new: ChatState, eventCursor: Cursor) {
|
||||
// Chats: added / updated / removed.
|
||||
for ((id, nc) in new.chats) {
|
||||
val oc = old.chats[id]
|
||||
if (oc == null || oc.title != nc.title || oc.deleted != nc.deleted || oc.lastCursor != nc.lastCursor) {
|
||||
upsertChatStmt.reset()
|
||||
upsertChatStmt.clearBindings()
|
||||
upsertChatStmt.bindText(1, id.raw)
|
||||
upsertChatStmt.bindText(2, nc.title)
|
||||
upsertChatStmt.bindLong(3, nc.createdAt.toEpochMilliseconds())
|
||||
upsertChatStmt.bindLong(4, if (nc.deleted) 1L else 0L)
|
||||
upsertChatStmt.bindLong(5, nc.lastCursor.number.toLong())
|
||||
upsertChatStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
for ((id, _) in old.chats) {
|
||||
if (id !in new.chats) {
|
||||
deleteChatStmt.reset()
|
||||
deleteChatStmt.clearBindings()
|
||||
deleteChatStmt.bindText(1, id.raw)
|
||||
deleteChatStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
// Messages: то же самое.
|
||||
for ((chatId, msgs) in new.messages) {
|
||||
val oldMsgsById = old.messages[chatId]?.associateBy { it.id } ?: emptyMap()
|
||||
for (m in msgs) {
|
||||
val om = oldMsgsById[m.id]
|
||||
if (om == null || om.content != m.content || om.deleted != m.deleted ||
|
||||
om.lastCursor != m.lastCursor || om.parentId != m.parentId
|
||||
) {
|
||||
upsertMessageStmt.reset()
|
||||
upsertMessageStmt.clearBindings()
|
||||
upsertMessageStmt.bindText(1, m.id.raw)
|
||||
upsertMessageStmt.bindText(2, m.chatId.raw)
|
||||
upsertMessageStmt.bindText(3, m.role.name)
|
||||
upsertMessageStmt.bindText(4, m.content)
|
||||
if (m.parentId == null) upsertMessageStmt.bindNull(5)
|
||||
else upsertMessageStmt.bindText(5, m.parentId.raw)
|
||||
upsertMessageStmt.bindLong(6, if (m.deleted) 1L else 0L)
|
||||
upsertMessageStmt.bindLong(7, m.lastCursor.number.toLong())
|
||||
upsertMessageStmt.bindLong(8, m.createdAt.toEpochMilliseconds())
|
||||
upsertMessageStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
// Удалённые (нет в new).
|
||||
for ((id, _) in oldMsgsById) {
|
||||
if (msgs.none { it.id == id }) {
|
||||
deleteMessageStmt.reset()
|
||||
deleteMessageStmt.clearBindings()
|
||||
deleteMessageStmt.bindText(1, id.raw)
|
||||
deleteMessageStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
upsertChatStmt.close()
|
||||
deleteChatStmt.close()
|
||||
upsertMessageStmt.close()
|
||||
deleteMessageStmt.close()
|
||||
selectChatsStmt.close()
|
||||
selectMessagesStmt.close()
|
||||
truncateChatsStmt.close()
|
||||
truncateMessagesStmt.close()
|
||||
selectCurrentEpochStmt.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val COL_EPOCH = Schema.COL_EPOCH
|
||||
private const val COL_CHAT_ID = Schema.COL_CHAT_ID
|
||||
private const val COL_TITLE = Schema.COL_TITLE
|
||||
private const val COL_CREATED_AT = Schema.COL_CREATED_AT
|
||||
private const val COL_DELETED = Schema.COL_DELETED
|
||||
private const val COL_LAST_SEQ = Schema.COL_LAST_SEQ
|
||||
private const val COL_MESSAGE_ID = Schema.COL_MESSAGE_ID
|
||||
private const val COL_ROLE = Schema.COL_ROLE
|
||||
private const val COL_CONTENT = Schema.COL_CONTENT
|
||||
private const val COL_PARENT_ID = Schema.COL_PARENT_ID
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package pw.binom.agentik.sync.ksqlite
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.sync.CompactionState
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.EventLogReplica
|
||||
import pw.binom.agentik.sync.EventLogWriter
|
||||
import pw.binom.agentik.sync.StateStore
|
||||
import pw.binom.agentik.sync.SyncEngine
|
||||
import pw.binom.agentik.sync.SyncTransport
|
||||
import pw.binom.agentik.sync.memory.InProcessTransport
|
||||
import pw.binom.agentik.sync.testing.SyncBundle
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* KSQLITE-бэкенд для тестов спеки. Сервер и клиент — на разных SQLite-базах
|
||||
* (одна in-memory connection shared у сервера, другая — у клиента).
|
||||
*
|
||||
* Создаётся через [create] с fresh in-memory SQLite для каждого бандла —
|
||||
* тесты изолированы друг от друга.
|
||||
*
|
||||
* Транспорт — [InProcessTransport], обёрнутый вокруг серверного event log'а.
|
||||
*/
|
||||
class SqliteSyncBundle private constructor(
|
||||
private val serverConn: SQLiteConnection,
|
||||
private val clientConn: SQLiteConnection,
|
||||
private val serverLog: SqliteEventLog,
|
||||
private val srvState: SqliteStateStore,
|
||||
private val cliState: SqliteStateStore,
|
||||
private val clientLog: SqliteEventLogReplica,
|
||||
private val transportImpl: InProcessTransport,
|
||||
) : SyncBundle() {
|
||||
|
||||
override val server: EventLogWriter = serverLog
|
||||
override val serverState: StateStore = srvState
|
||||
override val client: EventLogReplica = clientLog
|
||||
override val clientState: StateStore = cliState
|
||||
override val transport: SyncTransport = transportImpl
|
||||
override val engine: SyncEngine = SyncEngine(replica = clientLog, transport = transportImpl)
|
||||
|
||||
override suspend fun compactionState(): CompactionState = serverLog.compaction()
|
||||
|
||||
override suspend fun compactServer(upTo: Cursor) {
|
||||
serverLog.compact(upTo)
|
||||
}
|
||||
|
||||
override suspend fun serverBeginNewEpoch(newEpoch: String): Cursor {
|
||||
// §2.3 спеки: append на сервере атомарно пишет и в журнал,
|
||||
// и в материализацию. На beginNewEpoch state-store тоже надо
|
||||
// очистить.
|
||||
srvState.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState()))
|
||||
return serverLog.beginNewEpoch(newEpoch)
|
||||
}
|
||||
|
||||
/** Отдельно сменить эпоху на клиенте (для тестов WRONG_EPOCH-сценария). */
|
||||
suspend fun clientBeginNewEpoch(newEpoch: String): Cursor =
|
||||
clientLog.beginNewEpoch(newEpoch)
|
||||
|
||||
override fun close() {
|
||||
serverLog.closeStmts()
|
||||
srvState.close()
|
||||
clientLog.closeStmts()
|
||||
cliState.close()
|
||||
transportImpl.close()
|
||||
serverConn.close()
|
||||
clientConn.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(): SqliteSyncBundle {
|
||||
val serverConn = SQLiteConnection.memory("server-${Clock.System.now().toEpochMilliseconds()}")
|
||||
val clientConn = SQLiteConnection.memory("client-${Clock.System.now().toEpochMilliseconds()}")
|
||||
// Создаём схему.
|
||||
Schema.migrate(serverConn)
|
||||
Schema.migrate(clientConn)
|
||||
// Server.
|
||||
val serverLog = SqliteEventLog(serverConn, ownsConnection = false)
|
||||
val serverState = SqliteStateStore(serverConn)
|
||||
// §2.3 спеки: append на сервере атомарно пишет и в журнал,
|
||||
// и в материализацию. SqliteEventLog.append уведомляет подписчиков —
|
||||
// SqliteStateStore подписывается, чтобы обновить свой shadow.
|
||||
serverLog.onAppend { e -> serverState.applyOnServer(e) }
|
||||
// Client.
|
||||
val clientState = SqliteStateStore(clientConn)
|
||||
val clientLog = SqliteEventLogReplica(clientConn, clientState)
|
||||
// Транспорт ходит через серверный SqliteEventLog.
|
||||
val transport = InProcessTransport(
|
||||
server = serverLog,
|
||||
stateStore = serverState,
|
||||
compactionState = { serverLog.compaction() },
|
||||
)
|
||||
return SqliteSyncBundle(
|
||||
serverConn, clientConn,
|
||||
serverLog, serverState, clientState, clientLog,
|
||||
transport,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package pw.binom.agentik.sync.memory
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.sync.ChatState
|
||||
import pw.binom.agentik.sync.CompactionState
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.DefaultChatReducer
|
||||
import pw.binom.agentik.sync.DomainEvent
|
||||
import pw.binom.agentik.sync.EventLogReplica
|
||||
import pw.binom.agentik.sync.EventLogWriter
|
||||
import pw.binom.agentik.sync.LocalId
|
||||
import pw.binom.agentik.sync.LoggedEvent
|
||||
import pw.binom.agentik.sync.PendingEvent
|
||||
import pw.binom.agentik.sync.PendingStatus
|
||||
import pw.binom.agentik.sync.Reducer
|
||||
import pw.binom.agentik.sync.SeqMonotonicityViolationException
|
||||
import pw.binom.agentik.sync.StateSnapshot
|
||||
import pw.binom.agentik.sync.StateStore
|
||||
import pw.binom.agentik.sync.SyncState
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* In-memory реализация [EventLogWriter] + [EventLogReplica] + [StateStore].
|
||||
*
|
||||
* Семантика:
|
||||
* - [append] атомарно (под мьютексом): резервирует `cursor`, пишет событие
|
||||
* в `events`, применяет редьюсер к `state`.
|
||||
* - [compact] удаляет события `cursor.number <= upTo.number` и сдвигает
|
||||
* `minAvailableCursor`.
|
||||
* - [applyRemote] на replica применяет редьюсер, идемпотентно по `eventId`.
|
||||
* - [replaceState] полностью заменяет state и сбрасывает `syncState.cursor`.
|
||||
* - [beginNewEpoch] стирает все события и обновляет текущую эпоху.
|
||||
* - pending-события хранятся в отдельной мапе, смена статуса меняет запись.
|
||||
*
|
||||
* Для тестов спеки. Не предназначен для production — состояние в памяти
|
||||
* процесса не переживает рестарт.
|
||||
*/
|
||||
class InMemoryEventLog(
|
||||
private val reducer: Reducer<ChatState> = DefaultChatReducer,
|
||||
initialEpoch: String = Cursor.ZERO.epoch,
|
||||
) : EventLogWriter, EventLogReplica, StateStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Журнал событий: append-only. */
|
||||
private val events: MutableList<LoggedEvent> = mutableListOf()
|
||||
/** Для идемпотентности applyRemote: уже применённые `eventId`. */
|
||||
private val appliedEventIds: MutableSet<String> = mutableSetOf()
|
||||
|
||||
/** Материализованное состояние (обновляется атомарно с append/applyRemote). */
|
||||
private var state: ChatState = ChatState()
|
||||
|
||||
/** Pending-event'ы. Стартуют со статусом PENDING → SENT/FAILED. */
|
||||
private val pending: MutableMap<LocalId, PendingEvent> = mutableMapOf()
|
||||
|
||||
/** Подписчики на append (используется [InProcessTransport] для live-канала). */
|
||||
internal val appendListeners: MutableList<suspend (LoggedEvent) -> Unit> = mutableListOf()
|
||||
|
||||
/** Текущая эпоха. */
|
||||
private var currentEpoch: String = initialEpoch
|
||||
|
||||
/** `cursor` синхронизации (= последний успешно apply'нутый). */
|
||||
private var syncCursor: Cursor = Cursor(initialEpoch, 0uL)
|
||||
private var syncLastSyncAt: Instant? = null
|
||||
|
||||
/** Метаданные компакции. */
|
||||
private var minAvailableCursor: Cursor = Cursor(initialEpoch, 0uL)
|
||||
private var lastCompactedAt: Instant? = null
|
||||
|
||||
/** Подписаться на каждое новое append-событие. */
|
||||
fun onAppend(listener: suspend (LoggedEvent) -> Unit) {
|
||||
appendListeners.add(listener)
|
||||
}
|
||||
|
||||
private suspend fun notifyAppend(l: LoggedEvent) {
|
||||
for (f in appendListeners.toList()) f(l)
|
||||
}
|
||||
|
||||
// ─── EventLog (общий для writer и replica) ────────────────────────────
|
||||
|
||||
override suspend fun readUpdates(after: Cursor, limit: Int): List<LoggedEvent> = mutex.withLock {
|
||||
// Клиентская сторона не возвращает события — replay на сервере.
|
||||
// Если кто-то вызвал readUpdates на in-memory "server" — отдадим все.
|
||||
// Кросс-эпоха → бросаем CursorExpired.
|
||||
if (after.epoch != currentEpoch) {
|
||||
throw pw.binom.agentik.sync.CursorExpiredException(
|
||||
requested = after,
|
||||
currentEpoch = currentEpoch,
|
||||
minAvailable = minAvailableCursor,
|
||||
current = syncCursor,
|
||||
reason = pw.binom.agentik.sync.CursorExpiredReason.WRONG_EPOCH,
|
||||
)
|
||||
}
|
||||
if (after.number < minAvailableCursor.number) {
|
||||
throw pw.binom.agentik.sync.CursorExpiredException(
|
||||
requested = after,
|
||||
currentEpoch = currentEpoch,
|
||||
minAvailable = minAvailableCursor,
|
||||
current = syncCursor,
|
||||
reason = pw.binom.agentik.sync.CursorExpiredReason.TOO_OLD,
|
||||
)
|
||||
}
|
||||
events.asSequence()
|
||||
.filter { it.cursor.epoch == after.epoch && it.cursor.number > after.number }
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
|
||||
override suspend fun lastCursor(): Cursor = mutex.withLock {
|
||||
syncCursor
|
||||
}
|
||||
|
||||
override suspend fun compaction(): CompactionState = mutex.withLock {
|
||||
CompactionState(
|
||||
currentEpoch = currentEpoch,
|
||||
minAvailableCursor = minAvailableCursor,
|
||||
lastCompactedAt = lastCompactedAt,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── EventLogWriter (сервер) ───────────────────────────────────────────
|
||||
|
||||
override suspend fun append(event: DomainEvent): LoggedEvent = mutex.withLock {
|
||||
if (event.eventId in appliedEventIds) {
|
||||
return@withLock events.first { it.eventId == event.eventId }
|
||||
}
|
||||
// Резервируем следующий cursor в текущей эпохе.
|
||||
val nextNumber = (events.lastOrNull()?.cursor?.number ?: 0uL) + 1uL
|
||||
val cursor = Cursor(currentEpoch, nextNumber)
|
||||
val logged = LoggedEvent(
|
||||
cursor = cursor,
|
||||
eventId = event.eventId,
|
||||
payload = event,
|
||||
createdAt = Clock.System.now(),
|
||||
)
|
||||
state = reducer.reduce(state, logged)
|
||||
events.add(logged)
|
||||
appliedEventIds.add(event.eventId)
|
||||
syncCursor = cursor
|
||||
notifyAppend(logged)
|
||||
logged
|
||||
}
|
||||
|
||||
override suspend fun compact(upTo: Cursor) = mutex.withLock {
|
||||
require(upTo.epoch == currentEpoch) { "compact(upTo=$upTo) requires epoch=$currentEpoch" }
|
||||
events.removeAll { it.cursor.number <= upTo.number }
|
||||
val newMin = upTo.next()
|
||||
if (newMin.number > minAvailableCursor.number) {
|
||||
minAvailableCursor = newMin
|
||||
}
|
||||
lastCompactedAt = Clock.System.now()
|
||||
}
|
||||
|
||||
override suspend fun beginNewEpoch(newEpoch: String): Cursor = mutex.withLock {
|
||||
require(newEpoch.isNotEmpty()) { "epoch must be non-empty" }
|
||||
require('-' !in newEpoch) { "epoch must not contain '-': \"$newEpoch\"" }
|
||||
events.clear()
|
||||
appliedEventIds.clear()
|
||||
state = ChatState()
|
||||
currentEpoch = newEpoch
|
||||
syncCursor = Cursor(newEpoch, 0uL)
|
||||
minAvailableCursor = Cursor(newEpoch, 0uL)
|
||||
lastCompactedAt = null
|
||||
syncCursor
|
||||
}
|
||||
|
||||
// ─── EventLogReplica (клиент) ──────────────────────────────────────────
|
||||
|
||||
override suspend fun applyRemote(event: LoggedEvent) = mutex.withLock {
|
||||
if (event.eventId in appliedEventIds) return@withLock
|
||||
if (event.cursor.epoch != currentEpoch) {
|
||||
// Клиент видит событие другой эпохи → либо он сам отстал (нужен
|
||||
// replaceState), либо сервер сделал wipe. В обоих случаях клиент
|
||||
// должен делать replaceState через SyncEngine, а не applyRemote.
|
||||
// Здесь бросаем, чтобы SyncEngine заметил gap и пошёл в fetchState.
|
||||
throw SeqMonotonicityViolationException(
|
||||
previous = syncCursor.number.toLong(),
|
||||
attempted = event.cursor.number.toLong(),
|
||||
)
|
||||
}
|
||||
if (event.cursor.number <= syncCursor.number) {
|
||||
throw SeqMonotonicityViolationException(
|
||||
previous = syncCursor.number.toLong(),
|
||||
attempted = event.cursor.number.toLong(),
|
||||
)
|
||||
}
|
||||
state = reducer.reduce(state, event)
|
||||
events.add(event)
|
||||
appliedEventIds.add(event.eventId)
|
||||
syncCursor = event.cursor
|
||||
syncLastSyncAt = Clock.System.now()
|
||||
}
|
||||
|
||||
override suspend fun replaceState(snapshot: StateSnapshot) = mutex.withLock {
|
||||
state = snapshot.state
|
||||
currentEpoch = snapshot.state.lastCursor.epoch
|
||||
syncCursor = snapshot.state.lastCursor
|
||||
syncLastSyncAt = Clock.System.now()
|
||||
}
|
||||
|
||||
override suspend fun pendingEvents(): List<PendingEvent> = mutex.withLock {
|
||||
pending.values.filter { it.status == PendingStatus.PENDING }
|
||||
}
|
||||
|
||||
override suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent = mutex.withLock {
|
||||
val pe = PendingEvent(
|
||||
localId = LocalId(raw = payload.eventId),
|
||||
payload = payload,
|
||||
createdAt = createdAt,
|
||||
status = PendingStatus.PENDING,
|
||||
)
|
||||
pending[pe.localId] = pe
|
||||
// Оптимистичное применение к локальному состоянию — UI рисует сразу.
|
||||
val fakeCursor = syncCursor.next()
|
||||
val logged = LoggedEvent(
|
||||
cursor = fakeCursor,
|
||||
eventId = payload.eventId,
|
||||
payload = payload,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
state = reducer.reduce(state, logged)
|
||||
events.add(logged)
|
||||
syncCursor = fakeCursor
|
||||
pe
|
||||
}
|
||||
|
||||
override suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor) = mutex.withLock {
|
||||
val pe = pending[localId] ?: return@withLock
|
||||
pending[localId] = pe.copy(status = PendingStatus.SENT, sentCursor = cursor)
|
||||
if (cursor.number > syncCursor.number && cursor.epoch == currentEpoch) {
|
||||
syncCursor = cursor
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markPendingAsFailed(localId: LocalId) = mutex.withLock {
|
||||
val pe = pending[localId] ?: return@withLock
|
||||
pending[localId] = pe.copy(status = PendingStatus.FAILED)
|
||||
}
|
||||
|
||||
override suspend fun syncState(): SyncState = mutex.withLock {
|
||||
SyncState(cursor = syncCursor, lastSyncAt = syncLastSyncAt)
|
||||
}
|
||||
|
||||
// ─── StateStore ────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun applyOnServer(event: LoggedEvent) = mutex.withLock {
|
||||
if (event.eventId in appliedEventIds) return@withLock
|
||||
state = reducer.reduce(state, event)
|
||||
appliedEventIds.add(event.eventId)
|
||||
}
|
||||
|
||||
override suspend fun applyOnClient(event: LoggedEvent) = applyRemote(event)
|
||||
|
||||
override suspend fun readState(): StateSnapshot = mutex.withLock {
|
||||
StateSnapshot(state = state)
|
||||
}
|
||||
|
||||
/** Алиас [lastCursor] для [StateStore]. */
|
||||
suspend fun materializationLastCursor(): Cursor = mutex.withLock { state.lastCursor }
|
||||
|
||||
// ─── Тест-only инспекция ────────────────────────────────────────────────
|
||||
|
||||
/** Размер журнала (read-only). */
|
||||
suspend fun journalSize(): Int = mutex.withLock { events.size }
|
||||
|
||||
/** Все pending-события (любого статуса). */
|
||||
suspend fun allPending(): List<PendingEvent> = mutex.withLock { pending.values.toList() }
|
||||
|
||||
/** Текущая эпоха (read-only). */
|
||||
suspend fun epoch(): String = mutex.withLock { currentEpoch }
|
||||
|
||||
override fun close() {
|
||||
// Ничего не делаем — in-memory.
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package pw.binom.agentik.sync.memory
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.sync.CompactionState
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.CursorExpiredException
|
||||
import pw.binom.agentik.sync.CursorExpiredReason
|
||||
import pw.binom.agentik.sync.DomainEvent
|
||||
import pw.binom.agentik.sync.EventLogWriter
|
||||
import pw.binom.agentik.sync.LoggedEvent
|
||||
import pw.binom.agentik.sync.PendingEvent
|
||||
import pw.binom.agentik.sync.PostResult
|
||||
import pw.binom.agentik.sync.StateResponse
|
||||
import pw.binom.agentik.sync.StateStore
|
||||
import pw.binom.agentik.sync.SyncTransport
|
||||
import pw.binom.agentik.sync.UpdatesResponse
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* In-memory реализация [SyncTransport] поверх одного [EventLogWriter] +
|
||||
* [StateStore]. Имитирует HTTP/WS семантику спеки без сетевого слоя —
|
||||
* нужно для тестов.
|
||||
*
|
||||
* Семантика:
|
||||
* - [fetchUpdates] повторяет логику §5.2 шагов 2-4:
|
||||
* * `after.epoch != current_epoch` → [UpdatesResponse.CursorExpired] с
|
||||
* [CursorExpiredReason.WRONG_EPOCH].
|
||||
* * `after.number < minAvailable.number` (та же эпоха) →
|
||||
* [UpdatesResponse.CursorExpired] с [CursorExpiredReason.TOO_OLD].
|
||||
* * иначе — окно с `cursor > after`, не больше `limit`, и
|
||||
* `hasMore = (окно.size == limit)`.
|
||||
* - [fetchState] отдаёт полный снапшот через [StateStore.readState].
|
||||
* - [postPending] атомарно `append'`ит каждое событие; идемпотентность
|
||||
* по `eventId` (см. `InMemoryEventLog.append`).
|
||||
* - [subscribeLive] — холодный Flow, отдаёт события `cursor > after` и
|
||||
* подписывается на broadcast-канал. При пропущенном курсоре сразу
|
||||
* бросает [CursorExpiredException] внутрь Flow.
|
||||
*/
|
||||
class InProcessTransport(
|
||||
private val server: EventLogWriter,
|
||||
private val stateStore: StateStore,
|
||||
private val compactionState: suspend () -> CompactionState,
|
||||
) : SyncTransport {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Broadcast-канал для live-подписок. `extraBufferCapacity = 64` — окно для подписчиков. */
|
||||
private val live = MutableSharedFlow<LoggedEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
init {
|
||||
// Подписываемся на append'ы writer'а (in-memory impl поддерживает
|
||||
// listener'ов через [InMemoryEventLog.onAppend]).
|
||||
(server as? InMemoryEventLog)?.onAppend { live.emit(it) }
|
||||
}
|
||||
|
||||
/** Append с уведомлением подписчиков. */
|
||||
suspend fun appendAndBroadcast(event: DomainEvent): LoggedEvent {
|
||||
val logged = server.append(event)
|
||||
live.emit(logged)
|
||||
return logged
|
||||
}
|
||||
|
||||
override suspend fun fetchUpdates(after: Cursor, limit: Int): UpdatesResponse = mutex.withLock {
|
||||
val compaction = compactionState()
|
||||
val current = server.lastCursor()
|
||||
if (after.epoch != compaction.currentEpoch) {
|
||||
return@withLock UpdatesResponse.CursorExpired(
|
||||
requested = after,
|
||||
minAvailable = compaction.minAvailableCursor,
|
||||
current = current,
|
||||
reason = CursorExpiredReason.WRONG_EPOCH,
|
||||
)
|
||||
}
|
||||
if (after.number < compaction.minAvailableCursor.number) {
|
||||
return@withLock UpdatesResponse.CursorExpired(
|
||||
requested = after,
|
||||
minAvailable = compaction.minAvailableCursor,
|
||||
current = current,
|
||||
reason = CursorExpiredReason.TOO_OLD,
|
||||
)
|
||||
}
|
||||
val events = server.readUpdates(after, limit)
|
||||
val hasMore = events.size == limit
|
||||
UpdatesResponse.Ok(
|
||||
events = events,
|
||||
hasMore = hasMore,
|
||||
currentCursor = current,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun fetchState(): StateResponse {
|
||||
val snap = stateStore.readState()
|
||||
val cur = server.lastCursor()
|
||||
return StateResponse.Ok(
|
||||
snapshot = snap,
|
||||
upToCursor = snap.state.lastCursor,
|
||||
currentCursor = cur,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun postPending(events: List<PendingEvent>): List<PostResult> {
|
||||
val out = ArrayList<PostResult>(events.size)
|
||||
for (pe in events) {
|
||||
try {
|
||||
val logged = appendAndBroadcast(pe.payload)
|
||||
out.add(PostResult.Accepted(localId = pe.localId, cursor = logged.cursor))
|
||||
} catch (t: Throwable) {
|
||||
out.add(PostResult.Rejected(localId = pe.localId, reason = t.message ?: "unknown"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
override fun subscribeLive(after: Cursor): Flow<LoggedEvent> {
|
||||
val min = runBlocking { compactionState() }
|
||||
val current = runBlocking { server.lastCursor() }
|
||||
if (after.epoch != min.currentEpoch) {
|
||||
throw CursorExpiredException(
|
||||
requested = after,
|
||||
currentEpoch = min.currentEpoch,
|
||||
minAvailable = min.minAvailableCursor,
|
||||
current = current,
|
||||
reason = CursorExpiredReason.WRONG_EPOCH,
|
||||
)
|
||||
}
|
||||
if (after.number < min.minAvailableCursor.number) {
|
||||
throw CursorExpiredException(
|
||||
requested = after,
|
||||
currentEpoch = min.currentEpoch,
|
||||
minAvailable = min.minAvailableCursor,
|
||||
current = current,
|
||||
reason = CursorExpiredReason.TOO_OLD,
|
||||
)
|
||||
}
|
||||
return live.onStart {
|
||||
val catchup = server.readUpdates(after, limit = Int.MAX_VALUE)
|
||||
for (e in catchup) emit(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// No-op.
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> runBlocking(block: suspend () -> T): T =
|
||||
kotlinx.coroutines.runBlocking { block() }
|
||||
@@ -1,59 +0,0 @@
|
||||
package pw.binom.agentik.sync.testing
|
||||
|
||||
import pw.binom.agentik.sync.ChatState
|
||||
import pw.binom.agentik.sync.CompactionState
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.EventLogReplica
|
||||
import pw.binom.agentik.sync.EventLogWriter
|
||||
import pw.binom.agentik.sync.StateStore
|
||||
import pw.binom.agentik.sync.SyncEngine
|
||||
import pw.binom.agentik.sync.SyncTransport
|
||||
|
||||
/**
|
||||
* Контракт бэкенда для тестов спеки. Любая имплементация (in-memory,
|
||||
* ksqlite и т.п.) создаёт через `create()` (см. `MemorySyncBundle`,
|
||||
* `SqliteSyncBundle`) изолированный мир: сервер (writer + state store +
|
||||
* transport) + клиент (replica) + sync-engine.
|
||||
*
|
||||
* Тесты в этом пакете вызывают только методы бандла и проверяют инварианты
|
||||
* спеки, не зная, что внутри. Это позволяет одной и той же тестовой сюитой
|
||||
* прогонять и in-memory, и ksqlite impl.
|
||||
*/
|
||||
abstract class SyncBundle : AutoCloseable {
|
||||
|
||||
abstract val server: EventLogWriter
|
||||
abstract val serverState: StateStore
|
||||
abstract val client: EventLogReplica
|
||||
abstract val clientState: StateStore
|
||||
abstract val transport: SyncTransport
|
||||
abstract val engine: SyncEngine
|
||||
|
||||
/** Снапшот серверного compaction-стейта — для тестов, которые его
|
||||
* трогают напрямую. */
|
||||
abstract suspend fun compactionState(): CompactionState
|
||||
|
||||
/** Принудительный compact серверного журнала (используется тестами
|
||||
* сценария «cursor протух»). */
|
||||
abstract suspend fun compactServer(upTo: Cursor)
|
||||
|
||||
/** Принудительная смена эпохи на сервере (для тестов WRONG_EPOCH).
|
||||
* Возвращает новый стартовый Cursor. */
|
||||
abstract suspend fun serverBeginNewEpoch(newEpoch: String): Cursor
|
||||
|
||||
/** Клиентский lastCursor (для тестов). */
|
||||
suspend fun clientLastCursor(): Cursor = client.syncState().cursor
|
||||
|
||||
/** Серверный lastCursor. */
|
||||
suspend fun serverLastCursor(): Cursor = server.lastCursor()
|
||||
|
||||
/** Серверный снапшот. */
|
||||
suspend fun serverReadState(): ChatState = serverState.readState().state
|
||||
|
||||
/** Клиентский снапшот. */
|
||||
suspend fun clientReadState(): ChatState = clientState.readState().state
|
||||
|
||||
override fun close() {
|
||||
// По умолчанию ничего — конкретные бандлы (in-memory, ksqlite)
|
||||
// переопределяют.
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package pw.binom.agentik.sync.ksqlite
|
||||
|
||||
import kotlinx.io.files.Path
|
||||
import kotlinx.io.files.SystemFileSystem
|
||||
import pw.binom.agentik.sync.ChatId
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.test.samples
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Тест на персистентность ksqlite-имплёмплементации: данные, записанные в одной
|
||||
* сессии, доступны после полного закрытия соединения и открытия нового.
|
||||
* Использует kotlinx-io для работы с файлами — поэтому живёт в commonTest
|
||||
* и прогоняется на всех целях, где есть ksqlite (jvm, linuxX64, mingwX64).
|
||||
*/
|
||||
class KsqlitePersistenceTest {
|
||||
|
||||
@Test
|
||||
fun `данные сохраняются в SQLite и доступны после перезагрузки`() = kotlinx.coroutines.runBlocking {
|
||||
val tempFile = Path("/tmp", "opencode", "sync-core-test-${Clock.System.now().toEpochMilliseconds()}.db")
|
||||
SystemFileSystem.createDirectories(tempFile.parent!!)
|
||||
try {
|
||||
val conn1 = pw.binom.db.ksqlite.SQLiteConnection.open(tempFile.toString())
|
||||
Schema.migrate(conn1)
|
||||
val log1 = SqliteEventLog(conn1, ownsConnection = false)
|
||||
val state1 = SqliteStateStore(conn1)
|
||||
log1.append(samples.chatCreated(0))
|
||||
log1.append(samples.messageAppended(0, 0, content = "persistent"))
|
||||
val events = log1.readUpdates(Cursor.ZERO, 100)
|
||||
state1.applyOnServer(events[0])
|
||||
state1.applyOnServer(events[1])
|
||||
log1.closeStmts()
|
||||
state1.close()
|
||||
conn1.close()
|
||||
|
||||
val conn2 = pw.binom.db.ksqlite.SQLiteConnection.open(tempFile.toString())
|
||||
val log2 = SqliteEventLog(conn2, ownsConnection = false)
|
||||
val state2 = SqliteStateStore(conn2)
|
||||
val events2 = log2.readUpdates(Cursor.ZERO, 100)
|
||||
assertEquals(2, events2.size)
|
||||
val snap = state2.readState()
|
||||
assertNotNull(snap.state.chats[ChatId("chat0")])
|
||||
assertEquals(1, snap.state.messages[ChatId("chat0")]?.size)
|
||||
assertEquals("persistent", snap.state.messages[ChatId("chat0")]?.single()?.content)
|
||||
log2.closeStmts()
|
||||
state2.close()
|
||||
conn2.close()
|
||||
} finally {
|
||||
SystemFileSystem.delete(tempFile, mustExist = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package pw.binom.agentik.sync.ksqlite
|
||||
|
||||
import pw.binom.agentik.sync.ChatId
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.UpdatesResponse
|
||||
import pw.binom.agentik.sync.test.samples
|
||||
import pw.binom.agentik.sync.test.someInstant
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFails
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Интеграционные тесты спеки `SYNC-SYSTEM.md` против реальной SQLite (через
|
||||
* ksqlite). 15 in-memory тестов — в commonTest, чтобы прогонялись на всех
|
||||
* KMP-целях, где ksqlite публикуется (jvm, linuxX64, mingwX64).
|
||||
*/
|
||||
class KsqliteSyncSpecTest {
|
||||
|
||||
private fun bundle() = SqliteSyncBundle.create()
|
||||
|
||||
@Test
|
||||
fun `1 cursor монотонно возрастает на append`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
val s1 = b.server.append(samples.chatCreated(0))
|
||||
val s2 = b.server.append(samples.messageAppended(0, 0))
|
||||
val s3 = b.server.append(samples.messageAppended(0, 1))
|
||||
assertTrue(s1.cursor < s2.cursor)
|
||||
assertTrue(s2.cursor < s3.cursor)
|
||||
assertEquals(s1.cursor.next(), s2.cursor)
|
||||
assertEquals(s2.cursor.next(), s3.cursor)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `2 события не изменяются после append`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
val event = samples.chatCreated(0)
|
||||
val logged = b.server.append(event)
|
||||
val read = b.server.readUpdates(Cursor.ZERO, 100).single()
|
||||
assertEquals(logged.cursor, read.cursor)
|
||||
assertEquals(logged.eventId, read.eventId)
|
||||
assertEquals(logged.payload, read.payload)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `3 материализация сервера консистентна журналу`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.server.append(samples.messageAppended(0, 1))
|
||||
val state = b.serverReadState()
|
||||
assertNotNull(state.chats[ChatId("chat0")])
|
||||
assertEquals(2, state.messages[ChatId("chat0")]?.size)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `4 событие несёт полный payload через SQL`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
val created = pw.binom.agentik.sync.DomainEvent.ChatCreated(
|
||||
eventId = "ev-test",
|
||||
chatId = ChatId("chatX"),
|
||||
title = "Полный заголовок с Unicode",
|
||||
createdAt = someInstant,
|
||||
)
|
||||
val logged = b.server.append(created)
|
||||
val read = b.server.readUpdates(Cursor.ZERO, 100).single()
|
||||
assertEquals("Полный заголовок с Unicode", (read.payload as pw.binom.agentik.sync.DomainEvent.ChatCreated).title)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `5 replaceState полностью заменяет state`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.chatCreated(1))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.engine.sync()
|
||||
assertEquals(2, b.clientReadState().chats.size)
|
||||
b.server.append(samples.chatDeleted(0))
|
||||
b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState()))
|
||||
b.compactServer(upTo = b.serverLastCursor())
|
||||
b.engine.sync()
|
||||
val chats = b.clientReadState().chats
|
||||
assertEquals(1, chats.size)
|
||||
assertNotNull(chats[ChatId("chat1")])
|
||||
assertEquals(null, chats[ChatId("chat0")])
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `6 clientReadState совпадает с serverReadState после sync`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.server.append(samples.chatCreated(1))
|
||||
b.server.append(samples.messageAppended(1, 0))
|
||||
b.engine.sync()
|
||||
assertEquals(b.serverReadState(), b.clientReadState())
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `7 compact делает cursor протухшим - sync делает replace`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.engine.sync()
|
||||
val clientCursor = b.clientLastCursor()
|
||||
b.server.append(samples.messageAppended(0, 1))
|
||||
b.server.append(samples.messageAppended(0, 2))
|
||||
b.compactServer(upTo = clientCursor)
|
||||
val resp = b.transport.fetchUpdates(clientCursor, 100)
|
||||
assertTrue(resp is UpdatesResponse.CursorExpired)
|
||||
val report = b.engine.sync()
|
||||
assertTrue(report.resolvedCursorGap)
|
||||
assertEquals(3, b.clientReadState().messages[ChatId("chat0")]?.size)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `повторный append с тем же eventId возвращает тот же cursor`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
val e = samples.chatCreated(0)
|
||||
val first = b.server.append(e)
|
||||
val second = b.server.append(e)
|
||||
assertEquals(first.cursor, second.cursor)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyRemote идемпотентен по eventId`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.engine.sync()
|
||||
val before = b.clientReadState()
|
||||
val first = b.server.readUpdates(Cursor.ZERO, 100).first()
|
||||
b.client.applyRemote(first)
|
||||
assertEquals(before, b.clientReadState())
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `события упорядочены и без дыр`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
for (i in 0..10) b.server.append(samples.messageAppended(0, i))
|
||||
val events = b.server.readUpdates(Cursor.ZERO, 100)
|
||||
for (i in 1 until events.size) {
|
||||
assertEquals(events[i - 1].cursor.next(), events[i].cursor)
|
||||
}
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyRemote вне порядка бросает исключение`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.engine.sync()
|
||||
val past = pw.binom.agentik.sync.LoggedEvent(
|
||||
cursor = b.clientLastCursor(),
|
||||
eventId = "old-event",
|
||||
payload = samples.messageAppended(0, 99),
|
||||
createdAt = someInstant,
|
||||
)
|
||||
assertFails { b.client.applyRemote(past) }
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact удаляет старые события и сдвигает minAvailableCursor`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.server.append(samples.messageAppended(0, 1))
|
||||
val epoch = Cursor.ZERO.epoch
|
||||
b.compactServer(upTo = Cursor(epoch, 1uL))
|
||||
assertEquals(Cursor(epoch, 2uL), b.compactionState().minAvailableCursor)
|
||||
// После compact cursor < min → CursorExpired.
|
||||
val resp = b.transport.fetchUpdates(Cursor.ZERO, 100)
|
||||
assertTrue(resp is UpdatesResponse.CursorExpired)
|
||||
// Сверх floor: after=c(2) → events с cursor.number > 2 → number=3.
|
||||
val after2 = b.transport.fetchUpdates(Cursor(epoch, 2uL), 100)
|
||||
assertTrue(after2 is UpdatesResponse.Ok)
|
||||
val ok = after2 as UpdatesResponse.Ok
|
||||
assertEquals(1, ok.events.size)
|
||||
assertEquals(3uL, ok.events.single().cursor.number)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enqueuePending оптимистично применяется`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline"), someInstant)
|
||||
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
|
||||
assertEquals(1, msgs.size)
|
||||
assertEquals("offline", msgs.single().content)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync отправляет pending на сервер`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline"), someInstant)
|
||||
val report = b.engine.sync()
|
||||
assertEquals(1, report.postedPending)
|
||||
val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty()
|
||||
assertEquals(1, serverMsgs.size)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending сохраняется после replaceState`() = kotlinx.coroutines.runBlocking {
|
||||
val b = bundle()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.client.enqueuePending(samples.messageAppended(0, 0, content = "keep"), someInstant)
|
||||
b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState()))
|
||||
assertEquals(1, b.client.pendingEvents().size)
|
||||
b.engine.sync()
|
||||
assertEquals(1, b.serverReadState().messages[ChatId("chat0")]?.size)
|
||||
b.close()
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package pw.binom.agentik.sync.test
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import pw.binom.agentik.sync.Assistant
|
||||
import pw.binom.agentik.sync.ChatId
|
||||
import pw.binom.agentik.sync.DomainEvent
|
||||
import pw.binom.agentik.sync.MessageId
|
||||
import pw.binom.agentik.sync.RunInput
|
||||
import pw.binom.agentik.sync.remoteAssistant
|
||||
import pw.binom.agentik.sync.scriptedAssistant
|
||||
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Проверки §4.4 (Assistant) и §5.4 (LocalAssistant).
|
||||
*/
|
||||
class AssistantTest {
|
||||
|
||||
@Test
|
||||
fun `LocalAssistant возвращает поток доменных событий`() = kotlinx.coroutines.runBlocking {
|
||||
val events = listOf(
|
||||
samples.messageAppended(0, 0, content = "user asks"),
|
||||
samples.messageAppended(0, 1, content = "assistant replies"),
|
||||
)
|
||||
val a: Assistant = scriptedAssistant(events)
|
||||
val out = a.run(input()).toList()
|
||||
assertEquals(events, out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RemoteAssistant подписывается на live-канал сервера`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
|
||||
val assistant: Assistant = remoteAssistant(b.transport) { b.server.lastCursor() }
|
||||
|
||||
val collected = mutableListOf<DomainEvent>()
|
||||
val job = launch {
|
||||
assistant.run(input()).collect { collected.add(it) }
|
||||
}
|
||||
// Дать job запуститься и подписаться на live.
|
||||
yield()
|
||||
delay(20)
|
||||
// Сервер публикует событие — assistant его видит.
|
||||
b.server.append(samples.messageAppended(0, 0, content = "from-server"))
|
||||
delay(50)
|
||||
job.cancel()
|
||||
assertTrue(collected.any { (it as? DomainEvent.MessageAppended)?.content == "from-server" })
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `события LocalAssistant попадают в PendingEvent на клиенте`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
|
||||
val assistant: Assistant = scriptedAssistant(listOf(
|
||||
samples.messageAppended(0, 0, content = "user"),
|
||||
samples.messageAppended(0, 1, content = "assistant"),
|
||||
))
|
||||
|
||||
assistant.run(input()).collect { e ->
|
||||
b.client.enqueuePending(e, someInstant)
|
||||
}
|
||||
|
||||
val pending = b.client.pendingEvents()
|
||||
assertEquals(2, pending.size)
|
||||
val msgs = b.clientReadState().messages.values.flatten()
|
||||
assertEquals(2, msgs.size)
|
||||
|
||||
val report = b.engine.sync()
|
||||
assertEquals(2, report.postedPending)
|
||||
val serverMsgs = b.serverReadState().messages.values.flatten()
|
||||
assertEquals(2, serverMsgs.size)
|
||||
b.close()
|
||||
}
|
||||
|
||||
private fun input() = RunInput(
|
||||
chatId = ChatId("chat0"),
|
||||
userMessageId = MessageId("u1"),
|
||||
reasoning = "test",
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package pw.binom.agentik.sync.test
|
||||
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.LoggedEvent
|
||||
import pw.binom.agentik.sync.SeqMonotonicityViolationException
|
||||
import pw.binom.agentik.sync.UpdatesResponse
|
||||
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFails
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Проверки §6 (компакция) и §8.2 (порядок событий).
|
||||
*/
|
||||
class CompactionAndOrderTest {
|
||||
|
||||
private fun epoch() = Cursor.ZERO.epoch
|
||||
private fun c(n: Int) = Cursor(epoch(), n.toULong())
|
||||
|
||||
@Test
|
||||
fun `compact удаляет старые события и сдвигает minAvailableCursor`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.server.append(samples.messageAppended(0, 1))
|
||||
b.compactServer(upTo = c(1))
|
||||
val compState = b.compactionState()
|
||||
assertEquals(c(2), compState.minAvailableCursor)
|
||||
// После compact курсор ниже min → CursorExpired (§6.3).
|
||||
val resp = b.transport.fetchUpdates(Cursor.ZERO, 100)
|
||||
assertTrue(resp is UpdatesResponse.CursorExpired)
|
||||
// А с курсором >= minAvailableCursor — читается (events with cursor > after).
|
||||
// after=c(2) → events с cursor.number > 2 → только number=3.
|
||||
val after2 = b.transport.fetchUpdates(c(2), 100)
|
||||
assertTrue(after2 is UpdatesResponse.Ok)
|
||||
val ok = after2 as UpdatesResponse.Ok
|
||||
assertEquals(1, ok.events.size)
|
||||
assertEquals(3uL, ok.events.single().cursor.number)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact не трогает события после upTo`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
for (i in 0..5) b.server.append(samples.messageAppended(0, i))
|
||||
b.compactServer(upTo = c(3))
|
||||
// Чтение с c(4) — события начиная с number=5 (events with cursor > after).
|
||||
val all = b.transport.fetchUpdates(c(4), 100)
|
||||
assertTrue(all is UpdatesResponse.Ok)
|
||||
val evs = (all as UpdatesResponse.Ok).events
|
||||
assertEquals(listOf(5uL, 6uL), evs.map { it.cursor.number })
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cursor ниже minAvailableCursor дает CursorExpired`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.compactServer(upTo = c(1))
|
||||
// after=c(0) < minAvailable=c(2) → CursorExpired.
|
||||
val resp = b.transport.fetchUpdates(Cursor.ZERO, 100)
|
||||
assertTrue(resp is UpdatesResponse.CursorExpired)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cursor ровно на minAvailableCursor дает Ok`() = kotlinx.coroutines.runBlocking {
|
||||
// minAvailable=c(2) после compact(upTo=c(1)); запрос after=c(2) (= min) → cursor > c(2) → пусто.
|
||||
// CursorExpired НЕ возвращается — граница включительная.
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0)) // cursor.number=1
|
||||
b.server.append(samples.messageAppended(0, 0)) // cursor.number=2
|
||||
b.compactServer(upTo = c(1))
|
||||
assertEquals(c(2), b.compactionState().minAvailableCursor)
|
||||
val resp = b.transport.fetchUpdates(c(2), 100)
|
||||
assertTrue(resp is UpdatesResponse.Ok)
|
||||
val ok = resp as UpdatesResponse.Ok
|
||||
// events с cursor > c(2) — пусто.
|
||||
assertEquals(0, ok.events.size)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cursor ниже minAvailableCursor на 1 дает CursorExpired`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0)) // cursor.number=1
|
||||
b.server.append(samples.messageAppended(0, 0)) // cursor.number=2
|
||||
b.compactServer(upTo = c(1))
|
||||
// after=c(1) < min=c(2) → CursorExpired.
|
||||
val resp = b.transport.fetchUpdates(c(1), 100)
|
||||
assertTrue(resp is UpdatesResponse.CursorExpired)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `события в журнале строго упорядочены`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
for (i in 0..10) b.server.append(samples.messageAppended(0, i))
|
||||
val events = b.server.readUpdates(Cursor.ZERO, 100)
|
||||
assertEquals(events.size, events.distinctBy { it.cursor }.size, "cursor уникальны")
|
||||
val nums = events.map { it.cursor.number }
|
||||
assertEquals(nums.sorted(), nums, "numbers отсортированы по возрастанию")
|
||||
for (i in 1 until events.size) {
|
||||
assertEquals(events[i - 1].cursor.next(), events[i].cursor, "нет дыр")
|
||||
}
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyRemote вне порядка бросает исключение`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.engine.sync()
|
||||
// Пробуем применить event с cursor меньшим, чем clientLastCursor.
|
||||
val past = LoggedEvent(
|
||||
cursor = b.clientLastCursor(),
|
||||
eventId = "old-event",
|
||||
payload = samples.messageAppended(0, 99),
|
||||
createdAt = someInstant,
|
||||
)
|
||||
assertFails {
|
||||
b.client.applyRemote(past)
|
||||
}
|
||||
// Sanity: исключение именно монотонности.
|
||||
runCatching { b.client.applyRemote(past) }.onFailure {
|
||||
assertTrue(it is SeqMonotonicityViolationException, "got ${it::class.simpleName}")
|
||||
}
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchUpdates с limit меньше общего числа возвращает hasMore=true`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
for (i in 0..4) b.server.append(samples.messageAppended(0, i))
|
||||
val resp = b.transport.fetchUpdates(Cursor.ZERO, limit = 2)
|
||||
assertTrue(resp is UpdatesResponse.Ok)
|
||||
val ok = resp as UpdatesResponse.Ok
|
||||
assertEquals(2, ok.events.size)
|
||||
assertTrue(ok.hasMore)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync догружает события порциями`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
for (i in 0..5) b.server.append(samples.messageAppended(0, i))
|
||||
// pageSize=2 — должно быть 3 итерации цикла.
|
||||
b.engine.sync(pageSize = 2)
|
||||
val msgs = b.clientReadState().messages.values.flatten()
|
||||
assertEquals(6, msgs.size)
|
||||
assertEquals(b.serverLastCursor(), b.clientLastCursor())
|
||||
b.close()
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package pw.binom.agentik.sync.test
|
||||
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
/**
|
||||
* Тесты [Cursor] — компактного (epoch + number) курсора журнала. Эпоха
|
||||
* задаёт «поколение» журнала (меняется при wipe/restore сервера),
|
||||
* `number` — монотонный счётчик внутри эпохи.
|
||||
*/
|
||||
class CursorTest {
|
||||
|
||||
@Test
|
||||
fun `constructor строит epoch-number_hex`() {
|
||||
val c = Cursor(epoch = "v1", number = 42uL)
|
||||
assertEquals("v1", c.epoch)
|
||||
assertEquals(42uL, c.number)
|
||||
// toString() — debug-форма, raw представление собирается как "<epoch>-<number.toString(16)>".
|
||||
assertEquals("v1-2a", "${c.epoch}-${c.number.toString(16)}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromString разбирает корректный курсор`() {
|
||||
val c = Cursor.fromString("v2-1f4")
|
||||
assertEquals("v2", c.epoch)
|
||||
assertEquals(0x1f4uL, c.number)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trip через строковое представление`() {
|
||||
val original = Cursor("v3", 0xdeadbeefuL)
|
||||
val raw = "${original.epoch}-${original.number.toString(16)}"
|
||||
val parsed = Cursor.fromString(raw)
|
||||
assertEquals(original.epoch, parsed.epoch)
|
||||
assertEquals(original.number, parsed.number)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next инкрементирует number сохраняя epoch`() {
|
||||
val c = Cursor("v1", 5uL).next()
|
||||
assertEquals("v1", c.epoch)
|
||||
assertEquals(6uL, c.number)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next переходит через границу ULong`() {
|
||||
val maxMinusOne = Cursor("v1", ULong.MAX_VALUE - 1uL).next()
|
||||
assertEquals(ULong.MAX_VALUE, maxMinusOne.number)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareTo внутри одной эпохи`() {
|
||||
val a = Cursor("v1", 1uL)
|
||||
val b = Cursor("v1", 2uL)
|
||||
val c = Cursor("v1", 2uL)
|
||||
assertEquals(true, a < b)
|
||||
assertEquals(true, b > a)
|
||||
assertEquals(0, b.compareTo(c))
|
||||
assertNotEquals(0, a.compareTo(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareTo между разными эпохами лексикографически по epoch`() {
|
||||
// Реализация сравнивает epoch как строку, потом number как число.
|
||||
// "v10" vs "v2": "v10" < "v2" лексикографически, потому что '1' < '2'.
|
||||
// Это by design — нумерация эпох должна быть либо монотонной (v1, v2, v3),
|
||||
// либо фиксированной длины (v01, v02). Внутри одного поколения журнала
|
||||
// эпоха не меняется, так что лексикографическое сравнение эпох в проде не
|
||||
// нужно — оно существует только для сортировки/отсечения.
|
||||
val v2 = Cursor("v2", 999uL)
|
||||
val v10 = Cursor("v10", 0uL)
|
||||
assertEquals(true, v10 < v2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareTo с фиксированной длиной epoch монотонна`() {
|
||||
val v01 = Cursor("v01", 0uL)
|
||||
val v02 = Cursor("v02", 0uL)
|
||||
val v10 = Cursor("v10", 0uL)
|
||||
assertEquals(true, v01 < v02)
|
||||
assertEquals(true, v02 < v10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromString бросает на пустую строку`() {
|
||||
assertFailsWith<IllegalArgumentException> { Cursor.fromString("") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromString бросает на строку без разделителя`() {
|
||||
assertFailsWith<IllegalArgumentException> { Cursor.fromString("noseparator") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromString бросает на строку с несколькими разделителями`() {
|
||||
assertFailsWith<IllegalArgumentException> { Cursor.fromString("v1-1-2") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromString бросает на невалидный hex в number`() {
|
||||
assertFailsWith<IllegalArgumentException> { Cursor.fromString("v1-zz") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `constructor бросает на пустую epoch`() {
|
||||
assertFailsWith<IllegalArgumentException> { Cursor("", 0uL) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `constructor бросает на epoch с разделителем`() {
|
||||
assertFailsWith<IllegalArgumentException> { Cursor("v-1", 0uL) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `number в верхнем регистре hex при fromString`() {
|
||||
// fromString использует toULong(16), который case-insensitive.
|
||||
val c = Cursor.fromString("v1-FF")
|
||||
assertEquals(255uL, c.number)
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package pw.binom.agentik.sync.test
|
||||
|
||||
import pw.binom.agentik.sync.ChatId
|
||||
import pw.binom.agentik.sync.MessageId
|
||||
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Проверки §7.1 и §7.2 спеки: редактирование и удаление сообщений и чатов.
|
||||
*
|
||||
* Главные проверяемые инварианты:
|
||||
* - Edited заменяет content «на месте», last_cursor обновляется.
|
||||
* - Deleted физически удаляет строку (см. §7.2).
|
||||
* - replace при resync подхватывает новый контент / отсутствующие строки.
|
||||
*/
|
||||
class EditDeleteTest {
|
||||
|
||||
@Test
|
||||
fun `edit заменяет content и обновляет lastCursor`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
val created = b.server.append(samples.chatCreated(0))
|
||||
val appended = b.server.append(samples.messageAppended(0, 0, content = "v1"))
|
||||
val edited = b.server.append(samples.messageEdited(0, 0, newContent = "v2"))
|
||||
b.engine.sync()
|
||||
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
|
||||
assertEquals(1, msgs.size)
|
||||
assertEquals("v2", msgs.single().content)
|
||||
assertEquals(edited.cursor, msgs.single().lastCursor)
|
||||
// lastCursor строки > cursor appended.
|
||||
assertTrue(msgs.single().lastCursor > appended.cursor)
|
||||
// created event's cursor меньше appended, edited больше.
|
||||
assertTrue(created.cursor < appended.cursor)
|
||||
assertTrue(edited.cursor > appended.cursor)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete физически убирает строку`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.server.append(samples.messageAppended(0, 1))
|
||||
b.server.append(samples.messageDeleted(0, 1))
|
||||
b.engine.sync()
|
||||
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
|
||||
assertEquals(1, msgs.size)
|
||||
assertEquals(MessageId("msg-0-0"), msgs.single().id)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resync после edit подхватывает новый content из снапшота`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0, content = "v1"))
|
||||
b.engine.sync()
|
||||
// Edit на сервере.
|
||||
b.server.append(samples.messageEdited(0, 0, newContent = "v2-final"))
|
||||
b.compactServer(upTo = b.server.lastCursor())
|
||||
// Sync с clientLastCursor=1 (чат создан) → cursor_expired → replace.
|
||||
b.engine.sync()
|
||||
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
|
||||
assertEquals("v2-final", msgs.single().content)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resync после delete убирает строку через снапшот`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.server.append(samples.messageAppended(0, 1))
|
||||
b.engine.sync()
|
||||
b.server.append(samples.messageDeleted(0, 1))
|
||||
b.compactServer(upTo = b.server.lastCursor())
|
||||
b.engine.sync()
|
||||
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
|
||||
assertEquals(1, msgs.size)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chat deleted физически удаляет чат из state`() = kotlinx.coroutines.runBlocking {
|
||||
// §7.2 + §10.1 спеки: «Не делать tombstones, если не нужно показывать
|
||||
// удалено. Физическое удаление + replace при resync решают всё.»
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.chatDeleted(0))
|
||||
b.engine.sync()
|
||||
val chat = b.clientReadState().chats[ChatId("chat0")]
|
||||
assertEquals(null, chat, "чат должен быть физически удалён")
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit в середине диалога не теряет соседние сообщения`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0, content = "a"))
|
||||
b.server.append(samples.messageAppended(0, 1, content = "b"))
|
||||
b.server.append(samples.messageAppended(0, 2, content = "c"))
|
||||
b.server.append(samples.messageEdited(0, 1, newContent = "B!"))
|
||||
b.engine.sync()
|
||||
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
|
||||
assertEquals(3, msgs.size)
|
||||
assertEquals("a", msgs[0].content)
|
||||
assertEquals("B!", msgs[1].content)
|
||||
assertEquals("c", msgs[2].content)
|
||||
b.close()
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package pw.binom.agentik.sync.test
|
||||
|
||||
import pw.binom.agentik.sync.Cursor
|
||||
import pw.binom.agentik.sync.CursorExpiredReason
|
||||
import pw.binom.agentik.sync.UpdatesResponse
|
||||
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Проверки §6.3 спеки: поведение при смене эпохи (epoch mismatch) и при
|
||||
* протухшем по той же эпохе курсоре (cursor too old). В обоих случаях
|
||||
* клиент обязан сделать полный resync (replaceState).
|
||||
*
|
||||
* - **WRONG_EPOCH** — клиент прислал курсор из другой эпохи (например,
|
||||
* сервер был wiped или восстановлен из бэкапа). Сервер его отвергает
|
||||
* с `reason = WRONG_EPOCH`.
|
||||
* - **TOO_OLD** — клиент прислал курсор из той же эпохи, но ниже
|
||||
* `min_available_cursor` (после compact). Сервер отвергает с
|
||||
* `reason = TOO_OLD`.
|
||||
*
|
||||
* И там, и там клиент делает replaceState и начинает синхронизацию
|
||||
* заново. Логически реакция одинаковая, но причина разная — отделяем
|
||||
* для диагностики.
|
||||
*/
|
||||
class EpochMismatchTest {
|
||||
|
||||
@Test
|
||||
fun `WRONG_EPOCH - клиент со старой эпохой получает CursorExpired reason WRONG_EPOCH`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0, content = "v1"))
|
||||
b.engine.sync()
|
||||
val clientCursorBefore = b.client.syncState().cursor
|
||||
val clientEpochBefore = clientCursorBefore.epoch
|
||||
|
||||
// Сервер wipe'ает журнал и начинает новую эпоху.
|
||||
val newCursor = b.serverBeginNewEpoch(newEpoch = "v2")
|
||||
assertEquals("v2", newCursor.epoch)
|
||||
assertEquals(0uL, newCursor.number)
|
||||
|
||||
// Клиент по-прежнему сидит в старой эпохе. Сервер ему отвечает.
|
||||
val resp = b.transport.fetchUpdates(Cursor(clientEpochBefore, 100uL), 100)
|
||||
assertTrue(resp is UpdatesResponse.CursorExpired)
|
||||
val exp = resp as UpdatesResponse.CursorExpired
|
||||
assertEquals(CursorExpiredReason.WRONG_EPOCH, exp.reason)
|
||||
// Сервер подсказывает, в какой эпохе теперь живёт.
|
||||
assertEquals("v2", exp.current.epoch)
|
||||
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TOO_OLD - клиент с тем же epoch но ниже min_available_cursor получает CursorExpired reason TOO_OLD`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.server.append(samples.messageAppended(0, 1))
|
||||
b.engine.sync()
|
||||
|
||||
// Сервер compact'ит все события, клиентский курсор становится ниже floor.
|
||||
b.compactServer(upTo = b.serverLastCursor())
|
||||
val resp = b.transport.fetchUpdates(Cursor.ZERO, 100)
|
||||
assertTrue(resp is UpdatesResponse.CursorExpired)
|
||||
val exp = resp as UpdatesResponse.CursorExpired
|
||||
assertEquals(CursorExpiredReason.TOO_OLD, exp.reason)
|
||||
// Эпоха совпадает.
|
||||
assertEquals(b.client.syncState().cursor.epoch, exp.current.epoch)
|
||||
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync после WRONG_EPOCH автоматически делает replaceState и подхватывает новое состояние`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0, content = "old"))
|
||||
b.engine.sync()
|
||||
// Сервер wipe'ает журнал. Клиент по-прежнему сидит в старой эпохе.
|
||||
b.serverBeginNewEpoch("v2")
|
||||
b.server.append(samples.chatCreated(1))
|
||||
b.server.append(samples.messageAppended(1, 0, content = "fresh"))
|
||||
|
||||
// Sync видит WRONG_EPOCH → replaceState.
|
||||
val report = b.engine.sync()
|
||||
assertTrue(report.resolvedCursorGap, "sync должен был резолвить gap")
|
||||
|
||||
// Клиент в новой эпохе, не имеет старого чата, имеет новый.
|
||||
assertEquals("v2", b.client.syncState().cursor.epoch)
|
||||
val state = b.clientReadState()
|
||||
assertEquals(null, state.chats[pw.binom.agentik.sync.ChatId("chat0")])
|
||||
assertEquals("fresh", state.messages[pw.binom.agentik.sync.ChatId("chat1")]?.single()?.content)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync после TOO_OLD тоже делает replaceState`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
b.server.append(samples.chatCreated(0))
|
||||
b.server.append(samples.messageAppended(0, 0))
|
||||
b.engine.sync()
|
||||
b.server.append(samples.messageAppended(0, 1, content = "still-cached"))
|
||||
b.compactServer(upTo = b.serverLastCursor())
|
||||
|
||||
val report = b.engine.sync()
|
||||
assertTrue(report.resolvedCursorGap)
|
||||
// После replaceState клиент имеет полный снапшот — оба сообщения.
|
||||
val msgs = b.clientReadState().messages[pw.binom.agentik.sync.ChatId("chat0")].orEmpty()
|
||||
assertTrue(msgs.any { it.content == "still-cached" }, "новое сообщение должно быть в снапшоте")
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beginNewEpoch сбрасывает seq counter - новые события начинают от 1`() = kotlinx.coroutines.runBlocking {
|
||||
val b = InMemorySyncBundle.create()
|
||||
for (i in 0..10) b.server.append(samples.messageAppended(0, i))
|
||||
val before = b.serverLastCursor()
|
||||
assertTrue(before.number > 10uL, "до wipe курсор должен быть больше 10")
|
||||
|
||||
b.serverBeginNewEpoch("v2")
|
||||
b.server.append(samples.chatCreated(0))
|
||||
val after = b.serverLastCursor()
|
||||
// После wipe seq начинается заново: новое событие = 1 (а не 12).
|
||||
assertEquals("v2", after.epoch)
|
||||
assertEquals(1uL, after.number)
|
||||
b.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epoch нужен для проверки stale-ness - курсор из другой эпохи не сравнивается через compareTo на «свежесть»`() {
|
||||
// Документируем семантику: lexCompareTo для разных эпох упорядочит,
|
||||
// но isAfter/isBefore возвращает null. Это by design.
|
||||
val a = Cursor("v1", 100uL)
|
||||
val b2 = Cursor("v2", 1uL)
|
||||
// Lexicographic — лексикографически по строке эпохи: "v1" < "v2".
|
||||
assertTrue(a < b2, "v1 лексикографически меньше v2")
|
||||
// isAfter возвращает null — семантически курсоры несравнимы.
|
||||
assertEquals(null, a.isAfter(b2))
|
||||
assertEquals(null, b2.isAfter(a))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user