refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api

Семантика outbox'а — теперь чистый live-канал:
  * OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
    Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
    на live. Outbox ответственен только за уведомления «что-то произошло».
  * OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
    Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
    рестарт и инкремент для каждого commit.
  * DurableEvent: commit принимает блок { cursor -> MessageRecord }
    (cursor выдаёт CursorStore; клиент не вычисляет offset сам).
  * MessageRecord больше не несёт cursor — это не его ответственность.

Новые модули:
  * :cursor-api         — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
  * :cursor-ksqlite     — KsqliteCursorHolder (персистентный)
  * :cursor-inmemory    — для тестов
  * :client-sync        — LocalSyncAgent (мини-агент поверх :client для десктопа)

Удалены:
  * :sync-core          — старая референсная реализация, заменена
                           cursor-разделением и :client.
  * outbox-ksqlite      — CursorStore/Schema уехали в :cursor-ksqlite.
  * OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.

standalone:
  * ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
    ConversationEvents — подписка через push-паттерн (collect событий).
  * A2aBridge — currentCursor() и conversationEvents(after=) убраны.
  * SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
    читает MAX(created_at).
  * Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
    transport удалён; debug-routes удалены; A2A остался.
  * Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
    (subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅

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