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 kotlinx.coroutines.flow.emptyFlow
|
||||||
import pw.binom.agentik.journal.ConversationStore
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
import pw.binom.agentik.journal.JournalStore
|
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.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentInfo
|
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 events(after: Cursor?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
|
||||||
override fun agentEvents(after: Cursor?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>()
|
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 fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>()
|
||||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests")
|
override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests")
|
||||||
|
|
||||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
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 =
|
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 {
|
override fun createConversation(temp: Boolean): Conversation {
|
||||||
createCount++
|
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(":proto"))
|
||||||
api(project(":outbox-api"))
|
api(project(":outbox-api"))
|
||||||
api(project(":journal-api"))
|
api(project(":journal-api"))
|
||||||
implementation(project(":journal-inmemory"))
|
|
||||||
|
|
||||||
api(libs.ktor.client.core)
|
api(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
|||||||
@@ -1,24 +1,8 @@
|
|||||||
package pw.binom.agentik.client
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
import io.ktor.client.engine.HttpClientEngineFactory
|
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 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 pw.binom.agentik.proto.Agent
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
||||||
@@ -28,6 +12,11 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
|
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
|
||||||
* снаружи — всё под капотом.
|
* снаружи — всё под капотом.
|
||||||
*
|
*
|
||||||
|
* Возвращает **чистый** HTTP-[Agent]: [Agent.conversationStore] ходит по сети
|
||||||
|
* напрямую. Локальный кэш с курсор-синхронизацией — отдельно, через
|
||||||
|
* `:client-sync` (`Agent.withLocalSync(localStore, cursor, scope)`); либа
|
||||||
|
* намеренно не прячет за собой ни стор, ни курсор, ни их персистентность.
|
||||||
|
*
|
||||||
* ```
|
* ```
|
||||||
* val agent = AgentikAgent(
|
* val agent = AgentikAgent(
|
||||||
* id = "my-client",
|
* id = "my-client",
|
||||||
@@ -41,7 +30,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
* val snap = agent.chatSnapshot(conv.id)
|
* val snap = agent.chatSnapshot(conv.id)
|
||||||
* agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
|
* agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
|
||||||
* .collect { ... }
|
* .collect { ... }
|
||||||
* agent.close() // закрывает HttpClient + локальный кэш
|
* agent.close() // закрывает HttpClient
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* ## Что клиент должен хранить локально (persistence)
|
* ## Что клиент должен хранить локально (persistence)
|
||||||
@@ -78,22 +67,16 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
*
|
*
|
||||||
* ## Локальный кэш списка бесед
|
* ## Локальный кэш списка бесед
|
||||||
*
|
*
|
||||||
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
|
* В этом модуле его **нет**. Синхронизация локального кэша с удалённым агентом
|
||||||
* Внутри лежит [InMemoryMutableConversationStore], который:
|
* (снапшот + дельты по курсору, durable-закладка) живёт в `:client-sync` как
|
||||||
* 1. На старте берёт `conversationsSnapshot()` (полный список + курсор) и
|
* `Agent.withLocalSync(localStore, cursor, scope)`. Хост сам решает, где хранить
|
||||||
* приводит к нему локальную копию.
|
* [pw.binom.agentik.journal.MutableConversationStore] и
|
||||||
* 2. Подписывается на `outbox.agentEvents(after = snapshot.cursor)` → для каждого
|
* [pw.binom.agentik.cursor.MutableCursorHolder] (файл / SQLite / DataStore),
|
||||||
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
|
* и обязан держать их durable-парой.
|
||||||
* соответствующий `upsert/delete/rename/touch` к локальной копии.
|
|
||||||
* 3. При `OutboxGapException` повторяет с шага 1 (полный resync).
|
|
||||||
*
|
|
||||||
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
|
|
||||||
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
|
|
||||||
* через [Agent] и **не** через `conversationStore` (он read-only).
|
|
||||||
*
|
*
|
||||||
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
|
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
|
||||||
* HttpClient + локальный кэш + background-coroutine (идемпотентно).
|
* HttpClient (идемпотентно). После этого `createConversation` /
|
||||||
* После этого `createConversation` / `getConversation` etc. не определены.
|
* `getConversation` etc. не определены.
|
||||||
*/
|
*/
|
||||||
fun AgentikAgent(
|
fun AgentikAgent(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -102,106 +85,5 @@ fun AgentikAgent(
|
|||||||
token: String? = null,
|
token: String? = null,
|
||||||
): Agent {
|
): Agent {
|
||||||
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
|
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
|
||||||
val client = runBlocking { AgentClient.create(id = id, baseUrl = baseUrl, httpClient = httpClient) }
|
return 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
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.OutboxGapException
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ internal class HttpEventStore(
|
|||||||
httpClient.prepareGet(url) {
|
httpClient.prepareGet(url) {
|
||||||
noReadTimeout()
|
noReadTimeout()
|
||||||
if (after != null) {
|
if (after != null) {
|
||||||
parameter("epoch", after.epoch)
|
parameter("epoch", after.createdAt)
|
||||||
parameter("offset", after.offset)
|
parameter("offset", after.offset)
|
||||||
}
|
}
|
||||||
}.execute { response ->
|
}.execute { response ->
|
||||||
@@ -115,7 +115,7 @@ internal data class GapResponse(
|
|||||||
|
|
||||||
private suspend fun HttpResponse.toGapException(requested: Cursor?): OutboxGapException {
|
private suspend fun HttpResponse.toGapException(requested: Cursor?): OutboxGapException {
|
||||||
val dto = runCatching { agentikJson.decodeFromString(GapResponse.serializer(), bodyAsText()) }.getOrNull()
|
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(
|
return OutboxGapException(
|
||||||
requested = requested,
|
requested = requested,
|
||||||
oldest = dto?.oldest ?: fallback,
|
oldest = dto?.oldest ?: fallback,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
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.OutboxGapException
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import kotlin.concurrent.atomics.AtomicBoolean
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
@@ -225,7 +225,7 @@ class ReconnectingOutbox(
|
|||||||
var gap: OutboxGapException? = null
|
var gap: OutboxGapException? = null
|
||||||
val error: Throwable? = try {
|
val error: Throwable? = try {
|
||||||
outbox.events(after = cursor).collect { event ->
|
outbox.events(after = cursor).collect { event ->
|
||||||
lastSeen.store(Cursor(epoch = cursor?.epoch ?: "", offset = event.offset))
|
lastSeen.store(event.offset)
|
||||||
_events.emit(event)
|
_events.emit(event)
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
connected = true
|
connected = true
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import kotlinx.coroutines.test.advanceTimeBy
|
|||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
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.OutboxStore
|
||||||
import pw.binom.agentik.outbox.OutboxGapException
|
import pw.binom.agentik.outbox.OutboxGapException
|
||||||
import pw.binom.agentik.outbox.DurableEvent
|
import pw.binom.agentik.outbox.DurableEvent
|
||||||
@@ -59,17 +59,19 @@ internal class FakeOutbox : OutboxStore {
|
|||||||
after: Cursor?,
|
after: Cursor?,
|
||||||
conversationId: String?,
|
conversationId: String?,
|
||||||
): Flow<CommonEvent.Conversation> = emptyFlow()
|
): Flow<CommonEvent.Conversation> = emptyFlow()
|
||||||
override suspend fun currentCursor(): Cursor = Cursor(epoch = "test", offset = -1L)
|
override suspend fun currentCursor(): Cursor = Cursor(createdAt = TEST_EPOCH, offset = 0u)
|
||||||
override suspend fun oldestCursor(): Cursor = Cursor(epoch = "test", offset = -1L)
|
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = TEST_EPOCH, offset = 0u)
|
||||||
override fun close() { channel.close() }
|
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 =
|
private fun testEvent(dateMs: Long): CommonEvent =
|
||||||
CommonEvent.Conversation(
|
CommonEvent.Conversation(
|
||||||
date = Instant.fromEpochMilliseconds(dateMs),
|
date = Instant.fromEpochMilliseconds(dateMs),
|
||||||
offset = dateMs,
|
offset = testCursor(dateMs.toULong()),
|
||||||
conversationId = "test",
|
conversationId = "test",
|
||||||
event = DurableEvent.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
|
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 ->
|
fun `gap is not retried and emits Gap status`() = runConnectionTest(attempts = 5) { ctx ->
|
||||||
val fake = ctx.fake
|
val fake = ctx.fake
|
||||||
val gap = OutboxGapException(
|
val gap = OutboxGapException(
|
||||||
requested = Cursor("test", -1L),
|
requested = testCursor(0u),
|
||||||
oldest = Cursor("test", 10L),
|
oldest = testCursor(10u),
|
||||||
current = Cursor("test", 20L),
|
current = testCursor(20u),
|
||||||
)
|
)
|
||||||
fake.throwAtNextEvent(gap)
|
fake.throwAtNextEvent(gap)
|
||||||
ctx.advanceAndDrain(50)
|
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)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// KMP-реализация :outbox-api `CursorStore` поверх ksqlite.
|
// KMP-реализация :cursor-api `CursorHolder` поверх ksqlite.
|
||||||
// Минимальная — только таблица `outbox_cursor` (одна строка: epoch + next offset).
|
// Минимальная — только таблица `outbox_cursor` (одна строка: epoch + next offset).
|
||||||
// Даёт production-агенту персистентный `OffsetSequencer`: обычный рестарт сервера
|
// Даёт production-агенту персистентный счётчик: обычный рестарт сервера
|
||||||
// не ротирует epoch, клиент продолжает инкрементально.
|
// не ротирует epoch, клиент продолжает инкрементально.
|
||||||
//
|
//
|
||||||
// Цели сборки — jvm() + linuxX64() + mingwX64() (как у остальных ksqlite-модулей).
|
// Цели сборки — jvm() + linuxX64() + mingwX64() (как у остальных ksqlite-модулей).
|
||||||
@@ -20,7 +20,7 @@ kotlin {
|
|||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.ksqlite)
|
implementation(libs.ksqlite)
|
||||||
|
|
||||||
api(project(":outbox-api"))
|
api(project(":cursor-api"))
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
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
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Имена таблиц/колонок для ksqlite-бэкенда `:outbox-api`.
|
* Имена таблиц/колонок для ksqlite-реализации [pw.binom.agentik.cursor.CursorHolder].
|
||||||
*
|
*
|
||||||
* Владеет одной таблицей `outbox_cursor` — ровно одна строка (`id = 1`) с
|
* Владеет одной таблицей `outbox_cursor` — ровно одна строка (`id = 1`) с
|
||||||
* персистентной позицией счётчика событий агента (`epoch` + next offset).
|
* персистентной позицией счётчика событий агента ([Cursor.createdAt] +
|
||||||
|
* next offset), см. [KsqliteCursorHolder].
|
||||||
*
|
*
|
||||||
* Как и остальные ksqlite-модули, `user_version` как gate не используется
|
* `user_version` как gate не используется (split-world: несколько модулей
|
||||||
* (split-world: несколько модулей ставят его независимо) — [migrate] просто
|
* ставят его независимо) — [migrate] просто идемпотентно прогоняет DDL.
|
||||||
* идемпотентно прогоняет DDL.
|
|
||||||
*/
|
*/
|
||||||
object Schema {
|
object Schema {
|
||||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||||
const val CURRENT_VERSION: Int = 1
|
const val CURRENT_VERSION: Int = 2
|
||||||
|
|
||||||
const val TABLE = "outbox_cursor"
|
const val TABLE = "outbox_cursor"
|
||||||
const val COL_ID = "id"
|
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 (эксклюзивную границу), а не последний выданный. */
|
/** Хранит **next** offset (эксклюзивную границу), а не последний выданный. */
|
||||||
const val COL_OFFSET = "next_offset"
|
const val COL_OFFSET = "next_offset"
|
||||||
|
|
||||||
private val ddl = """
|
private val ddl = """
|
||||||
CREATE TABLE IF NOT EXISTS $TABLE (
|
CREATE TABLE IF NOT EXISTS $TABLE (
|
||||||
$COL_ID INTEGER PRIMARY KEY CHECK ($COL_ID = 1),
|
$COL_ID INTEGER PRIMARY KEY CHECK ($COL_ID = 1),
|
||||||
$COL_EPOCH TEXT NOT NULL,
|
$COL_CREATED_AT INTEGER NOT NULL,
|
||||||
$COL_OFFSET 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-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-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-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" }
|
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
|
||||||
|
|
||||||
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
|
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":content-api"))
|
api(project(":content-api"))
|
||||||
|
api(project(":cursor-api"))
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
api(libs.kotlinx.serialization.json)
|
api(libs.kotlinx.serialization.json)
|
||||||
|
|||||||
@@ -14,48 +14,27 @@ import kotlin.time.Instant
|
|||||||
* Никаких обновлений, никакого удаления (кроме каскадного вместе
|
* Никаких обновлений, никакого удаления (кроме каскадного вместе
|
||||||
* с ConversationStore.delete).
|
* с ConversationStore.delete).
|
||||||
*
|
*
|
||||||
* ## Два способа адресации позиции
|
* Адресация позиции — по [Instant] `createdAt` («дай всё после даты»).
|
||||||
* - **по [Instant] `createdAt`** — legacy, «дай всё после даты»;
|
* Курсора/`seq` здесь нет: монотонный `(эпоха, offset)` для удалённого
|
||||||
* - **по [MessageRecord.seq]** (монотонный per-agent offset) — протокол
|
* resume и снапшотов живёт в sync-слое, который строит свой поток поверх
|
||||||
* снапшотов: диапазон `afterSeq < seq <= upToSeq` даёт **конечное и
|
* журнала.
|
||||||
* стабильное** множество строк. Catch-up: `afterSeq = <курсор>, upToSeq = MAX`.
|
|
||||||
* Снапшот с курсором C: `afterSeq = -1, upToSeq = C`.
|
|
||||||
*
|
|
||||||
* [listFlowSeq] использует keyset-пагинацию (`seq > last`), а не `OFFSET` —
|
|
||||||
* иначе конкурентная вставка/удаление сдвигает окно и молча теряет строки.
|
|
||||||
*/
|
*/
|
||||||
interface JournalStore : AutoCloseable {
|
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>
|
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] всего. */
|
/** Сколько сообщений в диалоге [conversationId] всего. */
|
||||||
suspend fun count(conversationId: String): Long
|
suspend fun count(conversationId: String): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сколько сообщений создано **позже** [after] (строго `createdAt > after`).
|
* Сколько сообщений создано **позже** [after] (строго `createdAt > after`).
|
||||||
* Legacy unread-бейдж по времени.
|
* Unread-бейдж по времени.
|
||||||
*/
|
*/
|
||||||
suspend fun count(conversationId: String, after: Instant): Long
|
suspend fun count(conversationId: String, after: Instant): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сколько сообщений имеют `seq > [afterSeq]`. Cursor-версия unread-бейджа:
|
* Cold-flow paging по [Instant]. Default-реализация делает N+1 round-trip.
|
||||||
* `count(convId, afterSeq = lastSeenOffset)`.
|
|
||||||
*/
|
|
||||||
suspend fun count(conversationId: String, afterSeq: Long): Long
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cold-flow paging (legacy, по [Instant]). Default-реализация делает N+1
|
|
||||||
* round-trip.
|
|
||||||
*/
|
*/
|
||||||
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
|
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
|
||||||
var offset = 0
|
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 {
|
companion object {
|
||||||
const val PAGE_SIZE = 100
|
const val PAGE_SIZE = 100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,28 +10,20 @@ import kotlin.time.Instant
|
|||||||
/**
|
/**
|
||||||
* Запись в таблице `message` (append-only audit).
|
* Запись в таблице `message` (append-only audit).
|
||||||
*
|
*
|
||||||
* ## [seq] — курсор записи
|
* Позиция записи в потоке задаётся [createdAt] (UTC). Для разрешения ничьих
|
||||||
* [seq] — **тот же монотонный per-agent offset**, что и `offset` соответствующего
|
* (несколько сообщений с одинаковым `Instant`) реализация добавляет вторичный
|
||||||
* events-события (`OffsetSequencer.reserve()`). Writer резервирует offset и
|
* ключ (`id`) при сортировке.
|
||||||
* пишет строку с `seq = offset` **до** append'а события в outbox (инвариант
|
|
||||||
* «сначала состояние, потом событие»).
|
|
||||||
*
|
*
|
||||||
* Нужен для снапшота с «курсором»: клиент берёт `currentCursor() = C` и читает
|
* **Курсора здесь нет.** Монотонный `(эпоха, offset)` и вся семантика
|
||||||
* `listUpTo(convId, C)` — конечное, стабильное множество строк, отражающее
|
* удалённого доступа (снапшоты, resume, gap) — ответственность sync-слоя,
|
||||||
* состояние на момент C. Всё, что появится позже, имеет `seq > C` и приедет
|
* который строит свой упорядоченный поток поверх журнала. Журнал — это просто
|
||||||
* потоком событий.
|
* durable история диалогов.
|
||||||
*
|
|
||||||
* Значение по умолчанию `0L` — для legacy-записей и тестов; production-путь
|
|
||||||
* (`ConversationLoop` / `ToolDispatcher`) всегда выставляет реальный offset.
|
|
||||||
* Запись с `seq = 0` в снапшоте всегда «≤ C», поэтому попадает в снапшот и
|
|
||||||
* (если её событие ещё и в потоке) применяется дважды — идемпотентно, безвредно.
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface MessageRecord {
|
sealed interface MessageRecord {
|
||||||
val id: String
|
val id: String
|
||||||
val conversationId: String
|
val conversationId: String
|
||||||
val createdAt: Instant
|
val createdAt: Instant
|
||||||
val seq: Long
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface Body : MessageRecord {
|
sealed interface Body : MessageRecord {
|
||||||
@@ -46,7 +38,6 @@ sealed interface MessageRecord {
|
|||||||
override val content: List<Content>,
|
override val content: List<Content>,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
val context: MessageContext? = null,
|
val context: MessageContext? = null,
|
||||||
override val seq: Long = 0L,
|
|
||||||
) : Body
|
) : Body
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -63,7 +54,6 @@ sealed interface MessageRecord {
|
|||||||
* их не раскрывает.
|
* их не раскрывает.
|
||||||
*/
|
*/
|
||||||
val reasoning: String? = null,
|
val reasoning: String? = null,
|
||||||
override val seq: Long = 0L,
|
|
||||||
) : Body
|
) : Body
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -75,7 +65,6 @@ sealed interface MessageRecord {
|
|||||||
val toolTitle: String?,
|
val toolTitle: String?,
|
||||||
val toolArgsJson: String,
|
val toolArgsJson: String,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
override val seq: Long = 0L,
|
|
||||||
) : MessageRecord
|
) : MessageRecord
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -94,7 +83,6 @@ sealed interface MessageRecord {
|
|||||||
val toolName: String? = null,
|
val toolName: String? = null,
|
||||||
val result: String?,
|
val result: String?,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
override val seq: Long = 0L,
|
|
||||||
) : MessageRecord
|
) : MessageRecord
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -105,6 +93,5 @@ sealed interface MessageRecord {
|
|||||||
val message: String,
|
val message: String,
|
||||||
val code: String?,
|
val code: String?,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
override val seq: Long = 0L,
|
|
||||||
) : MessageRecord
|
) : MessageRecord
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-20
@@ -15,9 +15,8 @@ import kotlin.time.Instant
|
|||||||
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
|
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
|
||||||
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
|
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
|
||||||
*
|
*
|
||||||
* **Контракт `list`**: legacy — `createdAt > after`, `ORDER BY createdAt ASC`
|
* **Контракт `list`**: `createdAt > after`, `ORDER BY createdAt ASC`
|
||||||
* (+`offset/limit`); seq-версия — `afterSeq < seq <= upToSeq`,
|
* (+`offset/limit`).
|
||||||
* `ORDER BY seq ASC` (keyset).
|
|
||||||
*
|
*
|
||||||
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
|
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
|
||||||
* на сервере). [close] — no-op.
|
* на сервере). [close] — no-op.
|
||||||
@@ -45,19 +44,6 @@ class InMemoryJournalStore : MutableJournalStore {
|
|||||||
.toList()
|
.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). */
|
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
|
||||||
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
|
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
|
||||||
records.removeAll { it.conversationId == conversationId }
|
records.removeAll { it.conversationId == conversationId }
|
||||||
@@ -71,10 +57,6 @@ class InMemoryJournalStore : MutableJournalStore {
|
|||||||
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
|
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 }
|
suspend fun size(): Int = mutex.withLock { records.size }
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
api(project(":journal-api"))
|
api(project(":journal-api"))
|
||||||
|
api(project(":sync"))
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
|||||||
+9
-74
@@ -32,25 +32,18 @@ import kotlinx.coroutines.withContext
|
|||||||
* ## Миграция
|
* ## Миграция
|
||||||
*
|
*
|
||||||
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
|
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
|
||||||
* (CREATE TABLE / INDEX IF NOT EXISTS + гейтированный ADD COLUMN), так что
|
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
|
||||||
* лишних эффектов нет ни в standalone-форме, ни в shared-connection bundle'е,
|
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
|
||||||
* где несколько store'ов прогоняют миграцию одной и той же схемы по очереди.
|
* прогоняют миграцию одной и той же схемы по очереди.
|
||||||
*
|
*
|
||||||
* Prepared statements (insert / list / clear) препарируются один раз в
|
* Prepared statements (insert / list / clear / count) препарируются один раз в
|
||||||
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
|
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
|
||||||
* GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
|
* GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
|
||||||
* parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
|
* parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
|
||||||
* (см. [pw.binom.db.ksqlite.StmtHolder]).
|
* (см. [pw.binom.db.ksqlite.StmtHolder]).
|
||||||
*
|
*
|
||||||
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
|
* Адресация — по `created_at` (см. [JournalStore]). Курсор/`seq` здесь
|
||||||
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
|
* отсутствуют: монотонный порядок для удалённого доступа держит sync-слой.
|
||||||
* в [MessageCodecs.kt] рядом.
|
|
||||||
*
|
|
||||||
* ## Курсор ([MessageRecord.seq])
|
|
||||||
*
|
|
||||||
* [list] с диапазоном `[afterSeq] < seq <= [upToSeq]` — keyset-пагинация,
|
|
||||||
* а не `OFFSET`: конкурентная вставка/удаление сдвигает OFFSET-окно и молча
|
|
||||||
* теряет строки. Индекс `idx_msg_conv_seq` покрывает hot-path.
|
|
||||||
*/
|
*/
|
||||||
class KsqliteJournalStore private constructor(
|
class KsqliteJournalStore private constructor(
|
||||||
private val connection: SQLiteConnection,
|
private val connection: SQLiteConnection,
|
||||||
@@ -88,14 +81,14 @@ class KsqliteJournalStore private constructor(
|
|||||||
"""
|
"""
|
||||||
INSERT INTO ${Schema.TABLE_MESSAGE}
|
INSERT INTO ${Schema.TABLE_MESSAGE}
|
||||||
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
(${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})
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
"""
|
"""
|
||||||
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
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}
|
FROM ${Schema.TABLE_MESSAGE}
|
||||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
AND ${Schema.COL_CREATED_AT} > ?
|
AND ${Schema.COL_CREATED_AT} > ?
|
||||||
@@ -103,18 +96,6 @@ class KsqliteJournalStore private constructor(
|
|||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
""".trimIndent()
|
""".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(
|
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||||
)
|
)
|
||||||
@@ -128,13 +109,6 @@ class KsqliteJournalStore private constructor(
|
|||||||
AND ${Schema.COL_CREATED_AT} > ?
|
AND ${Schema.COL_CREATED_AT} > ?
|
||||||
""".trimIndent()
|
""".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) {
|
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
||||||
val (kind, payload) = encodeRecord(record)
|
val (kind, payload) = encodeRecord(record)
|
||||||
@@ -146,7 +120,6 @@ class KsqliteJournalStore private constructor(
|
|||||||
insertStmt.bindText(3, kind)
|
insertStmt.bindText(3, kind)
|
||||||
insertStmt.bindText(4, payload)
|
insertStmt.bindText(4, payload)
|
||||||
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
|
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
|
||||||
insertStmt.bindLong(6, record.seq)
|
|
||||||
insertStmt.executeUpdate()
|
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) {
|
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
clearStmt.reset()
|
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() {
|
override fun close() {
|
||||||
insertStmt.close()
|
insertStmt.close()
|
||||||
listStmt.close()
|
listStmt.close()
|
||||||
listSeqStmt.close()
|
|
||||||
clearStmt.close()
|
clearStmt.close()
|
||||||
countAllStmt.close()
|
countAllStmt.close()
|
||||||
countAfterStmt.close()
|
countAfterStmt.close()
|
||||||
countAfterSeqStmt.close()
|
|
||||||
if (ownsConnection) {
|
if (ownsConnection) {
|
||||||
connection.close()
|
connection.close()
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -43,28 +43,26 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
|||||||
val kind = getText(2)!!
|
val kind = getText(2)!!
|
||||||
val payload = getText(3)!!
|
val payload = getText(3)!!
|
||||||
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
|
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
|
||||||
// Колонка `seq` — 6-я (индекс 5) в SELECT'ах store'а.
|
|
||||||
val seq = getLong(5) ?: 0L
|
|
||||||
return when (kind) {
|
return when (kind) {
|
||||||
"user" -> {
|
"user" -> {
|
||||||
val d = decodeBodyPayload(payload)
|
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" -> {
|
"assistant" -> {
|
||||||
val d = decodeBodyPayload(payload)
|
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" -> {
|
"tool_call" -> {
|
||||||
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
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" -> {
|
"tool_result" -> {
|
||||||
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
|
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" -> {
|
"error" -> {
|
||||||
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
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")
|
else -> error("Unknown message kind in audit log: $kind")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ object Schema {
|
|||||||
/**
|
/**
|
||||||
* Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL.
|
* Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL.
|
||||||
*
|
*
|
||||||
* v2: `message.seq` — монотонный per-agent offset (курсор снапшота),
|
* v4: из `message` убраны колонки курсора (`cursor_created_at` /
|
||||||
* синхронный с `OutboxStore`'ом. Старые БД до-мигрируются через
|
* `cursor_offset`) и индекс `idx_msg_conv_cursor`. Позиция в потоке —
|
||||||
* `ALTER TABLE ... ADD COLUMN` (см. [migrate]).
|
* только `created_at`; монотонный курсор для удалённого доступа живёт в
|
||||||
|
* sync-слое. Существующие БД сохраняют legacy-колонки как неиспользуемые
|
||||||
|
* (в DDL они больше не описываются).
|
||||||
*/
|
*/
|
||||||
const val CURRENT_VERSION: Int = 2
|
const val CURRENT_VERSION: Int = 4
|
||||||
|
|
||||||
// ───── Таблицы ─────
|
// ───── Таблицы ─────
|
||||||
const val TABLE_CONVERSATION = "conversation"
|
const val TABLE_CONVERSATION = "conversation"
|
||||||
@@ -40,16 +42,13 @@ object Schema {
|
|||||||
const val COL_CONVERSATION_ID = "conversation_id"
|
const val COL_CONVERSATION_ID = "conversation_id"
|
||||||
const val COL_KIND = "kind"
|
const val COL_KIND = "kind"
|
||||||
const val COL_PAYLOAD_JSON = "payload_json"
|
const val COL_PAYLOAD_JSON = "payload_json"
|
||||||
/** Монотонный per-agent offset записи (см. `OffsetSequencer`). */
|
|
||||||
const val COL_SEQ = "seq"
|
|
||||||
|
|
||||||
// ───── Индексы ─────
|
// ───── Индексы ─────
|
||||||
const val IDX_CONV_UPDATED = "idx_conv_updated"
|
const val IDX_CONV_UPDATED = "idx_conv_updated"
|
||||||
|
/** Hot-path `list(convId, after, offset, limit)` по времени. */
|
||||||
const val IDX_MSG_CONV = "idx_msg_conv"
|
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 (
|
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
|
||||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||||
$COL_TITLE TEXT,
|
$COL_TITLE TEXT,
|
||||||
@@ -59,28 +58,22 @@ object Schema {
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
private val v2MessageDdl = """
|
private val messageDdl = """
|
||||||
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
||||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||||
$COL_CONVERSATION_ID TEXT NOT NULL,
|
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||||
$COL_KIND TEXT NOT NULL,
|
$COL_KIND TEXT NOT NULL,
|
||||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||||
$COL_CREATED_AT INTEGER NOT NULL,
|
$COL_CREATED_AT INTEGER NOT NULL
|
||||||
$COL_SEQ INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
private val v2IndexesDdl = """
|
private val indexesDdl = """
|
||||||
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
|
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
|
||||||
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
|
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
|
||||||
|
|
||||||
-- Legacy hot-path (по времени): list() по createdAt.
|
|
||||||
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
|
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
|
||||||
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
|
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` — безопасно на
|
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
|
||||||
* уже-мигрированной БД; `ADD COLUMN` гейтится проверкой `PRAGMA table_info`;
|
* уже-мигрированной БД;
|
||||||
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
||||||
* ROLLBACK оставит БД консистентной.
|
* ROLLBACK оставит БД консистентной.
|
||||||
*
|
*
|
||||||
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
|
** NOTE**: в сплит-мире (несколько ksqlite-модулей, каждый владеет своей
|
||||||
* user_version как gate перестал работать — два модуля ставят его в 1,
|
* таблицей) user_version как gate перестал работать — два модуля ставят его
|
||||||
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
* в 1, второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||||
* idempotently; координация multi-module миграций — ответственность
|
* idempotently; координация multi-module миграций — ответственность
|
||||||
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
|
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
|
||||||
*/
|
*/
|
||||||
fun migrate(conn: SQLiteConnection) {
|
fun migrate(conn: SQLiteConnection) {
|
||||||
conn.exec("BEGIN")
|
conn.exec("BEGIN")
|
||||||
try {
|
try {
|
||||||
conn.exec(v1ConversationDdl)
|
conn.exec(conversationDdl)
|
||||||
conn.exec(v2MessageDdl)
|
conn.exec(messageDdl)
|
||||||
// Старая БД (v1) не получит `seq` от CREATE IF NOT EXISTS —
|
conn.exec(indexesDdl)
|
||||||
// добавляем колонку, если её ещё нет.
|
|
||||||
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("COMMIT")
|
conn.exec("COMMIT")
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
runCatching { conn.exec("ROLLBACK") }
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
throw t
|
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-события несут общие типы содержимого из
|
// здесь). Message-события несут общие типы содержимого из
|
||||||
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
|
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
|
||||||
api(project(":content-api"))
|
api(project(":content-api"))
|
||||||
|
api(project(":cursor-api"))
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
api(libs.kotlinx.serialization.json)
|
api(libs.kotlinx.serialization.json)
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import kotlinx.serialization.Serializable
|
|||||||
* ([AgentEvent] via `/events` и [DurableEvent] via `/conversations/{id}/events`);
|
* ([AgentEvent] via `/events` и [DurableEvent] via `/conversations/{id}/events`);
|
||||||
* [CommonEvent] — for those who need everything in one place.
|
* [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`;
|
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.CommonEvent`;
|
||||||
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
|
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
|
||||||
@@ -23,23 +25,13 @@ import kotlinx.serialization.Serializable
|
|||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface CommonEvent {
|
sealed interface CommonEvent {
|
||||||
/** Момент эмиссии в UTC. Только для отображения/сортировки — **не** курсор. */
|
/** Момент эмиссии в UTC. Только для отображения/сортировки. */
|
||||||
val date: Instant
|
val date: Instant
|
||||||
|
|
||||||
/**
|
|
||||||
* Монотонный per-agent offset события — **курсор** (см. [Cursor]).
|
|
||||||
*
|
|
||||||
* Присваивается writer'ом через [OffsetSequencer.reserve] в тот же момент,
|
|
||||||
* что и `seq` соответствующей строки состояния (сначала строка, потом
|
|
||||||
* событие). Клиенты оперируют [Cursor], а не [date].
|
|
||||||
*/
|
|
||||||
val offset: Long
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("agent")
|
@SerialName("agent")
|
||||||
data class Agent(
|
data class Agent(
|
||||||
override val date: Instant,
|
override val date: Instant,
|
||||||
override val offset: Long,
|
|
||||||
val event: AgentEvent,
|
val event: AgentEvent,
|
||||||
) : CommonEvent
|
) : CommonEvent
|
||||||
|
|
||||||
@@ -47,7 +39,6 @@ sealed interface CommonEvent {
|
|||||||
@SerialName("conversation")
|
@SerialName("conversation")
|
||||||
data class Conversation(
|
data class Conversation(
|
||||||
override val date: Instant,
|
override val date: Instant,
|
||||||
override val offset: Long,
|
|
||||||
val conversationId: String,
|
val conversationId: String,
|
||||||
val event: DurableEvent,
|
val event: DurableEvent,
|
||||||
) : CommonEvent
|
) : 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**-потока диалога.
|
* Элемент **durable**-потока диалога.
|
||||||
*
|
*
|
||||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
* Каждое событие несёт [date] — момент эмиссии в UTC (для отображения и
|
||||||
* как курсор («где остановился») при обрыве/переподключении и для разрешения
|
* разрешения порядка при равных timestamps).
|
||||||
* порядка при равных timestamps.
|
|
||||||
*
|
*
|
||||||
* Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
|
* Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
|
||||||
* вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
|
* вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
|
||||||
* ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
|
* ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
|
||||||
* Их можно перезапросить по курсору (`after`).
|
* Durable-история для перезапроса живёт в
|
||||||
|
* [pw.binom.agentik.journal.JournalStore].
|
||||||
*
|
*
|
||||||
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
|
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
|
||||||
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
|
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
|
||||||
|
|||||||
@@ -1,40 +1,25 @@
|
|||||||
package pw.binom.agentik.outbox
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutable вариант [OutboxStore] — добавляет producer-операции [reserveOffset]
|
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
|
||||||
* и [append].
|
|
||||||
*
|
*
|
||||||
* Предназначен **только для producer'ов** (ChatAgent, ConversationLoop,
|
* Предназначен **только для producer'ов** (ChatAgent, ConversationLoop,
|
||||||
* ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only
|
* ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only
|
||||||
* [OutboxStore] — тогда невозможно случайно писать в store из observer'а.
|
* [OutboxStore] — тогда невозможно случайно писать в store из observer'а.
|
||||||
*
|
*
|
||||||
* ## Контракт записи (порядок важен)
|
* ## Контракт записи
|
||||||
* ```
|
* ```
|
||||||
* val n = outbox.reserveOffset() // 1. забронировать offset
|
* journal.append(record) // 1. сначала durable состояние (audit)
|
||||||
* journal.append(record.copy(seq = n)) // 2. сначала состояние
|
* outbox.append(event) // 2. потом live-уведомление
|
||||||
* outbox.append(event.copy(offset = n)) // 3. потом событие
|
|
||||||
* ```
|
* ```
|
||||||
* «Сначала состояние, потом событие» — инвариант, на котором держится
|
* Событие — это уведомление о том, что durable-факт уже записан. Позицию в
|
||||||
* [OutboxStore.currentCursor]: к моменту, когда событие `n` появилось в
|
* потоке (курсор) outbox не ведёт: монотонный порядок для удалённого
|
||||||
* outbox, строка состояния со `seq = n` уже записана.
|
* доступа держит sync-слой.
|
||||||
*
|
|
||||||
* Offset **обязан** быть выставлен в [CommonEvent.offset]; store проверяет
|
|
||||||
* строгую монотонность и бросает [IllegalArgumentException] на нарушение.
|
|
||||||
*/
|
*/
|
||||||
interface MutableOutboxStore : OutboxStore {
|
interface MutableOutboxStore : OutboxStore {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Забронировать следующий монотонный offset (делегирует в
|
* Опубликовать событие в live-поток.
|
||||||
* [OffsetSequencer.reserve]). Вызывается **до** записи состояния.
|
|
||||||
*/
|
|
||||||
suspend fun reserveOffset(): Long
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Положить событие в лог. [CommonEvent.offset] должен быть уже выставлен
|
|
||||||
* (обычно значением из [reserveOffset]).
|
|
||||||
*
|
|
||||||
* **Не идемпотентно** — повторный append с тем же offset'ом нарушает
|
|
||||||
* монотонность и бросит исключение (защита от двойной записи).
|
|
||||||
*/
|
*/
|
||||||
suspend fun append(event: CommonEvent)
|
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
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live-канал **онлайн-событий** ([OnlineEvent]) диалога — стриминга ответа
|
* Live-канал **онлайн-событий** ([OnlineEvent]) диалога — стриминга ответа
|
||||||
@@ -27,7 +28,8 @@ interface OnlineOutbox : AutoCloseable {
|
|||||||
* Подписка на live-поток онлайн-событий диалога [conversationId].
|
* Подписка на live-поток онлайн-событий диалога [conversationId].
|
||||||
* События, эмитнутые до подписки, не приходят.
|
* События, эмитнутые до подписки, не приходят.
|
||||||
*/
|
*/
|
||||||
fun onlineEvents(conversationId: String): Flow<OnlineEvent>
|
fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
|
||||||
|
onlineEvents().filter { it.conversationId == conversationId }
|
||||||
|
|
||||||
/** Освобождает ресурсы. Idempotent. */
|
/** Освобождает ресурсы. Idempotent. */
|
||||||
override fun close()
|
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
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bounded-tail лог **durable**-событий агента.
|
* Live-шина **durable**-событий агента.
|
||||||
*
|
*
|
||||||
* Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle
|
* Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle
|
||||||
* диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и
|
* диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и
|
||||||
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only, не
|
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only,
|
||||||
* сохраняется и не реплеится).
|
* не сохраняется и не реплеится).
|
||||||
*
|
*
|
||||||
* ## Два уровня хранения
|
* ## Роль
|
||||||
* 1. **Этот store** — короткий bounded tail (live SSE + недавний replay),
|
* outbox — это **уведомления в моменте**: расширения агента (reflection,
|
||||||
* эвиктится по TTL/cap (implementation-defined).
|
* skill mining, sub-agents) и UI подписываются на живой поток. Историю
|
||||||
* 2. **Журнал (`:journal-api`)** — полный audit log, никогда не эвиктится.
|
* («что было раньше») отдаёт [pw.binom.agentik.journal.JournalStore] — полный
|
||||||
* Source of truth для всего прошлого. Он и есть «полное состояние»,
|
* durable audit. Всё, что нужно для *удалённого* доступа (resume с курсора,
|
||||||
* которое запрашивает клиент при resync'е.
|
* снапшот + дельты, gap detection), — забота sync-слоя поверх journal/outbox,
|
||||||
|
* а не этого интерфейса.
|
||||||
*
|
*
|
||||||
* ## Курсор, а не дата
|
* ## Курсора и replay здесь нет
|
||||||
* Позиция в потоке — монотонный [Cursor] `(epoch, offset)`, а не wall-clock
|
* [CommonEvent.date] — только метка времени для отображения/сортировки.
|
||||||
* [CommonEvent.date]. Offset уникален и упорядочен даже когда два события
|
* Подписки отдают события, эмитированные **после** вызова (live-only).
|
||||||
* делят одну миллисекунду. `Instant` для этого не годится (лоссов на ничьих),
|
* Клиенту, которому нужен пропущенный хвост, следует читать journal.
|
||||||
* случайный `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».
|
|
||||||
*
|
*
|
||||||
* ## Read-only
|
* ## Read-only
|
||||||
* Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore].
|
* Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore].
|
||||||
*
|
*
|
||||||
* ## Подписки
|
* ## Подписки
|
||||||
* Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая
|
* Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая
|
||||||
* независимая подписка** (cold Flow). `after == null` → только live (события
|
* независимая подписка** (cold Flow). Иные consumer'ы видят тот же live-tail;
|
||||||
* с момента вызова). Иные consumer'ы видят тот же live-tail; каждая подписка —
|
* каждая подписка — своя проекция.
|
||||||
* своя проекция.
|
|
||||||
*/
|
*/
|
||||||
interface OutboxStore : AutoCloseable {
|
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]).
|
* Подписка только на conversation-события ([CommonEvent.Conversation]).
|
||||||
*
|
*
|
||||||
* - `conversationId == null` → все диалоги;
|
* - `conversationId == null` → все диалоги;
|
||||||
* - `conversationId != null` → только этот диалог.
|
* - `conversationId != null` → только этот диалог.
|
||||||
*
|
|
||||||
* Семантика [after] и `gap` идентична [events].
|
|
||||||
*/
|
*/
|
||||||
fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
fun conversationEvents(conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||||
events(after)
|
events()
|
||||||
.filterIsInstance<CommonEvent.Conversation>()
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
.let { filtered ->
|
.let { filtered ->
|
||||||
if (conversationId == null) filtered
|
if (conversationId == null) filtered
|
||||||
@@ -91,32 +57,9 @@ interface OutboxStore : AutoCloseable {
|
|||||||
/**
|
/**
|
||||||
* Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/
|
* Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/
|
||||||
* переименование диалога).
|
* переименование диалога).
|
||||||
*
|
|
||||||
* Семантика [after] и `gap` идентична [events].
|
|
||||||
*/
|
*/
|
||||||
fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
|
fun agentEvents(): Flow<CommonEvent.Agent> =
|
||||||
events(after).filterIsInstance<CommonEvent.Agent>()
|
events().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
|
|
||||||
|
|
||||||
override fun close()
|
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) для стабильного
|
// вызываются на каждом `append`, в одном проходе с amortized O(1) для стабильного
|
||||||
// размера буфера.
|
// размера буфера.
|
||||||
//
|
//
|
||||||
// Зависимости: только `:outbox-api` (api → `:proto` транзитивно).
|
// Зависимости: `:outbox-api` (api → `:proto`/`:cursor-api` транзитивно) и
|
||||||
|
// `:cursor-inmemory` (default-holder для `InMemoryOutboxStore`).
|
||||||
// Никакого I/O — pure in-memory.
|
// Никакого I/O — pure in-memory.
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -20,6 +21,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":outbox-api"))
|
api(project(":outbox-api"))
|
||||||
|
implementation(project(":cursor-inmemory"))
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
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(): 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 suspend fun appendOnline(event: OnlineEvent) = liveFlow.emit(event)
|
||||||
|
|
||||||
override fun tryAppendOnline(event: OnlineEvent): Boolean = liveFlow.tryEmit(event)
|
override fun tryAppendOnline(event: OnlineEvent): Boolean = liveFlow.tryEmit(event)
|
||||||
|
|||||||
+24
-131
@@ -1,161 +1,54 @@
|
|||||||
package pw.binom.agentik.outbox.inmemory
|
package pw.binom.agentik.outbox.inmemory
|
||||||
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
import pw.binom.agentik.outbox.Cursor
|
|
||||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
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
|
* Никакого хранения и никакого replay: [CommonEvent] публикуются подписчикам
|
||||||
* - [maxMessages] `null` → неограниченно по количеству;
|
* «в моменте». Единственный буфер — внутренний у [MutableSharedFlow] для
|
||||||
* - [ttl] `null` → нет time-based eviction;
|
* развязки продюсера/подписчиков; при переполнении **старые дропаются**
|
||||||
* - **оба `null` → вечное хранилище в RAM**;
|
* ([BufferOverflow.DROP_OLDEST]), [append] не блокируется.
|
||||||
* - любой non-null → граница применяется на каждом [append] (amortized O(1)).
|
|
||||||
*
|
*
|
||||||
* ## Курсор и gap
|
* История событий для перезапроса живёт в
|
||||||
* Offset'ы берутся из [sequencer] ([reserveOffset] = [OffsetSequencer.reserve]).
|
* [pw.binom.agentik.journal.JournalStore]; курсор/resume для удалённого
|
||||||
* [currentCursor] = offset последнего **записанного** события; [oldestCursor] =
|
* доступа — в sync-слое. Здесь их нет.
|
||||||
* `buffer.first().offset - 1` (или `lastOffset`, если буфер пуст). Подписка
|
|
||||||
* `after` старше `oldestCursor` (или с чужой эпохой) бросает [OutboxGapException].
|
|
||||||
*
|
*
|
||||||
* На старте `lastOffset = sequencer.current() - 1`: если счётчик персистентный
|
* **Маршрутизация**: один общий [MutableSharedFlow]; [conversationEvents] /
|
||||||
* и равен N, то клиент с курсором N-1 (догнавший состояние до рестарта)
|
* [agentEvents] фильтруют его (см. [MutableOutboxStore]).
|
||||||
* продолжает инкрементально, а клиент с курсором < N-1 получает gap и делает
|
|
||||||
* resync. Так рестарт сервера не теряет события молча.
|
|
||||||
*
|
|
||||||
* ## Concurrency
|
|
||||||
* Один [Mutex] защищает append/evict/подписки. Регистрация подписчика и снятие
|
|
||||||
* snapshot'а идут **одним критическим участком** — это закрывает окно
|
|
||||||
* «snapshot → live», в котором append мог потеряться: всё, что попадёт в буфер
|
|
||||||
* после регистрации, доедет до подписчика через его [Channel]. Snapshot
|
|
||||||
* итерируется и эмитится вне lock'а.
|
|
||||||
*
|
|
||||||
* ## Live-tail
|
|
||||||
* Каждому подписчику — свой [Channel] с `DROP_OLDEST`: медленный подписчик
|
|
||||||
* теряет только хвост live-потока и обязан сам сделать resync при обнаружении
|
|
||||||
* gap'а по retention'у.
|
|
||||||
*/
|
*/
|
||||||
class InMemoryOutboxStore(
|
class InMemoryOutboxStore(
|
||||||
private val maxMessages: Int?,
|
liveBufferCapacity: Int = DEFAULT_LIVE_BUFFER_CAPACITY,
|
||||||
private val ttl: Duration?,
|
|
||||||
private val clock: Clock = Clock.System,
|
|
||||||
private val sequencer: OffsetSequencer = InMemoryOffsetSequencer(),
|
|
||||||
) : MutableOutboxStore {
|
) : MutableOutboxStore {
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val liveFlow = MutableSharedFlow<CommonEvent>(
|
||||||
private val buffer = ArrayDeque<CommonEvent>()
|
replay = 0,
|
||||||
private val subscribers = mutableSetOf<SendChannel<CommonEvent>>()
|
extraBufferCapacity = liveBufferCapacity,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
/**
|
)
|
||||||
* Offset последнего **записанного** события. Инициализируется из счётчика:
|
|
||||||
* `current() - 1` (для fresh-счётчика это `-1`).
|
|
||||||
*/
|
|
||||||
private var lastOffset: Long = sequencer.current() - 1
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
require(maxMessages == null || maxMessages > 0) {
|
require(liveBufferCapacity > 0) {
|
||||||
"maxMessages must be > 0 or null, got $maxMessages"
|
"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) {
|
override suspend fun append(event: CommonEvent) {
|
||||||
mutex.withLock {
|
liveFlow.emit(event)
|
||||||
require(event.offset > lastOffset) {
|
|
||||||
"Non-monotonic offset: got ${event.offset}, last=${lastOffset}"
|
|
||||||
}
|
|
||||||
buffer.addLast(event)
|
|
||||||
lastOffset = event.offset
|
|
||||||
subscribers.forEach { it.trySend(event) }
|
|
||||||
evictLocked()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
override fun close() {
|
||||||
buffer.clear()
|
// replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки.
|
||||||
subscribers.clear()
|
// Идемпотентно: повторный close() безопасен.
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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
|
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.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFailsWith
|
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.AgentEvent
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
import pw.binom.agentik.outbox.Cursor
|
|
||||||
import pw.binom.agentik.outbox.DurableEvent
|
import pw.binom.agentik.outbox.DurableEvent
|
||||||
import pw.binom.agentik.outbox.OutboxGapException
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты [InMemoryOutboxStore] как live-шины durable-событий: доставка живым
|
||||||
|
* подписчикам, отсутствие replay, фильтры по варианту/диалогу.
|
||||||
|
*/
|
||||||
class InMemoryOutboxStoreTest {
|
class InMemoryOutboxStoreTest {
|
||||||
|
|
||||||
private class FixedClock(private var nowMs: Long = 1_000_000_000L) : Clock {
|
private fun agentEvent(conversationId: String, at: Instant = Instant.fromEpochMilliseconds(1)) =
|
||||||
fun advance(delta: Duration) { nowMs += delta.inWholeMilliseconds }
|
CommonEvent.Agent(date = at, event = AgentEvent.Created(date = at, conversationId = conversationId))
|
||||||
override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun agentEvent(offset: Long, conversationId: String, at: Instant = Instant.fromEpochSeconds(offset)) =
|
private fun conversationEvent(conversationId: String, at: Instant = Instant.fromEpochMilliseconds(1)) =
|
||||||
CommonEvent.Agent(
|
CommonEvent.Conversation(date = at, conversationId = conversationId, event = DurableEvent.Interrupted(date = at))
|
||||||
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 }
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `append stores all events when both limits are null store-forever`() = runBlocking {
|
fun `events delivers appended events live`() = runTest {
|
||||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
val store = InMemoryOutboxStore()
|
||||||
repeat(100) { i ->
|
val collected = mutableListOf<CommonEvent>()
|
||||||
store.append(agentEvent(i.toLong(), "c-$i"))
|
val job = backgroundScope.launch { store.events().collect { collected.add(it) } }
|
||||||
}
|
runCurrent()
|
||||||
assertEquals(100, store.snapshot().size)
|
|
||||||
|
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
|
@Test
|
||||||
fun `reserveOffset is monotonic`() = runBlocking {
|
fun `events does not replay history`() = runTest {
|
||||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
val store = InMemoryOutboxStore()
|
||||||
assertEquals(0L, store.reserveOffset())
|
store.append(agentEvent("old"))
|
||||||
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"))
|
|
||||||
|
|
||||||
val collected = mutableListOf<CommonEvent>()
|
val collected = mutableListOf<CommonEvent>()
|
||||||
val done = CompletableDeferred<Unit>()
|
val job = backgroundScope.launch { store.events().collect { collected.add(it) } }
|
||||||
val job = launch {
|
runCurrent()
|
||||||
store.events(after = null).collect { e ->
|
assertEquals(emptyList(), collected)
|
||||||
collected.add(e)
|
|
||||||
done.complete(Unit)
|
store.append(agentEvent("new"))
|
||||||
}
|
runCurrent()
|
||||||
}
|
assertEquals(1, collected.size)
|
||||||
delay(20)
|
|
||||||
store.append(agentEvent(2, "e3"))
|
|
||||||
done.await()
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
assertEquals(listOf("e3"), collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `events with cursor catches up then continues with live`() = runBlocking {
|
fun `conversationEvents filters to conversation variant`() = runTest {
|
||||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
val store = InMemoryOutboxStore()
|
||||||
store.append(agentEvent(0, "e1"))
|
val collected = mutableListOf<CommonEvent.Conversation>()
|
||||||
store.append(agentEvent(1, "e2"))
|
val job = backgroundScope.launch { store.conversationEvents().collect { collected.add(it) } }
|
||||||
store.append(agentEvent(2, "e3"))
|
runCurrent()
|
||||||
|
|
||||||
val from = Cursor(store.currentCursor().epoch, 0L)
|
store.append(agentEvent("agent"))
|
||||||
val collected = mutableListOf<CommonEvent>()
|
store.append(conversationEvent("c1"))
|
||||||
val done = CompletableDeferred<Unit>()
|
runCurrent()
|
||||||
val job = launch {
|
|
||||||
store.events(after = from).collect { e ->
|
assertEquals(listOf("c1"), collected.map { it.conversationId })
|
||||||
collected.add(e)
|
|
||||||
if (collected.size >= 3) done.complete(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delay(20)
|
|
||||||
store.append(agentEvent(3, "e4"))
|
|
||||||
done.await()
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
|
||||||
assertEquals(listOf("e2", "e3", "e4"), ids)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `subscribe from currentCursor receives only newer events - no handoff loss`() = runBlocking {
|
fun `conversationEvents with id filters by conversation`() = runTest {
|
||||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
val store = InMemoryOutboxStore()
|
||||||
store.append(agentEvent(0, "old"))
|
val collected = mutableListOf<CommonEvent.Conversation>()
|
||||||
|
val job = backgroundScope.launch { store.conversationEvents("c1").collect { collected.add(it) } }
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
val collected = mutableListOf<CommonEvent>()
|
store.append(conversationEvent("c1"))
|
||||||
val from = store.currentCursor()
|
store.append(conversationEvent("c2"))
|
||||||
val job = launch { store.events(after = from).collect { collected.add(it) } }
|
store.append(conversationEvent("c1"))
|
||||||
delay(50)
|
runCurrent()
|
||||||
store.append(agentEvent(1, "new"))
|
|
||||||
delay(50)
|
assertEquals(2, collected.size)
|
||||||
|
assertEquals(listOf("c1", "c1"), collected.map { it.conversationId })
|
||||||
job.cancel()
|
job.cancel()
|
||||||
|
|
||||||
assertEquals(listOf(1L), collected.map { it.offset })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `gap exception when cursor older than oldest`() = runBlocking {
|
fun `agentEvents filters to agent variant`() = runTest {
|
||||||
val store = InMemoryOutboxStore(maxMessages = 2, ttl = null)
|
val store = InMemoryOutboxStore()
|
||||||
store.append(agentEvent(0, "e0"))
|
val collected = mutableListOf<CommonEvent.Agent>()
|
||||||
store.append(agentEvent(1, "e1"))
|
val job = backgroundScope.launch { store.agentEvents().collect { collected.add(it) } }
|
||||||
store.append(agentEvent(2, "e2"))
|
runCurrent()
|
||||||
|
|
||||||
val tooOld = Cursor(store.currentCursor().epoch, -1L)
|
store.append(conversationEvent("c1"))
|
||||||
assertFailsWith<OutboxGapException> {
|
store.append(agentEvent("agent"))
|
||||||
store.events(after = tooOld).collect { }
|
runCurrent()
|
||||||
}
|
|
||||||
// Ровно на границе — ещё можно.
|
assertEquals(1, collected.size)
|
||||||
val atFloor = Cursor(store.currentCursor().epoch, 0L)
|
assertEquals("agent", (collected[0].event as AgentEvent.Created).conversationId)
|
||||||
val got = mutableListOf<Long>()
|
|
||||||
val job = launch { store.events(after = atFloor).collect { got.add(it.offset) } }
|
|
||||||
delay(30)
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
assertEquals(listOf(1L, 2L), got)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `gap exception on epoch mismatch`(): Unit = runBlocking {
|
fun `close is idempotent`() {
|
||||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
val store = InMemoryOutboxStore()
|
||||||
store.append(agentEvent(0, "e0"))
|
store.close()
|
||||||
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"))
|
|
||||||
store.close()
|
store.close()
|
||||||
assertEquals(emptyList(), store.snapshot())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `negative maxMessages throws at construction`() {
|
fun `non-positive buffer capacity throws`() {
|
||||||
kotlin.runCatching { InMemoryOutboxStore(maxMessages = -1, ttl = null) }
|
assertFailsWith<IllegalArgumentException> { InMemoryOutboxStore(liveBufferCapacity = 0) }
|
||||||
.onFailure { /* expected */ }
|
|
||||||
.onSuccess { kotlin.test.fail("should have thrown") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.outbox.Cursor
|
import pw.binom.agentik.cursor.Cursor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Снапшот списка диалогов + его [cursor].
|
* Снапшот списка диалогов + его [cursor].
|
||||||
*
|
*
|
||||||
* Курсор — **commit point** на момент чтения (см. `OutboxStore.currentCursor`):
|
* Курсор — **commit point** на момент чтения (см. `OutboxStore.currentCursor`):
|
||||||
* состояние со `seq <= cursor.offset` отражено в [conversations], всё, что
|
* состояние с `offset <= cursor.offset` отражено в [conversations], всё, что
|
||||||
* появится позже, придёт потоком outbox-событий. Клиент после применения
|
* появится позже, придёт потоком outbox-событий. Клиент после применения
|
||||||
* снапшота подписывается `agentEvents(after = cursor)` (или
|
* снапшота подписывается `agentEvents(after = cursor)` (или
|
||||||
* `conversationEvents(after = cursor)`) и накатывает дельты поверх.
|
* `conversationEvents(after = cursor)`) и накатывает дельты поверх.
|
||||||
@@ -27,9 +27,9 @@ data class ConversationsSnapshot(
|
|||||||
/**
|
/**
|
||||||
* Снапшот сообщений одного диалога + его [cursor].
|
* Снапшот сообщений одного диалога + его [cursor].
|
||||||
*
|
*
|
||||||
* Сообщения прочитаны с отсечкой `seq <= cursor.offset` — конечное и
|
* Сообщения прочитаны с отсечкой `offset <= cursor.offset` — конечное и
|
||||||
* стабильное множество, отражающее состояние диалога на момент [cursor].
|
* стабильное множество, отражающее состояние диалога на момент [cursor].
|
||||||
* Всё, что появится позже (`seq > cursor.offset`), придёт потоком
|
* Всё, что появится позже (`offset > cursor.offset`), придёт потоком
|
||||||
* `conversationEvents(after = cursor, conversationId)`.
|
* `conversationEvents(after = cursor, conversationId)`.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import io.ktor.server.routing.get
|
|||||||
import io.ktor.server.routing.route
|
import io.ktor.server.routing.route
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
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.OutboxStore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,7 +89,7 @@ internal suspend fun ApplicationCall.requireCursorAlive(outbox: OutboxStore, aft
|
|||||||
if (after == null) return true
|
if (after == null) return true
|
||||||
val oldest = outbox.oldestCursor()
|
val oldest = outbox.oldestCursor()
|
||||||
val current = outbox.currentCursor()
|
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(
|
respond(
|
||||||
HttpStatusCode.Gone,
|
HttpStatusCode.Gone,
|
||||||
OutboxGapResponse(requested = after, oldest = oldest, current = current),
|
OutboxGapResponse(requested = after, oldest = oldest, current = current),
|
||||||
@@ -101,17 +101,20 @@ internal suspend fun ApplicationCall.requireCursorAlive(outbox: OutboxStore, aft
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Парсит курсор из query-параметров `epoch` + `offset`.
|
* Парсит курсор из query-параметров `epoch` + `offset`.
|
||||||
* Оба отсутствуют → `null` (live-only). Задан только один или невалидный
|
* Оба отсутствуют → `null` (live-only). Задан только один или невалидные
|
||||||
* `offset` → `400` и `null`.
|
* значения → `400` и `null`.
|
||||||
|
*
|
||||||
|
* `epoch` — [Cursor.createdAt] (Unix ms), `offset` — [Cursor.offset] (ULong).
|
||||||
*/
|
*/
|
||||||
internal suspend fun ApplicationCall.parseCursor(): Cursor? {
|
internal suspend fun ApplicationCall.parseCursor(): Cursor? {
|
||||||
val epoch = request.queryParameters["epoch"]
|
val epochRaw = request.queryParameters["epoch"]
|
||||||
val offsetRaw = request.queryParameters["offset"]
|
val offsetRaw = request.queryParameters["offset"]
|
||||||
if (epoch == null && offsetRaw == null) return null
|
if (epochRaw == null && offsetRaw == null) return null
|
||||||
val offset = offsetRaw?.toLongOrNull()
|
val epoch = epochRaw?.toLongOrNull()
|
||||||
|
val offset = offsetRaw?.toULongOrNull()
|
||||||
if (epoch == null || offset == null) {
|
if (epoch == null || offset == null) {
|
||||||
respond(HttpStatusCode.BadRequest, "Invalid cursor (expected 'epoch' + 'offset' query params)")
|
respond(HttpStatusCode.BadRequest, "Invalid cursor (expected 'epoch' + 'offset' query params)")
|
||||||
return null
|
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.Agent
|
||||||
import pw.binom.agentik.proto.ChatSnapshot
|
import pw.binom.agentik.proto.ChatSnapshot
|
||||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
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.AgentInfo
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
@@ -77,8 +77,8 @@ class AgentInfoRouteTest {
|
|||||||
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
||||||
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
||||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
@@ -88,9 +88,9 @@ class AgentInfoRouteTest {
|
|||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
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 =
|
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 fun createConversation(temp: Boolean): Conversation = TODO("not used")
|
||||||
override suspend fun getConversation(id: String): Conversation? = null
|
override suspend fun getConversation(id: String): Conversation? = null
|
||||||
@@ -155,8 +155,8 @@ class AgentInfoRouteTest {
|
|||||||
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
||||||
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
||||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
@@ -166,9 +166,9 @@ class AgentInfoRouteTest {
|
|||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
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 =
|
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 fun createConversation(temp: Boolean): Conversation = TODO("not used")
|
||||||
override suspend fun getConversation(id: String): Conversation? = null
|
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.Agent
|
||||||
import pw.binom.agentik.proto.ChatSnapshot
|
import pw.binom.agentik.proto.ChatSnapshot
|
||||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
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.AgentInfo
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -61,8 +61,8 @@ class BearerTokenTest {
|
|||||||
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
|
||||||
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
||||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
@@ -72,9 +72,9 @@ class BearerTokenTest {
|
|||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
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 =
|
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 fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
|
||||||
override suspend fun getConversation(id: String): Conversation? = null
|
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.Agent
|
||||||
import pw.binom.agentik.proto.ChatSnapshot
|
import pw.binom.agentik.proto.ChatSnapshot
|
||||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
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.AgentInfo
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.BeforeTest
|
import kotlin.test.BeforeTest
|
||||||
@@ -169,8 +169,8 @@ class ConversationRoutesTest {
|
|||||||
override fun conversationEvents(after: Cursor?, conversationId: String?) =
|
override fun conversationEvents(after: Cursor?, conversationId: String?) =
|
||||||
emptyFlow<CommonEvent.Conversation>()
|
emptyFlow<CommonEvent.Conversation>()
|
||||||
|
|
||||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
@@ -178,11 +178,11 @@ class ConversationRoutesTest {
|
|||||||
|
|
||||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
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 =
|
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 =
|
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.Agent
|
||||||
import pw.binom.agentik.proto.ChatSnapshot
|
import pw.binom.agentik.proto.ChatSnapshot
|
||||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
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.AgentInfo
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.BeforeTest
|
import kotlin.test.BeforeTest
|
||||||
@@ -80,6 +80,7 @@ class JournalRoutesCountTest {
|
|||||||
conversationId = convId,
|
conversationId = convId,
|
||||||
content = listOf(JContent.Text(text)),
|
content = listOf(JContent.Text(text)),
|
||||||
createdAt = at,
|
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 events(after: Cursor?) = emptyFlow<CommonEvent>()
|
||||||
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
|
||||||
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||||
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
|
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
@@ -201,11 +202,11 @@ class JournalRoutesCountTest {
|
|||||||
|
|
||||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
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 =
|
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 =
|
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.JournalStore
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
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.OnlineOutbox
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
@@ -45,20 +45,20 @@ class SnapshotRouteTest {
|
|||||||
private lateinit var server: EmbeddedServer<*, *>
|
private lateinit var server: EmbeddedServer<*, *>
|
||||||
private var port: Int = 0
|
private var port: Int = 0
|
||||||
|
|
||||||
private val epoch = "epoch-1"
|
private val epoch = 123456789L
|
||||||
|
|
||||||
private class FakeOutbox(
|
private class FakeOutbox(
|
||||||
private val epoch: String,
|
private val epoch: Long,
|
||||||
private val oldest: Long,
|
private val oldest: ULong,
|
||||||
private val current: Long,
|
private val current: ULong,
|
||||||
private val replay: List<CommonEvent> = emptyList(),
|
private val replay: List<CommonEvent> = emptyList(),
|
||||||
) : OutboxStore {
|
) : OutboxStore {
|
||||||
override fun events(after: Cursor?): Flow<CommonEvent> = flowOf(*replay.toTypedArray())
|
override fun events(after: Cursor?): Flow<CommonEvent> = flowOf(*replay.toTypedArray())
|
||||||
override fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> = emptyFlow()
|
override fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> = emptyFlow()
|
||||||
override fun conversationEvents(after: Cursor?, conversationId: String?): Flow<CommonEvent.Conversation> =
|
override fun conversationEvents(after: Cursor?, conversationId: String?): Flow<CommonEvent.Conversation> =
|
||||||
emptyFlow()
|
emptyFlow()
|
||||||
override suspend fun currentCursor(): Cursor = Cursor(epoch, current)
|
override suspend fun currentCursor(): Cursor = Cursor(createdAt = epoch, offset = current)
|
||||||
override suspend fun oldestCursor(): Cursor = Cursor(epoch, oldest)
|
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = epoch, offset = oldest)
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ class SnapshotRouteTest {
|
|||||||
override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L
|
override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L
|
||||||
override fun close() {}
|
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 onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
override val conversationStore: ConversationStore = object : ConversationStore {
|
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||||
override suspend fun get(id: String) = null
|
override suspend fun get(id: String) = null
|
||||||
@@ -92,10 +92,10 @@ class SnapshotRouteTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
|
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 =
|
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 fun createConversation(temp: Boolean): Conversation = TODO("not used")
|
||||||
override suspend fun getConversation(id: String): Conversation? = null
|
override suspend fun getConversation(id: String): Conversation? = null
|
||||||
@@ -126,7 +126,7 @@ class SnapshotRouteTest {
|
|||||||
val body = response.bodyAsText()
|
val body = response.bodyAsText()
|
||||||
assertTrue("conv-1" in body, "body=$body")
|
assertTrue("conv-1" in body, "body=$body")
|
||||||
assertTrue("\"cursor\"" 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")
|
assertTrue("\"offset\":11" in body, "body=$body")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ class SnapshotRouteTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `foreign epoch returns 410`() = runBlocking {
|
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)
|
assertEquals(HttpStatusCode.Gone, response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-10
@@ -44,6 +44,12 @@ include(":skills")
|
|||||||
include(":server")
|
include(":server")
|
||||||
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
|
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
|
||||||
include(":client")
|
include(":client")
|
||||||
|
// Транспорт-агностичный слой клиентской синхронизации: оборачивает `Agent`
|
||||||
|
// локальным стором бесед + durable-курсором (MutableCursorHolder) и ведёт
|
||||||
|
// протокол «снапшот + дельты». Зависит только от :proto/:journal-api/:cursor-api,
|
||||||
|
// поэтому переиспользуется любым клиентом (desktop, CLI, embedded), не только
|
||||||
|
// HTTP-овым :client.
|
||||||
|
include(":client-sync")
|
||||||
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
|
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
|
||||||
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
|
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
|
||||||
// include(":agentik-cli") — отключено 2026-09-21: пользователь временно вывел
|
// include(":agentik-cli") — отключено 2026-09-21: пользователь временно вывел
|
||||||
@@ -114,10 +120,14 @@ include(":context-ksqlite")
|
|||||||
// ConversationStore / conversation table). Минимальный модуль:
|
// ConversationStore / conversation table). Минимальный модуль:
|
||||||
// таблицы `message` + `conversation` + индексы.
|
// таблицы `message` + `conversation` + индексы.
|
||||||
include(":journal-ksqlite")
|
include(":journal-ksqlite")
|
||||||
// ksqlite-реализация :outbox-api (CursorStore / outbox_cursor table).
|
// ksqlite-реализация :cursor-api (CursorHolder / outbox_cursor table).
|
||||||
// Персистентная позиция счётчика событий агента — переживает рестарт, чтобы
|
// Персистентная позиция счётчика событий агента — переживает рестарт, чтобы
|
||||||
// клиент продолжал инкрементально (см. PersistentOffsetSequencer).
|
// клиент продолжал инкрементально (см. CursorHolder).
|
||||||
include(":outbox-ksqlite")
|
include(":cursor-ksqlite")
|
||||||
|
|
||||||
|
// In-memory-реализация :cursor-api (CursorHolder) — для тестов, dev-режима
|
||||||
|
// и ephemeral runtime; эпоха не переживает пересоздание holder'а.
|
||||||
|
include(":cursor-inmemory")
|
||||||
// ksqlite-реализация :reflection-api (ReflectionStore / reflection table).
|
// ksqlite-реализация :reflection-api (ReflectionStore / reflection table).
|
||||||
// Минимальный модуль: только таблица `reflection` + 2 индекса.
|
// Минимальный модуль: только таблица `reflection` + 2 индекса.
|
||||||
include(":reflection-ksqlite")
|
include(":reflection-ksqlite")
|
||||||
@@ -150,10 +160,11 @@ include(":agent-api")
|
|||||||
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
|
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
|
||||||
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
|
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
|
||||||
include(":skill-mining")
|
include(":skill-mining")
|
||||||
// Референсная реализация спеки `SYNC-SYSTEM.md` — журнал событий с
|
include(":cursor-api")
|
||||||
// монотонным курсором + материализация + replace-resync, единый протокол
|
include(":sync")
|
||||||
// для клиента и сервера. KMP с jvm/linuxX64/mingwX64 целями (ksqlite-
|
include(":ksqlite-message-storage")
|
||||||
// ограничение). Тесты спеки в commonTest, интеграция с ksqlite — в jvmTest.
|
// Мосты между :proto-агентом и внешними мессенджерами. Каждый модуль —
|
||||||
// Существующий код (outbox/journal/client/server) не трогаем; это чистый
|
// `pw.binom.agentik.integrations.<messenger>` с `TelegramBridgeComponent`
|
||||||
// референс для последующей миграции.
|
// (реализация `pw.binom.agentik.agent.Component`). Подключаются в
|
||||||
include(":sync-core")
|
// `:standalone` при наличии соответствующего токена в env (см. AppConfig).
|
||||||
|
include(":integrations-telegram")
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
|||||||
.joinToString("\n") { it.text }
|
.joinToString("\n") { it.text }
|
||||||
val conv = resolveConversation(contextId)
|
val conv = resolveConversation(contextId)
|
||||||
|
|
||||||
// Курсор старта: снапшот не нужен, достаточно текущей позиции лога.
|
// Live-подписки без catchup (snapshots выдаются outbox'ом отдельно,
|
||||||
val since = agent.outbox.currentCursor()
|
// см. Agent.chatSnapshot / conversationsSnapshot). Терминатор
|
||||||
val reply = StringBuilder()
|
// хода — [Event.AssistantMessage] (успех) / [Event.Interrupted] /
|
||||||
|
// [Event.Error] (провал).
|
||||||
|
val reply = StringBuilder()
|
||||||
val turnDone = CompletableDeferred<Unit>()
|
val turnDone = CompletableDeferred<Unit>()
|
||||||
// Онлайн-поток — дельты ответа (live-only, без catchup).
|
// Онлайн-поток — дельты ответа (live-only, без catchup).
|
||||||
val onlineJob = async {
|
val onlineJob = async {
|
||||||
@@ -61,11 +63,9 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Durable-поток — терминатор хода (catchup + live): целый ответ
|
// Durable-поток — терминатор хода (live-only).
|
||||||
// приходит [Event.AssistantMessage], обрыв — [Event.Interrupted],
|
|
||||||
// провал — [Event.Error].
|
|
||||||
val turnJob = async {
|
val turnJob = async {
|
||||||
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
|
agent.outbox.conversationEvents(conv.id).collect { ce ->
|
||||||
when (val e = ce.event) {
|
when (val e = ce.event) {
|
||||||
is DurableEvent.AssistantMessage, is DurableEvent.Interrupted -> turnDone.complete(Unit)
|
is DurableEvent.AssistantMessage, is DurableEvent.Interrupted -> turnDone.complete(Unit)
|
||||||
is DurableEvent.Error ->
|
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.VectorMemorySystem
|
||||||
import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient
|
import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient
|
||||||
import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
|
import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
|
||||||
import pw.binom.agentik.server.agentikAgent
|
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
import pw.binom.agentik.standalone.agent.ChatAgent
|
import pw.binom.agentik.standalone.agent.ChatAgent
|
||||||
import pw.binom.agentik.llm.tools.LiteLlmContextCompactor
|
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.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.ModelDownloader
|
import pw.binom.agentik.standalone.llm.ModelDownloader
|
||||||
import pw.binom.agentik.mcp.bridge.McpRegistry
|
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 pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import pw.binom.agentik.skill.mining.SkillMiner
|
import pw.binom.agentik.skill.mining.SkillMiner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* standalone-контейнер agentik:
|
* standalone-контейнер agentik: локальный агент с A2A-фасадом.
|
||||||
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
|
*
|
||||||
* POST /agentik/conversations -> ConversationSnapshot (201)
|
* Транспорт user↔agent (`:server` proto-фасад) из standalone удалён. Остаётся
|
||||||
* GET /agentik/conversations -> [ConversationSnapshot]
|
* встраиваемый Ktor (CIO), на котором смонтирован только A2A-фасад:
|
||||||
* GET /agentik/conversations/{id} -> ConversationSnapshot
|
* - GET /health -> "ok"
|
||||||
* PATCH /agentik/conversations/{id} -> ConversationSnapshot
|
* - POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)
|
||||||
* DELETE /agentik/conversations/{id} -> 204
|
* - GET /a2a/.well-known/agent-card.json -> AgentCard
|
||||||
* POST /agentik/conversations/{id}/messages -> 202
|
* MCP-клиент (исходящий) и локальный агент — на месте; локальный транспорт
|
||||||
* POST /agentik/conversations/{id}/interrupt -> 202
|
* (IPC/stdio) добавим отдельно позже.
|
||||||
* 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"
|
|
||||||
*
|
*
|
||||||
* Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
|
* Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
|
||||||
* - AGENTIK_PORT / AGENTIK_DB_PATH
|
* - AGENTIK_PORT / AGENTIK_DB_PATH
|
||||||
* - AGENTIK_TOKEN — Bearer-токен для HTTP-фасада /agentik (пусто — авторизация выключена)
|
|
||||||
* - AGENTIK_A2A_TOKEN — Bearer-токен для A2A-фасада /a2a (пусто — авторизация выключена)
|
* - AGENTIK_A2A_TOKEN — Bearer-токен для A2A-фасада /a2a (пусто — авторизация выключена)
|
||||||
|
|
||||||
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
|
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
|
||||||
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
|
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
|
||||||
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
|
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
|
||||||
@@ -88,7 +86,7 @@ private fun printHelp() {
|
|||||||
println(
|
println(
|
||||||
"""
|
"""
|
||||||
agentik standalone — usage:
|
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
|
java -jar agentik.jar pull-model Download the LiteRT-LM model from static.binom.pw
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
@@ -394,9 +392,32 @@ private fun runServer() {
|
|||||||
reflector = reflector,
|
reflector = reflector,
|
||||||
// Персистентный счётчик событий: offset/epoch переживают рестарт,
|
// Персистентный счётчик событий: offset/epoch переживают рестарт,
|
||||||
// клиенты продолжают инкрементально, а не делают полный resync.
|
// клиенты продолжают инкрементально, а не делают полный resync.
|
||||||
outboxSequencer = sqliteStores.outboxSequencer,
|
outboxCursorHolder = sqliteStores.outboxCursorHolder,
|
||||||
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
|
).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'а,
|
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
|
||||||
// чтобы debug-эндпоинты могли его триггерить вручную.
|
// чтобы debug-эндпоинты могли его триггерить вручную.
|
||||||
val curator: pw.binom.agentik.standalone.agent.memory.Curator? =
|
val curator: pw.binom.agentik.standalone.agent.memory.Curator? =
|
||||||
@@ -407,38 +428,30 @@ private fun runServer() {
|
|||||||
c
|
c
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
|
// A2A-фасад (agent↔agent). Proto-фасад user↔agent удалён.
|
||||||
val server = embeddedServer(CIO, port = config.agent.port) {
|
val server = embeddedServer(CIO, port = config.agent.port) {
|
||||||
routing {
|
routing {
|
||||||
get("/health") { call.respondText("ok") }
|
get("/health") { call.respondText("ok") }
|
||||||
agentikAgent(agent, path = "/agentik", token = config.agent.authToken)
|
|
||||||
a2aAgent(
|
a2aAgent(
|
||||||
agentName = "agentik",
|
agentName = "agentik",
|
||||||
handler = A2aBridge(agent),
|
handler = A2aBridge(agent),
|
||||||
path = "/a2a",
|
path = "/a2a",
|
||||||
token = config.agent.a2aToken,
|
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("agentik standalone listening on http://localhost:${config.agent.port}")
|
||||||
println(" GET /health")
|
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(" POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)")
|
||||||
println(" GET /a2a/.well-known/agent-card.json -> AgentCard")
|
println(" GET /a2a/.well-known/agent-card.json -> AgentCard")
|
||||||
println(" storage: ${config.agent.dbPath}")
|
println(" storage: ${config.agent.dbPath}")
|
||||||
println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
|
println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
|
||||||
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
|
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" } ?: ""}")
|
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)")
|
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"}")
|
println(" memory: ${if (memorySystem == null) "disabled" else "${config.memory.backend.name.lowercase()}-backend"}")
|
||||||
@@ -453,9 +466,6 @@ private fun runServer() {
|
|||||||
if (skillMiner != null) {
|
if (skillMiner != null) {
|
||||||
println(" skill-mining: enabled (interval=${config.skillMining.interval} turns, maxTurns=${config.skillMining.maxTurns})")
|
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'ам при старте. Метод
|
// Раньше здесь был агрегат tokenStats() по всем conv'ам при старте. Метод
|
||||||
// убран из :message-log-api (MessageStore стал чисто read-only list+listFlow);
|
// убран из :message-log-api (MessageStore стал чисто read-only list+listFlow);
|
||||||
// см. agentik :message-log-api/MessageStore.kt. Token accounting теперь
|
// см. agentik :message-log-api/MessageStore.kt. Token accounting теперь
|
||||||
@@ -465,7 +475,11 @@ private fun runServer() {
|
|||||||
println(" conversations: ${existingConvs.size} (active)")
|
println(" conversations: ${existingConvs.size} (active)")
|
||||||
}
|
}
|
||||||
Runtime.getRuntime().addShutdownHook(Thread {
|
Runtime.getRuntime().addShutdownHook(Thread {
|
||||||
|
// Сначала отменяем integrations (polling/stream-джобы) — иначе
|
||||||
|
// длинный Telegram-long-poll (до 30с) будет держать JVM.
|
||||||
|
integrationsScope.cancel()
|
||||||
agent.close()
|
agent.close()
|
||||||
|
tgClient?.close()
|
||||||
mcpRegistry.close()
|
mcpRegistry.close()
|
||||||
})
|
})
|
||||||
Runtime.getRuntime().addShutdownHook(Thread {
|
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.CommonEvent
|
||||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||||
import pw.binom.agentik.outbox.OffsetSequencer
|
import pw.binom.agentik.cursor.CursorHolder
|
||||||
import pw.binom.agentik.outbox.inmemory.InMemoryOffsetSequencer
|
import pw.binom.agentik.cursor.inmemory.InMemoryCursorHolder
|
||||||
import pw.binom.agentik.outbox.OnlineOutbox
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
@@ -150,11 +150,11 @@ internal class ChatAgent(
|
|||||||
/**
|
/**
|
||||||
* Счётчик событий агента. По умолчанию in-memory: offset'ы начинаются с 0
|
* Счётчик событий агента. По умолчанию in-memory: offset'ы начинаются с 0
|
||||||
* при каждом старте процесса. Production передаёт
|
* при каждом старте процесса. Production передаёт
|
||||||
* [pw.binom.agentik.outbox.PersistentOffsetSequencer] поверх ksqlite
|
* [pw.binom.agentik.cursor.ksqlite.KsqliteCursorHolder] поверх ksqlite
|
||||||
* (`SqliteStores.outboxSequencer`), чтобы `offset`/`epoch` переживали
|
* (`SqliteStores.outboxCursorHolder`), чтобы `offset`/`epoch` переживали
|
||||||
* рестарт и клиент продолжал инкрементально (см. [Cursor]).
|
* рестарт и клиент продолжал инкрементально (см. [Cursor]).
|
||||||
*/
|
*/
|
||||||
private val outboxSequencer: OffsetSequencer = InMemoryOffsetSequencer(),
|
private val outboxCursorHolder: CursorHolder = InMemoryCursorHolder(),
|
||||||
) : MutableAgent, AutoCloseable {
|
) : MutableAgent, AutoCloseable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,18 +218,14 @@ private val testTools: MutableList<LiteTool> = mutableListOf()
|
|||||||
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
|
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
|
||||||
* или подписке на два отдельных канала.
|
* или подписке на два отдельных канала.
|
||||||
*/
|
*/
|
||||||
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
|
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore()
|
||||||
maxMessages = null,
|
|
||||||
ttl = null,
|
|
||||||
sequencer = outboxSequencer,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сериализатор durable-записей: reserve offset → state → event (см. [DurableLog]).
|
* Сериализатор durable-записей: reserve offset → state → event (см. [DurableLog]).
|
||||||
* Делит [eventStore] со всеми беседами агента, поэтому offset'ы сквозные
|
* Делит [eventStore] со всеми беседами агента, поэтому offset'ы сквозные
|
||||||
* по агенту (и по всем беседам сразу).
|
* по агенту (и по всем беседам сразу).
|
||||||
*/
|
*/
|
||||||
private val durableLog: DurableLog = DurableLog(eventStore)
|
private val durableLog: DurableLog = DurableLog(eventStore, outboxCursorHolder)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры).
|
* Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры).
|
||||||
@@ -319,7 +315,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
|
|||||||
* Оба маппатся в один [Flow], `SkillMiningComponent` сам решает что делать.
|
* Оба маппатся в один [Flow], `SkillMiningComponent` сам решает что делать.
|
||||||
*/
|
*/
|
||||||
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
|
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
|
||||||
eventStore.events(after = null)
|
eventStore.events()
|
||||||
.filterIsInstance<CommonEvent.Conversation>()
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
.mapNotNull { ce ->
|
.mapNotNull { ce ->
|
||||||
when (val e = ce.event) {
|
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 {
|
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot {
|
||||||
val cursor = durableLog.currentCursor()
|
val cursor = durableLog.currentCursor()
|
||||||
val out = ArrayList<MessageRecord>()
|
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) {
|
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
|
if (page.isEmpty()) break
|
||||||
out += page
|
out += page
|
||||||
afterSeq = page.last().seq
|
|
||||||
if (page.size < JournalStore.PAGE_SIZE) break
|
if (page.size < JournalStore.PAGE_SIZE) break
|
||||||
|
offset += page.size
|
||||||
}
|
}
|
||||||
return ChatSnapshot(conversationId = conversationId, messages = out, cursor = cursor)
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.runBlocking
|
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.DurableEvent
|
||||||
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||||
import pw.binom.agentik.outbox.OnlineEvent
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
@@ -14,7 +14,18 @@ import pw.binom.agentik.outbox.OnlineEvent
|
|||||||
* - online ([OnlineEvent]) → [onlineStore], live-only (без catchup).
|
* - online ([OnlineEvent]) → [onlineStore], live-only (без catchup).
|
||||||
*
|
*
|
||||||
* Все durable-эмиссии идут через [DurableLog], чтобы state-row и парное
|
* Все 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(
|
internal class ConversationEvents(
|
||||||
private val durableLog: DurableLog,
|
private val durableLog: DurableLog,
|
||||||
@@ -23,9 +34,9 @@ internal class ConversationEvents(
|
|||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Атомарная durable-запись: сначала [writeState] (journal/...) с
|
* Атомарная 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)
|
durableLog.commit(conversationId = conversationId, writeState = writeState, event = event)
|
||||||
|
|
||||||
/** Durable-событие без парной записи состояния. */
|
/** Durable-событие без парной записи состояния. */
|
||||||
@@ -43,8 +54,14 @@ internal class ConversationEvents(
|
|||||||
return true
|
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 }
|
.map { it.event }
|
||||||
|
|
||||||
/** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */
|
/** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */
|
||||||
|
|||||||
+4
-5
@@ -195,8 +195,8 @@ class ConversationLoop(
|
|||||||
// State-first: journal.append + workingMemory под одним offset'ом,
|
// State-first: journal.append + workingMemory под одним offset'ом,
|
||||||
// затем парное durable-событие (см. DurableLog).
|
// затем парное durable-событие (см. DurableLog).
|
||||||
events.commit(
|
events.commit(
|
||||||
writeState = { seq ->
|
writeState = { _ ->
|
||||||
messageStore.append(userRecord.copy(seq = seq))
|
messageStore.append(userRecord)
|
||||||
workingMemoryStore.append(
|
workingMemoryStore.append(
|
||||||
conversationId = id,
|
conversationId = id,
|
||||||
entry = WorkingMemoryEntry.User(
|
entry = WorkingMemoryEntry.User(
|
||||||
@@ -465,7 +465,7 @@ class ConversationLoop(
|
|||||||
// State-first: journal.append под забронированным offset'ом,
|
// State-first: journal.append под забронированным offset'ом,
|
||||||
// затем парное durable-событие.
|
// затем парное durable-событие.
|
||||||
events.commit(
|
events.commit(
|
||||||
writeState = { seq -> messageStore.append(assistantRecord.copy(seq = seq)) },
|
writeState = { _ -> messageStore.append(assistantRecord) },
|
||||||
event = {
|
event = {
|
||||||
ProtoEvent.AssistantMessage(
|
ProtoEvent.AssistantMessage(
|
||||||
date = assistantAt,
|
date = assistantAt,
|
||||||
@@ -533,7 +533,7 @@ class ConversationLoop(
|
|||||||
val ts = now()
|
val ts = now()
|
||||||
if (!state.isTemporal) {
|
if (!state.isTemporal) {
|
||||||
events.commit(
|
events.commit(
|
||||||
writeState = { seq ->
|
writeState = { _ ->
|
||||||
messageStore.append(
|
messageStore.append(
|
||||||
MessageRecord.Error(
|
MessageRecord.Error(
|
||||||
id = newId("err"),
|
id = newId("err"),
|
||||||
@@ -541,7 +541,6 @@ class ConversationLoop(
|
|||||||
message = message,
|
message = message,
|
||||||
code = code,
|
code = code,
|
||||||
createdAt = ts,
|
createdAt = ts,
|
||||||
seq = seq,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,40 +2,50 @@ package pw.binom.agentik.standalone.agent
|
|||||||
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
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.AgentEvent
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
import pw.binom.agentik.outbox.Cursor
|
|
||||||
import pw.binom.agentik.outbox.DurableEvent
|
import pw.binom.agentik.outbox.DurableEvent
|
||||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сериализатор durable-записей агента: единственное место, где state-row и
|
* Сериализатор durable-записей агента: единственное место, где state-row и
|
||||||
* парное ему outbox-событие получают **один и тот же** монотонный offset.
|
* парное ему outbox-событие появляются **синхронно**.
|
||||||
*
|
*
|
||||||
* ## Зачем нужен commit-lock
|
* ## Зачем нужен commit-lock
|
||||||
*
|
*
|
||||||
* Протокол снапшотов держится на трёх инвариантах (см. README `:outbox-api`):
|
* Протокол снапшотов держится на трёх инвариантах (см. README `:outbox-api`):
|
||||||
* 1. **Producer**: сначала мутация состояния (journal/conversation), потом
|
* 1. **Producer**: сначала мутация состояния (journal/conversation), потом
|
||||||
* `append` события — оба с одинаковым `seq`/`offset`;
|
* `append` события — оба с одинаковым [Cursor], выданным из [CursorHolder];
|
||||||
* 2. **Reader**: сначала `currentCursor()` (= последний **заапенденный**
|
* 2. **Reader**: сначала [currentCursor] (= следующий offset'ы **к выдаче**),
|
||||||
* offset), потом чтение состояния с отсечкой `seq <= C`; всё, что `> C`,
|
* потом чтение состояния с отсечкой по `created_at <= cursor.offset`;
|
||||||
* приедет потоком;
|
* всё, что появилось позже, приедет потоком;
|
||||||
* 3. **Идемпотентность** применения (upsert/delete по id).
|
* 3. **Идемпотентность** применения (upsert/delete по id).
|
||||||
*
|
*
|
||||||
* Инвариант (1) ломается, если два продюсера параллельно забронируют
|
* Инвариант (1) ломается, если два продюсера параллельно забронируют
|
||||||
* offset'ы и запишут их в разном порядке (A резервирует 10, B — 11, B
|
* offset'ы и запишут их в разном порядке. [commit] / [appendConversation] /
|
||||||
* аппендит первым → outbox отвергнет 10 как немонотонный, либо состояние
|
* [appendAgent] держат [Mutex] на всё время «reserve → запись состояния →
|
||||||
* 10 «застрянет» ниже `currentCursor` и потеряется для подписчика).
|
* append», поэтому durable-поток строго линеен, а [currentCursor] всегда
|
||||||
* [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`), потому что знает про
|
* Живёт в `:standalone` (а не в `:outbox-api`), потому что знает про
|
||||||
* journal/conversation-сторы; outbox остаётся тупым хранилищем событий.
|
* journal/conversation-сторы; outbox остаётся тупым потоком событий.
|
||||||
*/
|
*/
|
||||||
class DurableLog(
|
class DurableLog(
|
||||||
val outbox: MutableOutboxStore,
|
val outbox: MutableOutboxStore,
|
||||||
|
private val cursorHolder: CursorHolder,
|
||||||
) {
|
) {
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -43,35 +53,35 @@ class DurableLog(
|
|||||||
val events: OutboxStore get() = outbox
|
val events: OutboxStore get() = outbox
|
||||||
|
|
||||||
/** Точка отсечки для нового снапшота (см. протокол). */
|
/** Точка отсечки для нового снапшота (см. протокол). */
|
||||||
suspend fun currentCursor(): Cursor = outbox.currentCursor()
|
suspend fun currentCursor(): Cursor = mutex.withLock { cursorHolder.current() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Атомарно: забронировать offset → записать состояние с этим `seq` →
|
* Атомарно: забронировать курсор → записать состояние с этим [Cursor] →
|
||||||
* аппендить парное событие. Возвращает результат [writeState].
|
* аппендить парное событие. Возвращает результат [writeState].
|
||||||
*/
|
*/
|
||||||
suspend fun <T> commit(
|
suspend fun <T> commit(
|
||||||
conversationId: String,
|
conversationId: String,
|
||||||
writeState: suspend (seq: Long) -> T,
|
writeState: suspend (cursor: Cursor) -> T,
|
||||||
event: (seq: Long) -> DurableEvent,
|
event: (cursor: Cursor) -> DurableEvent,
|
||||||
): T = mutex.withLock {
|
): T = mutex.withLock {
|
||||||
val seq = outbox.reserveOffset()
|
val cursor = cursorHolder.next()
|
||||||
val result = writeState(seq)
|
val result = writeState(cursor)
|
||||||
val e = event(seq)
|
val e = event(cursor)
|
||||||
outbox.append(CommonEvent.Conversation(date = e.date, offset = seq, conversationId = conversationId, event = e))
|
outbox.append(CommonEvent.Conversation(date = e.date, conversationId = conversationId, event = e))
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Durable-событие диалога без парной записи состояния (Closing, CompactionTriggered, ...). */
|
/** Durable-событие диалога без парной записи состояния (Closing, CompactionTriggered, ...). */
|
||||||
suspend fun appendConversation(conversationId: String, event: DurableEvent): Long = mutex.withLock {
|
suspend fun appendConversation(conversationId: String, event: DurableEvent): Cursor = mutex.withLock {
|
||||||
val seq = outbox.reserveOffset()
|
val cursor = cursorHolder.next()
|
||||||
outbox.append(CommonEvent.Conversation(date = event.date, offset = seq, conversationId = conversationId, event = event))
|
outbox.append(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
|
||||||
seq
|
cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Агент-level событие (Created/Deleted/Renamed/Touched) — состояние мутируется вызывающим до вызова. */
|
/** Агент-level событие (Created/Deleted/Renamed/Touched) — состояние мутируется вызывающим до вызова. */
|
||||||
suspend fun appendAgent(event: AgentEvent): Long = mutex.withLock {
|
suspend fun appendAgent(event: AgentEvent): Cursor = mutex.withLock {
|
||||||
val seq = outbox.reserveOffset()
|
val cursor = cursorHolder.next()
|
||||||
outbox.append(CommonEvent.Agent(date = event.date, offset = seq, event = event))
|
outbox.append(CommonEvent.Agent(date = event.date, event = event))
|
||||||
seq
|
cursor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -66,11 +66,11 @@ internal class ReflectionScheduler(
|
|||||||
if (subscriptionJob?.isActive == true) return
|
if (subscriptionJob?.isActive == true) return
|
||||||
subscriptionJob = scope.launch {
|
subscriptionJob = scope.launch {
|
||||||
merge(
|
merge(
|
||||||
eventStore.events(after = null)
|
eventStore.events()
|
||||||
.filterIsInstance<CommonEvent.Conversation>()
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
.filter { it.event is DurableEvent.ConversationClosing && it.conversationId == conversationIdProvider() }
|
.filter { it.event is DurableEvent.ConversationClosing && it.conversationId == conversationIdProvider() }
|
||||||
.onEach { onClosing() },
|
.onEach { onClosing() },
|
||||||
eventStore.events(after = null)
|
eventStore.events()
|
||||||
.filterIsInstance<CommonEvent.Conversation>()
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
.filter { it.event is DurableEvent.ToolFailed && it.conversationId == conversationIdProvider() }
|
.filter { it.event is DurableEvent.ToolFailed && it.conversationId == conversationIdProvider() }
|
||||||
.onEach { onToolFailure() },
|
.onEach { onToolFailure() },
|
||||||
|
|||||||
+2
-4
@@ -40,7 +40,7 @@ internal class ToolDispatcher(
|
|||||||
if (!state.isTemporal) {
|
if (!state.isTemporal) {
|
||||||
// State-first: journal-запись и парное событие под одним offset'ом.
|
// State-first: journal-запись и парное событие под одним offset'ом.
|
||||||
events.commit(
|
events.commit(
|
||||||
writeState = { seq ->
|
writeState = { _ ->
|
||||||
messageStore.append(
|
messageStore.append(
|
||||||
MessageRecord.ToolCall(
|
MessageRecord.ToolCall(
|
||||||
id = callId,
|
id = callId,
|
||||||
@@ -49,7 +49,6 @@ internal class ToolDispatcher(
|
|||||||
toolTitle = null,
|
toolTitle = null,
|
||||||
toolArgsJson = argsJson,
|
toolArgsJson = argsJson,
|
||||||
createdAt = nowTs,
|
createdAt = nowTs,
|
||||||
seq = seq,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -99,7 +98,7 @@ internal class ToolDispatcher(
|
|||||||
if (!state.isTemporal) {
|
if (!state.isTemporal) {
|
||||||
// State-first: journal-запись и парное событие под одним offset'ом.
|
// State-first: journal-запись и парное событие под одним offset'ом.
|
||||||
events.commit(
|
events.commit(
|
||||||
writeState = { seq ->
|
writeState = { _ ->
|
||||||
messageStore.append(
|
messageStore.append(
|
||||||
MessageRecord.ToolResult(
|
MessageRecord.ToolResult(
|
||||||
id = resultId,
|
id = resultId,
|
||||||
@@ -108,7 +107,6 @@ internal class ToolDispatcher(
|
|||||||
toolName = call.name,
|
toolName = call.name,
|
||||||
result = resultText,
|
result = resultText,
|
||||||
createdAt = resultAt,
|
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.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
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.mcp.bridge.McpConfig
|
||||||
import pw.binom.agentik.proto.AgentInfo
|
import pw.binom.agentik.proto.AgentInfo
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ data class AppConfig(
|
|||||||
val embedding: EmbeddingSection,
|
val embedding: EmbeddingSection,
|
||||||
val reflection: ReflectionSection,
|
val reflection: ReflectionSection,
|
||||||
val skillMining: SkillMiningSection,
|
val skillMining: SkillMiningSection,
|
||||||
|
val integrations: IntegrationsSection,
|
||||||
val debug: DebugSection,
|
val debug: DebugSection,
|
||||||
) {
|
) {
|
||||||
/** HTTP-сервер, БД, skills, soul. */
|
/** HTTP-сервер, БД, skills, soul. */
|
||||||
@@ -141,6 +143,18 @@ data class AppConfig(
|
|||||||
val maxTurns: Int = DEFAULT_SKILL_MINING_MAX_TURNS,
|
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, и т.д.). */
|
/** Debug-эндпоинты (/debug/reflect, /debug/skill-mine, и т.д.). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DebugSection(
|
data class DebugSection(
|
||||||
@@ -270,6 +284,16 @@ data class AppConfig(
|
|||||||
maxTurns = env("AGENTIK_SKILL_MINING_MAX_TURNS")?.toIntOrNull()
|
maxTurns = env("AGENTIK_SKILL_MINING_MAX_TURNS")?.toIntOrNull()
|
||||||
?.coerceIn(1, 1000) ?: DEFAULT_SKILL_MINING_MAX_TURNS,
|
?.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(
|
debug = DebugSection(
|
||||||
endpoints = env("AGENTIK_DEBUG_ENDPOINTS")?.let {
|
endpoints = env("AGENTIK_DEBUG_ENDPOINTS")?.let {
|
||||||
it.equals("1", ignoreCase = true) || it.equals("true", ignoreCase = true)
|
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.ContextStore
|
||||||
import pw.binom.agentik.context.ksqlite.KsqliteContextStore
|
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.MutableJournalStore
|
||||||
import pw.binom.agentik.journal.MutableConversationStore
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
|
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
|
||||||
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore
|
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore
|
||||||
import pw.binom.agentik.journal.ksqlite.Schema as JournalSchema
|
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.ReflectionStore
|
||||||
import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore
|
import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore
|
||||||
import pw.binom.db.ksqlite.SQLiteConnection
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
@@ -38,12 +36,11 @@ internal class SqliteStores internal constructor(
|
|||||||
val messages: MutableJournalStore,
|
val messages: MutableJournalStore,
|
||||||
val workingMemory: ContextStore,
|
val workingMemory: ContextStore,
|
||||||
val reflections: ReflectionStore,
|
val reflections: ReflectionStore,
|
||||||
private val outboxCursorStore: KsqliteCursorStore,
|
|
||||||
/**
|
/**
|
||||||
* Персистентный счётчик событий агента (см. [PersistentOffsetSequencer]).
|
* Персистентный счётчик событий агента (см. [KsqliteCursorHolder]).
|
||||||
* Передаётся в `ChatAgent`, чтобы offset'ы переживали рестарт процесса.
|
* Передаётся в `ChatAgent`, чтобы offset'ы переживали рестарт процесса.
|
||||||
*/
|
*/
|
||||||
val outboxSequencer: OffsetSequencer,
|
val outboxCursorHolder: KsqliteCursorHolder,
|
||||||
) : AutoCloseable {
|
) : AutoCloseable {
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
@@ -51,7 +48,7 @@ internal class SqliteStores internal constructor(
|
|||||||
messages.close()
|
messages.close()
|
||||||
workingMemory.close()
|
workingMemory.close()
|
||||||
reflections.close()
|
reflections.close()
|
||||||
outboxCursorStore.close()
|
outboxCursorHolder.close()
|
||||||
connection.close()
|
connection.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,36 +57,37 @@ internal class SqliteStores internal constructor(
|
|||||||
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
|
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
|
||||||
|
|
||||||
private fun assemble(conn: SQLiteConnection): SqliteStores {
|
private fun assemble(conn: SQLiteConnection): SqliteStores {
|
||||||
val cursorStore = KsqliteCursorStore(conn)
|
|
||||||
return SqliteStores(
|
return SqliteStores(
|
||||||
connection = conn,
|
connection = conn,
|
||||||
conversations = KsqliteMutableConversationStore(conn),
|
conversations = KsqliteMutableConversationStore(conn),
|
||||||
messages = KsqliteJournalStore(conn),
|
messages = KsqliteJournalStore(conn),
|
||||||
workingMemory = KsqliteContextStore(conn),
|
workingMemory = KsqliteContextStore(conn),
|
||||||
reflections = KsqliteReflectionStore(conn),
|
reflections = KsqliteReflectionStore(conn),
|
||||||
outboxCursorStore = cursorStore,
|
outboxCursorHolder = KsqliteCursorHolder(
|
||||||
outboxSequencer = PersistentOffsetSequencer(
|
connection = conn,
|
||||||
store = cursorStore,
|
|
||||||
initialNext = { seedNextFromJournal(conn) },
|
initialNext = { seedNextFromJournal(conn) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стартовая позиция счётчика при первом создании [KsqliteCursorStore],
|
* Стартовая позиция счётчика при первом создании [KsqliteCursorHolder],
|
||||||
* когда в БД уже есть сообщения (апгрейд): `MAX(seq) + 1`.
|
* когда в БД уже есть сообщения (апгрейд): `MAX(created_at) + 1`.
|
||||||
*
|
*
|
||||||
* Без этого новые offset'ы начинались бы с 0 и столкнулись бы с уже
|
* Без этого новые offset'ы начинались бы с `1` и могли бы оказаться
|
||||||
* записанными `seq` журнала (см. [PersistentOffsetSequencer]).
|
* меньше уже записанных сообщений (в журнале 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(
|
conn.prepare(
|
||||||
"SELECT COALESCE(MAX(${JournalSchema.COL_SEQ}), -1) " +
|
"SELECT COALESCE(MAX(${JournalSchema.COL_CREATED_AT}), 0) + 1 " +
|
||||||
"FROM ${JournalSchema.TABLE_MESSAGE}"
|
"FROM ${JournalSchema.TABLE_MESSAGE}"
|
||||||
).use { stmt ->
|
).use { stmt ->
|
||||||
stmt.executeQuery().use { rs ->
|
stmt.executeQuery().use { rs ->
|
||||||
check(rs.next()) { "MAX(seq) must return a row" }
|
check(rs.next()) { "MAX(created_at) must return a row" }
|
||||||
return (rs.getLong(0) ?: -1L) + 1L
|
return (rs.getLong(0) ?: 1L).toULong()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-35
@@ -338,7 +338,7 @@ class ChatAgentTest {
|
|||||||
|
|
||||||
val events = mutableListOf<ProtoEvent>()
|
val events = mutableListOf<ProtoEvent>()
|
||||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
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 online = mutableListOf<OnlineEvent>()
|
||||||
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
@@ -380,7 +380,7 @@ class ChatAgentTest {
|
|||||||
val durable = mutableListOf<ProtoEvent>()
|
val durable = mutableListOf<ProtoEvent>()
|
||||||
val online = mutableListOf<OnlineEvent>()
|
val online = mutableListOf<OnlineEvent>()
|
||||||
val durableJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
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) {
|
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
|
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
|
||||||
@@ -420,7 +420,7 @@ class ChatAgentTest {
|
|||||||
// отправки событий подписка ничего не увидит.
|
// отправки событий подписка ничего не увидит.
|
||||||
val events = mutableListOf<ProtoEvent>()
|
val events = mutableListOf<ProtoEvent>()
|
||||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
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 online = mutableListOf<OnlineEvent>()
|
||||||
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
@@ -484,7 +484,7 @@ class ChatAgentTest {
|
|||||||
// Подписываемся ДО send — SharedFlow без replay
|
// Подписываемся ДО send — SharedFlow без replay
|
||||||
val events = mutableListOf<ProtoEvent>()
|
val events = mutableListOf<ProtoEvent>()
|
||||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
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 {
|
val sendJob = launch {
|
||||||
@@ -648,7 +648,7 @@ class ChatAgentTest {
|
|||||||
// Agent.events() удалён из :proto — события живут в
|
// Agent.events() удалён из :proto — события живут в
|
||||||
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
|
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
|
||||||
// распаковываем .event для получения AgentEvent.
|
// распаковываем .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)
|
val conv = agent.createConversation(temp = false)
|
||||||
agent.deleteConversation(conv.id)
|
agent.deleteConversation(conv.id)
|
||||||
@@ -663,60 +663,76 @@ class ChatAgentTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `chatSnapshot returns state plus cursor and resuming from it loses nothing`() = runTest {
|
fun `chatSnapshot returns full state and live events after subscription extend it`() = runTest {
|
||||||
// Инвариант протокола: snapshot.messages ∪ дельты(offset > cursor) ==
|
// Новая семантика (v4, live-only outbox): snapshot — это «полное
|
||||||
// финальное состояние. Ни одна строка не теряется и не дублируется
|
// состояние на момент cursor», дальнейшие изменения приходят
|
||||||
// (дедуп делается по id у клиента, но здесь проверяем само покрытие).
|
// **только** live-flow'ом. Клиент сам мажет (snapshot ∪ live) и
|
||||||
|
// делает дедуп по id. Здесь проверяем, что:
|
||||||
|
// 1) chatSnapshot возвращает всё, что было на момент чтения;
|
||||||
|
// 2) live-events, подписанные ДО snapshot, идут ВСЕ (нет пропусков);
|
||||||
|
// 3) финальный snapshot содержит snapshot1 ∪ userId+assistantId.
|
||||||
val agent = newAgent()
|
val agent = newAgent()
|
||||||
fakeLlm.reply = "first reply"
|
fakeLlm.reply = "first reply"
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
conv.send(listOf(Content.Text("first user")))
|
conv.send(listOf(Content.Text("first user")))
|
||||||
|
|
||||||
val snap = agent.chatSnapshot(conv.id)
|
// Подписываемся на durable-события диалога ДО повторного send'а.
|
||||||
assertEquals(agent.outbox.currentCursor(), snap.cursor, "snapshot cursor must equal current cursor")
|
val live = mutableListOf<ProtoEvent>()
|
||||||
assertTrue(snap.messages.all { it.seq <= snap.cursor.offset }, "message beyond cursor: ${snap.messages}")
|
val liveJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
assertTrue(snap.messages.any { it is MessageRecord.UserMessage })
|
agent.outbox.conversationEvents(conv.id).collect { live.add(it.event) }
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
|
// Берём 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"
|
fakeLlm.reply = "second reply"
|
||||||
conv.send(listOf(Content.Text("second user")))
|
conv.send(listOf(Content.Text("second user")))
|
||||||
delay(50)
|
delay(50)
|
||||||
job.cancel()
|
liveJob.cancel()
|
||||||
|
|
||||||
assertTrue(deltas.isNotEmpty(), "no deltas after snapshot cursor")
|
// Live-flow получил ВСЕ durable-события обоих turn'ов.
|
||||||
assertTrue(deltas.all { it.offset > snap.cursor.offset }, "delta <= cursor: $deltas")
|
// (snap1 не отрезает «до подписки», поэтому live начинает ловить
|
||||||
|
// и user1+assistant1, если они приходят позже подписки — но они
|
||||||
val final = agent.chatSnapshot(conv.id)
|
// уже в outbox'е до подписки и SharedFlow без replay их не выдаёт.
|
||||||
assertTrue(final.messages.size > snap.messages.size, "resume must advance state")
|
// Поэтому в live будут только события send'а#2, и это корректно.)
|
||||||
val snapshotIds = snap.messages.map { it.id }.toSet()
|
val liveIds = live.mapNotNull { e ->
|
||||||
val deltaIds = deltas.mapNotNull { ce ->
|
when (e) {
|
||||||
when (val e = ce.event) {
|
|
||||||
is ProtoEvent.UserMessage -> e.id
|
is ProtoEvent.UserMessage -> e.id
|
||||||
is ProtoEvent.AssistantMessage -> e.id
|
is ProtoEvent.AssistantMessage -> e.id
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}.toSet()
|
}.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
|
@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 agent = newAgent()
|
||||||
val conv1 = agent.createConversation(temp = false)
|
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 deltas = mutableListOf<AgentEvent>()
|
||||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
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)
|
val conv2 = agent.createConversation(temp = false)
|
||||||
delay(50)
|
delay(50)
|
||||||
job.cancel()
|
job.cancel()
|
||||||
@@ -725,6 +741,11 @@ class ChatAgentTest {
|
|||||||
deltas.any { it is AgentEvent.Created && it.conversationId == conv2.id },
|
deltas.any { it is AgentEvent.Created && it.conversationId == conv2.id },
|
||||||
"deltas=$deltas",
|
"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.ConversationRecord
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.content.Content
|
import pw.binom.agentik.content.Content
|
||||||
|
import pw.binom.agentik.cursor.Cursor
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.flow.toList
|
||||||
@@ -129,10 +130,10 @@ class PersistenceTest {
|
|||||||
fun `message audit log - append and read back`() = runTest {
|
fun `message audit log - append and read back`() = runTest {
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
stores.messages.append(
|
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(
|
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()
|
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
||||||
@@ -150,8 +151,8 @@ class PersistenceTest {
|
|||||||
fun `message after timestamp filter`() = runTest {
|
fun `message after timestamp filter`() = runTest {
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_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("m1", "c1", listOf(Content.Text("a")), t0, null))
|
||||||
stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1))
|
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)
|
val after = stores.messages.list("c1", after = t0, offset = 0, limit = 10)
|
||||||
assertEquals(1, after.size)
|
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