From 9e888227a34d2295d07de53666ac236e0f0ec29c Mon Sep 17 00:00:00 2001 From: subochev Date: Mon, 5 Oct 2026 23:33:35 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20outbox=20=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=82=D1=81=D1=8F=20live-only=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BC=D0=BE=D0=BC,=20cursor=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D0=B5=D0=BD=20=D0=B2=20:cursor-api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Семантика 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. --- .../kotlin/pw/binom/agentik/tui/FakeAgent.kt | 10 +- client-sync/build.gradle.kts | 37 ++ .../agentik/client/sync/LocalSyncAgent.kt | 143 ++++++ client/build.gradle.kts | 1 - .../pw/binom/agentik/client/AgentikAgent.kt | 148 +------ .../pw/binom/agentik/client/HttpEventStore.kt | 6 +- .../agentik/client/ReconnectingOutbox.kt | 4 +- .../agentik/client/ReconnectingOutboxTest.kt | 18 +- cursor-api/build.gradle.kts | 30 ++ .../kotlin/pw/binom/agentik/cursor/Cursor.kt | 25 ++ .../pw/binom/agentik/cursor/CursorHolder.kt | 25 ++ .../agentik/cursor/MutableCursorHolder.kt | 43 ++ cursor-inmemory/build.gradle.kts | 31 ++ .../cursor/inmemory/InMemoryCursorHolder.kt | 29 ++ .../inmemory/InMemoryMutableCursorHolder.kt | 27 ++ .../InMemoryMutableCursorHolderTest.kt | 34 ++ .../build.gradle.kts | 6 +- .../cursor/ksqlite/KsqliteCursorHolder.kt | 132 ++++++ .../binom/agentik/cursor}/ksqlite/Schema.kt | 24 +- .../cursor/ksqlite/KsqliteCursorHolderTest.kt | 63 +++ gradle/libs.versions.toml | 1 + journal-api/build.gradle.kts | 1 + .../pw/binom/agentik/journal/JournalStore.kt | 55 +-- .../pw/binom/agentik/journal/MessageRecord.kt | 27 +- .../journal/inmemory/InMemoryJournalStore.kt | 22 +- journal-ksqlite/build.gradle.kts | 1 + .../journal/ksqlite/KsqliteJournalStore.kt | 83 +--- .../agentik/journal/ksqlite/MessageCodecs.kt | 12 +- .../binom/agentik/journal/ksqlite/Schema.kt | 70 +-- ksqlite-message-storage/build.gradle.kts | 34 ++ .../kotlin/pw/binom/MessageStore.kt | 414 ++++++++++++++++++ .../kotlin/pw/binom/MessageStoreTest.kt | 382 ++++++++++++++++ .../kotlin/pw/binom/MessageStoreFileTest.kt | 88 ++++ outbox-api/build.gradle.kts | 1 + .../pw/binom/agentik/outbox/CommonEvent.kt | 17 +- .../kotlin/pw/binom/agentik/outbox/Cursor.kt | 51 --- .../pw/binom/agentik/outbox/CursorStore.kt | 25 -- .../pw/binom/agentik/outbox/DurableEvent.kt | 8 +- .../agentik/outbox/MutableOutboxStore.kt | 31 +- .../binom/agentik/outbox/OffsetSequencer.kt | 39 -- .../pw/binom/agentik/outbox/OnlineOutbox.kt | 4 +- .../agentik/outbox/OutboxGapException.kt | 34 -- .../pw/binom/agentik/outbox/OutboxStore.kt | 101 +---- .../outbox/PersistentOffsetSequencer.kt | 62 --- outbox-inmemory/build.gradle.kts | 4 +- .../inmemory/InMemoryOffsetSequencer.kt | 34 -- .../outbox/inmemory/InMemoryOnlineOutbox.kt | 3 - .../outbox/inmemory/InMemoryOutboxStore.kt | 155 +------ .../inmemory/InMemoryOutboxStoreTest.kt | 321 +++----------- .../outbox/ksqlite/KsqliteCursorStore.kt | 78 ---- .../outbox/ksqlite/KsqliteCursorStoreTest.kt | 80 ---- .../kotlin/pw/binom/agentik/proto/Snapshot.kt | 8 +- .../pw/binom/agentik/server/OutboxRoutes.kt | 19 +- .../agentik/server/AgentInfoRouteTest.kt | 18 +- .../binom/agentik/server/BearerTokenTest.kt | 10 +- .../agentik/server/ConversationRoutesTest.kt | 10 +- .../agentik/server/JournalRoutesCountTest.kt | 11 +- .../binom/agentik/server/SnapshotRouteTest.kt | 24 +- settings.gradle.kts | 31 +- .../pw/binom/agentik/standalone/A2aBridge.kt | 14 +- .../binom/agentik/standalone/DebugRoutes.kt | 141 ------ .../pw/binom/agentik/standalone/Main.kt | 86 ++-- .../agentik/standalone/agent/ChatAgent.kt | 30 +- .../standalone/agent/ConversationEvents.kt | 29 +- .../standalone/agent/ConversationLoop.kt | 9 +- .../agentik/standalone/agent/DurableLog.kt | 70 +-- .../standalone/agent/ReflectionScheduler.kt | 4 +- .../standalone/agent/ToolDispatcher.kt | 6 +- .../agentik/standalone/config/AppConfig.kt | 24 + .../standalone/persistence/SqliteStores.kt | 36 +- .../agentik/standalone/agent/ChatAgentTest.kt | 91 ++-- .../standalone/persistence/PersistenceTest.kt | 9 +- sync-core/README.md | 46 -- sync-core/build.gradle.kts | 41 -- .../pw/binom/agentik/sync/AssistantImpl.kt | 60 --- .../kotlin/pw/binom/agentik/sync/Cursor.kt | 128 ------ .../binom/agentik/sync/CursorExpiredReason.kt | 12 - .../pw/binom/agentik/sync/DefaultReducer.kt | 129 ------ .../kotlin/pw/binom/agentik/sync/Domain.kt | 217 --------- .../kotlin/pw/binom/agentik/sync/Errors.kt | 23 - .../kotlin/pw/binom/agentik/sync/Events.kt | 78 ---- .../pw/binom/agentik/sync/Interfaces.kt | 160 ------- .../pw/binom/agentik/sync/SyncEngine.kt | 108 ----- .../kotlin/pw/binom/agentik/sync/Transport.kt | 119 ----- .../binom/agentik/sync/ksqlite/EventCodec.kt | 41 -- .../pw/binom/agentik/sync/ksqlite/Schema.kt | 169 ------- .../agentik/sync/ksqlite/SqliteEventLog.kt | 385 ---------------- .../sync/ksqlite/SqliteEventLogReplica.kt | 319 -------------- .../agentik/sync/ksqlite/SqliteStateStore.kt | 339 -------------- .../agentik/sync/ksqlite/SqliteSyncBundle.kt | 100 ----- .../agentik/sync/memory/InMemoryEventLog.kt | 277 ------------ .../agentik/sync/memory/InProcessTransport.kt | 156 ------- .../binom/agentik/sync/testing/SyncBundle.kt | 59 --- .../sync/ksqlite/KsqlitePersistenceTest.kt | 55 --- .../sync/ksqlite/KsqliteSyncSpecTest.kt | 229 ---------- .../binom/agentik/sync/test/AssistantTest.kt | 88 ---- .../sync/test/CompactionAndOrderTest.kt | 157 ------- .../pw/binom/agentik/sync/test/CursorTest.kt | 123 ------ .../binom/agentik/sync/test/EditDeleteTest.kt | 114 ----- .../agentik/sync/test/EpochMismatchTest.kt | 142 ------ .../agentik/sync/test/IdempotencyTest.kt | 91 ---- .../binom/agentik/sync/test/InvariantsTest.kt | 166 ------- .../agentik/sync/test/OfflineWriteTest.kt | 106 ----- .../pw/binom/agentik/sync/test/Samples.kt | 63 --- .../sync/test/memory/InMemorySyncBundle.kt | 64 --- .../kotlin/pw/binom/CursorExpiredException.kt | 3 + sync/src/commonMain/kotlin/pw/binom/Event.kt | 3 + .../kotlin/pw/binom/EventSeqResult.kt | 7 + .../commonMain/kotlin/pw/binom/EventStore.kt | 7 + .../pw/binom/KSqliteMutableEventStore.kt | 2 +- .../kotlin/pw/binom/MutableEventStore.kt | 4 + .../kotlin/pw/binom/MutableStateStore.kt | 4 + .../kotlin/pw/binom/MutableStorage.kt | 13 +- .../commonMain/kotlin/pw/binom/StateStore.kt | 8 +- .../pw/binom/KSqliteMutableEventStoreTest.kt | 3 + 115 files changed, 2147 insertions(+), 6025 deletions(-) create mode 100644 client-sync/build.gradle.kts create mode 100644 client-sync/src/commonMain/kotlin/pw/binom/agentik/client/sync/LocalSyncAgent.kt create mode 100644 cursor-api/build.gradle.kts create mode 100644 cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/Cursor.kt create mode 100644 cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/CursorHolder.kt create mode 100644 cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/MutableCursorHolder.kt create mode 100644 cursor-inmemory/build.gradle.kts create mode 100644 cursor-inmemory/src/commonMain/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryCursorHolder.kt create mode 100644 cursor-inmemory/src/commonMain/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryMutableCursorHolder.kt create mode 100644 cursor-inmemory/src/commonTest/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryMutableCursorHolderTest.kt rename {outbox-ksqlite => cursor-ksqlite}/build.gradle.kts (74%) create mode 100644 cursor-ksqlite/src/commonMain/kotlin/pw/binom/agentik/cursor/ksqlite/KsqliteCursorHolder.kt rename {outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox => cursor-ksqlite/src/commonMain/kotlin/pw/binom/agentik/cursor}/ksqlite/Schema.kt (53%) create mode 100644 cursor-ksqlite/src/commonTest/kotlin/pw/binom/agentik/cursor/ksqlite/KsqliteCursorHolderTest.kt create mode 100644 ksqlite-message-storage/build.gradle.kts create mode 100644 ksqlite-message-storage/src/commonMain/kotlin/pw/binom/MessageStore.kt create mode 100644 ksqlite-message-storage/src/commonTest/kotlin/pw/binom/MessageStoreTest.kt create mode 100644 ksqlite-message-storage/src/jvmTest/kotlin/pw/binom/MessageStoreFileTest.kt delete mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Cursor.kt delete mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CursorStore.kt delete mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OffsetSequencer.kt delete mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxGapException.kt delete mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/PersistentOffsetSequencer.kt delete mode 100644 outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOffsetSequencer.kt delete mode 100644 outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStore.kt delete mode 100644 outbox-ksqlite/src/commonTest/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStoreTest.kt delete mode 100644 standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt delete mode 100644 sync-core/README.md delete mode 100644 sync-core/build.gradle.kts delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/AssistantImpl.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Cursor.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/CursorExpiredReason.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/DefaultReducer.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Domain.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Errors.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Events.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Interfaces.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/SyncEngine.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Transport.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/EventCodec.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/Schema.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLog.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLogReplica.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteStateStore.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteSyncBundle.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InMemoryEventLog.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InProcessTransport.kt delete mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/testing/SyncBundle.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqlitePersistenceTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqliteSyncSpecTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/AssistantTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CompactionAndOrderTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CursorTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EditDeleteTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EpochMismatchTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/IdempotencyTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/InvariantsTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/OfflineWriteTest.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/Samples.kt delete mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/memory/InMemorySyncBundle.kt diff --git a/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt b/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt index b5d5776..25b5927 100644 --- a/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt +++ b/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt @@ -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() override fun agentEvents(after: Cursor?) = emptyFlow() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() - 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++ diff --git a/client-sync/build.gradle.kts b/client-sync/build.gradle.kts new file mode 100644 index 0000000..b6d46d3 --- /dev/null +++ b/client-sync/build.gradle.kts @@ -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")) + } + } +} diff --git a/client-sync/src/commonMain/kotlin/pw/binom/agentik/client/sync/LocalSyncAgent.kt b/client-sync/src/commonMain/kotlin/pw/binom/agentik/client/sync/LocalSyncAgent.kt new file mode 100644 index 0000000..deff7ec --- /dev/null +++ b/client-sync/src/commonMain/kotlin/pw/binom/agentik/client/sync/LocalSyncAgent.kt @@ -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) { + val fresh = records.mapTo(HashSet()) { it.id } + val stale = ArrayList() + 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 = localStore.list(offset, limit) + override fun close() {} // owned by outer close + } + + override fun close() { + syncJob.cancel() + delegate.close() + } +} diff --git a/client/build.gradle.kts b/client/build.gradle.kts index 742d3b8..cadb0ef 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -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) diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt index 104d0e5..497b70e 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt @@ -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) { - val fresh = records.mapTo(HashSet()) { it.id } - val stale = ArrayList() - 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 = 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) } } diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt index e54cf52..f2881fe 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt @@ -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, diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/ReconnectingOutbox.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/ReconnectingOutbox.kt index d8ccfbf..89d1eb6 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/ReconnectingOutbox.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/ReconnectingOutbox.kt @@ -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 diff --git a/client/src/commonTest/kotlin/pw/binom/agentik/client/ReconnectingOutboxTest.kt b/client/src/commonTest/kotlin/pw/binom/agentik/client/ReconnectingOutboxTest.kt index 24f6cf7..0d272d3 100644 --- a/client/src/commonTest/kotlin/pw/binom/agentik/client/ReconnectingOutboxTest.kt +++ b/client/src/commonTest/kotlin/pw/binom/agentik/client/ReconnectingOutboxTest.kt @@ -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 = 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) diff --git a/cursor-api/build.gradle.kts b/cursor-api/build.gradle.kts new file mode 100644 index 0000000..82c52a1 --- /dev/null +++ b/cursor-api/build.gradle.kts @@ -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) + } + } +} diff --git a/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/Cursor.kt b/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/Cursor.kt new file mode 100644 index 0000000..c71eea2 --- /dev/null +++ b/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/Cursor.kt @@ -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 { + override fun compareTo(other: Cursor): Int { + val byCreatedAt = createdAt.compareTo(other.createdAt) + if (byCreatedAt != 0) return byCreatedAt + return offset.compareTo(other.offset) + } +} \ No newline at end of file diff --git a/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/CursorHolder.kt b/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/CursorHolder.kt new file mode 100644 index 0000000..a69f958 --- /dev/null +++ b/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/CursorHolder.kt @@ -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 +} diff --git a/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/MutableCursorHolder.kt b/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/MutableCursorHolder.kt new file mode 100644 index 0000000..6c62ca7 --- /dev/null +++ b/cursor-api/src/commonMain/kotlin/pw/binom/agentik/cursor/MutableCursorHolder.kt @@ -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) +} diff --git a/cursor-inmemory/build.gradle.kts b/cursor-inmemory/build.gradle.kts new file mode 100644 index 0000000..aa2a38f --- /dev/null +++ b/cursor-inmemory/build.gradle.kts @@ -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) + } + } +} diff --git a/cursor-inmemory/src/commonMain/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryCursorHolder.kt b/cursor-inmemory/src/commonMain/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryCursorHolder.kt new file mode 100644 index 0000000..fa50c9b --- /dev/null +++ b/cursor-inmemory/src/commonMain/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryCursorHolder.kt @@ -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 + } +} diff --git a/cursor-inmemory/src/commonMain/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryMutableCursorHolder.kt b/cursor-inmemory/src/commonMain/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryMutableCursorHolder.kt new file mode 100644 index 0000000..9ba3b20 --- /dev/null +++ b/cursor-inmemory/src/commonMain/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryMutableCursorHolder.kt @@ -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 + } +} diff --git a/cursor-inmemory/src/commonTest/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryMutableCursorHolderTest.kt b/cursor-inmemory/src/commonTest/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryMutableCursorHolderTest.kt new file mode 100644 index 0000000..b7eceb2 --- /dev/null +++ b/cursor-inmemory/src/commonTest/kotlin/pw/binom/agentik/cursor/inmemory/InMemoryMutableCursorHolderTest.kt @@ -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()) + } +} diff --git a/outbox-ksqlite/build.gradle.kts b/cursor-ksqlite/build.gradle.kts similarity index 74% rename from outbox-ksqlite/build.gradle.kts rename to cursor-ksqlite/build.gradle.kts index 6b82ae6..81fede2 100644 --- a/outbox-ksqlite/build.gradle.kts +++ b/cursor-ksqlite/build.gradle.kts @@ -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")) diff --git a/cursor-ksqlite/src/commonMain/kotlin/pw/binom/agentik/cursor/ksqlite/KsqliteCursorHolder.kt b/cursor-ksqlite/src/commonMain/kotlin/pw/binom/agentik/cursor/ksqlite/KsqliteCursorHolder.kt new file mode 100644 index 0000000..e2400cd --- /dev/null +++ b/cursor-ksqlite/src/commonMain/kotlin/pw/binom/agentik/cursor/ksqlite/KsqliteCursorHolder.kt @@ -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() + } +} diff --git a/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/Schema.kt b/cursor-ksqlite/src/commonMain/kotlin/pw/binom/agentik/cursor/ksqlite/Schema.kt similarity index 53% rename from outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/Schema.kt rename to cursor-ksqlite/src/commonMain/kotlin/pw/binom/agentik/cursor/ksqlite/Schema.kt index b5b0a99..51a6a45 100644 --- a/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/Schema.kt +++ b/cursor-ksqlite/src/commonMain/kotlin/pw/binom/agentik/cursor/ksqlite/Schema.kt @@ -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 ); """ diff --git a/cursor-ksqlite/src/commonTest/kotlin/pw/binom/agentik/cursor/ksqlite/KsqliteCursorHolderTest.kt b/cursor-ksqlite/src/commonTest/kotlin/pw/binom/agentik/cursor/ksqlite/KsqliteCursorHolderTest.kt new file mode 100644 index 0000000..13dc202 --- /dev/null +++ b/cursor-ksqlite/src/commonTest/kotlin/pw/binom/agentik/cursor/ksqlite/KsqliteCursorHolderTest.kt @@ -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() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6e3ebbe..b324241 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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, пока не используется --- diff --git a/journal-api/build.gradle.kts b/journal-api/build.gradle.kts index 3ac2371..117d149 100644 --- a/journal-api/build.gradle.kts +++ b/journal-api/build.gradle.kts @@ -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) diff --git a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/JournalStore.kt b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/JournalStore.kt index 4d68261..f2d7ba3 100644 --- a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/JournalStore.kt +++ b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/JournalStore.kt @@ -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 - /** - * 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 - /** Сколько сообщений в диалоге [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 = 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 = 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 } diff --git a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/MessageRecord.kt b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/MessageRecord.kt index 7bf0c17..1cf26c6 100644 --- a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/MessageRecord.kt +++ b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/MessageRecord.kt @@ -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, 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 } diff --git a/journal-inmemory/src/commonMain/kotlin/pw/binom/agentik/journal/inmemory/InMemoryJournalStore.kt b/journal-inmemory/src/commonMain/kotlin/pw/binom/agentik/journal/inmemory/InMemoryJournalStore.kt index 945b5ec..efc9a41 100644 --- a/journal-inmemory/src/commonMain/kotlin/pw/binom/agentik/journal/inmemory/InMemoryJournalStore.kt +++ b/journal-inmemory/src/commonMain/kotlin/pw/binom/agentik/journal/inmemory/InMemoryJournalStore.kt @@ -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 = 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 } diff --git a/journal-ksqlite/build.gradle.kts b/journal-ksqlite/build.gradle.kts index 1dbea12..58d6dc8 100644 --- a/journal-ksqlite/build.gradle.kts +++ b/journal-ksqlite/build.gradle.kts @@ -26,6 +26,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) api(project(":journal-api")) + api(project(":sync")) } commonTest.dependencies { implementation(kotlin("test")) diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt index a89a3a5..040c604 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt @@ -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 = 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() - 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() } diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt index 4de256b..096d3c1 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt @@ -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") } diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt index 206594e..20e5a5f 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt @@ -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 - } } diff --git a/ksqlite-message-storage/build.gradle.kts b/ksqlite-message-storage/build.gradle.kts new file mode 100644 index 0000000..140c529 --- /dev/null +++ b/ksqlite-message-storage/build.gradle.kts @@ -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) + } + } +} diff --git a/ksqlite-message-storage/src/commonMain/kotlin/pw/binom/MessageStore.kt b/ksqlite-message-storage/src/commonMain/kotlin/pw/binom/MessageStore.kt new file mode 100644 index 0000000..f2911f5 --- /dev/null +++ b/ksqlite-message-storage/src/commonMain/kotlin/pw/binom/MessageStore.kt @@ -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, + val conversationId: String, + ) : MessageRecord, Message + + @Serializable + data class AssistantMessage( + override val id: String, + override val date: Instant, + val conversationId: String, + val content: List, + ) : 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, + ) : MessageEvent, Message + + @Serializable + class AddAssistantMessage( + override val id: String, + override val date: Instant, + override val conversationId: String, + val content: List, + ) : 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, 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 = 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 = 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>() + private var after: Cursor? = null + private var exhausted = false + + fun peek(): Pair? { + if (buffer.isEmpty() && !exhausted) load() + return buffer.firstOrNull() + } + + fun poll(): Pair? { + 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() + } +} diff --git a/ksqlite-message-storage/src/commonTest/kotlin/pw/binom/MessageStoreTest.kt b/ksqlite-message-storage/src/commonTest/kotlin/pw/binom/MessageStoreTest.kt new file mode 100644 index 0000000..510acdd --- /dev/null +++ b/ksqlite-message-storage/src/commonTest/kotlin/pw/binom/MessageStoreTest.kt @@ -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 { + store.push( + MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("b"))), + cursorAt(5u), + ) + } + assertFailsWith { + store.push( + MessageEvent.AddUserMessage("m3", t0, "c", listOf(Content.Text("c"))), + cursorAt(4u), + ) + } + + // Проекция откатилась: только первое сообщение, курсор на месте. + assertEquals( + listOf(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.Conversation("c1", "First")), + store.getState(created).toList(), + ) + + val renamed = nextCursor() + store.push(MessageEvent.RenameConversation("c1", "Renamed"), renamed) + assertEquals( + listOf(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.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.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.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.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.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()) + } + } +} diff --git a/ksqlite-message-storage/src/jvmTest/kotlin/pw/binom/MessageStoreFileTest.kt b/ksqlite-message-storage/src/jvmTest/kotlin/pw/binom/MessageStoreFileTest.kt new file mode 100644 index 0000000..470aac7 --- /dev/null +++ b/ksqlite-message-storage/src/jvmTest/kotlin/pw/binom/MessageStoreFileTest.kt @@ -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.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 { 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() + } + } +} diff --git a/outbox-api/build.gradle.kts b/outbox-api/build.gradle.kts index 89d3690..3cb68a1 100644 --- a/outbox-api/build.gradle.kts +++ b/outbox-api/build.gradle.kts @@ -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) diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt index 115a67a..9be17ed 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt @@ -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 diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Cursor.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Cursor.kt deleted file mode 100644 index 435712f..0000000 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Cursor.kt +++ /dev/null @@ -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') - } -} diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CursorStore.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CursorStore.kt deleted file mode 100644 index 5dbdc9c..0000000 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CursorStore.kt +++ /dev/null @@ -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) -} diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/DurableEvent.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/DurableEvent.kt index 21a814b..090de47 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/DurableEvent.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/DurableEvent.kt @@ -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, не сохраняются). diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOutboxStore.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOutboxStore.kt index 22401e2..8281c2d 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOutboxStore.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOutboxStore.kt @@ -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) } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OffsetSequencer.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OffsetSequencer.kt deleted file mode 100644 index 69d1cf7..0000000 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OffsetSequencer.kt +++ /dev/null @@ -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 -} diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineOutbox.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineOutbox.kt index b41844c..c0dc49c 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineOutbox.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineOutbox.kt @@ -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 + fun onlineEvents(conversationId: String): Flow = + onlineEvents().filter { it.conversationId == conversationId } /** Освобождает ресурсы. Idempotent. */ override fun close() diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxGapException.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxGapException.kt deleted file mode 100644 index b8c2caf..0000000 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxGapException.kt +++ /dev/null @@ -1,34 +0,0 @@ -package pw.binom.agentik.outbox - -/** - * Курсор клиента вышел за пределы retention'а outbox'а, **или** принадлежит - * другой [Cursor.epoch]. - * - * Это **не ошибка выполнения**, а сигнал протокола: «твой курсор мёртв — я не - * могу отдать непрерывный поток событий, начиная с него». Клиент обязан: - * 1. очистить/пометить свой локальный кэш как устаревший; - * 2. запросить у сервера свежий **snapshot состояния** (он вернёт и состояние, - * и актуальный [Cursor]); - * 3. подписаться на события `after = ` и накатить 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." -) diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxStore.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxStore.kt index b770f1c..58f2ca2 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxStore.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxStore.kt @@ -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 + fun events(): Flow /** * Подписка только на conversation-события ([CommonEvent.Conversation]). * * - `conversationId == null` → все диалоги; * - `conversationId != null` → только этот диалог. - * - * Семантика [after] и `gap` идентична [events]. */ - fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow = - events(after) + fun conversationEvents(conversationId: String? = null): Flow = + events() .filterIsInstance() .let { filtered -> if (conversationId == null) filtered @@ -91,32 +57,9 @@ interface OutboxStore : AutoCloseable { /** * Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/ * переименование диалога). - * - * Семантика [after] и `gap` идентична [events]. */ - fun agentEvents(after: Cursor?): Flow = - events(after).filterIsInstance() - - /** - * Актуальный курсор: 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 = + events().filterIsInstance() override fun close() } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/PersistentOffsetSequencer.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/PersistentOffsetSequencer.kt deleted file mode 100644 index f0b835a..0000000 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/PersistentOffsetSequencer.kt +++ /dev/null @@ -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 - } -} diff --git a/outbox-inmemory/build.gradle.kts b/outbox-inmemory/build.gradle.kts index cd36ed7..37de160 100644 --- a/outbox-inmemory/build.gradle.kts +++ b/outbox-inmemory/build.gradle.kts @@ -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")) diff --git a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOffsetSequencer.kt b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOffsetSequencer.kt deleted file mode 100644 index bffd698..0000000 --- a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOffsetSequencer.kt +++ /dev/null @@ -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() - } -} diff --git a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOnlineOutbox.kt b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOnlineOutbox.kt index a683136..422f973 100644 --- a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOnlineOutbox.kt +++ b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOnlineOutbox.kt @@ -41,9 +41,6 @@ class InMemoryOnlineOutbox( override fun onlineEvents(): Flow = liveFlow - override fun onlineEvents(conversationId: String): Flow = - liveFlow.filter { it.conversationId == conversationId } - override suspend fun appendOnline(event: OnlineEvent) = liveFlow.emit(event) override fun tryAppendOnline(event: OnlineEvent): Boolean = liveFlow.tryEmit(event) diff --git a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStore.kt b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStore.kt index 6366117..c7399da 100644 --- a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStore.kt +++ b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStore.kt @@ -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() - private val subscribers = mutableSetOf>() - - /** - * Offset последнего **записанного** события. Инициализируется из счётчика: - * `current() - 1` (для fresh-счётчика это `-1`). - */ - private var lastOffset: Long = sequencer.current() - 1 + private val liveFlow = MutableSharedFlow( + 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 = 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 = channelFlow { - val channel = Channel( - capacity = LIVE_BUFFER_CAPACITY, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val snapshot: List = 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 = 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 } } diff --git a/outbox-inmemory/src/commonTest/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStoreTest.kt b/outbox-inmemory/src/commonTest/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStoreTest.kt index 86a1dd3..39fa205 100644 --- a/outbox-inmemory/src/commonTest/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStoreTest.kt +++ b/outbox-inmemory/src/commonTest/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStoreTest.kt @@ -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() + 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() - val done = CompletableDeferred() - 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() + val job = backgroundScope.launch { store.conversationEvents().collect { collected.add(it) } } + runCurrent() - val from = Cursor(store.currentCursor().epoch, 0L) - val collected = mutableListOf() - val done = CompletableDeferred() - 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() + val job = backgroundScope.launch { store.conversationEvents("c1").collect { collected.add(it) } } + runCurrent() - val collected = mutableListOf() - 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() + val job = backgroundScope.launch { store.agentEvents().collect { collected.add(it) } } + runCurrent() - val tooOld = Cursor(store.currentCursor().epoch, -1L) - assertFailsWith { - store.events(after = tooOld).collect { } - } - // Ровно на границе — ещё можно. - val atFloor = Cursor(store.currentCursor().epoch, 0L) - val got = mutableListOf() - 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 { - 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 { 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() - .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() - 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 { store.append(agentEvent(5, "again")) } - assertFailsWith { 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 { InMemoryOutboxStore(liveBufferCapacity = 0) } } } - -private val Int.milliseconds: Duration get() = Duration.parse("${this}ms") diff --git a/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStore.kt b/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStore.kt deleted file mode 100644 index 579c09d..0000000 --- a/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStore.kt +++ /dev/null @@ -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() - } -} diff --git a/outbox-ksqlite/src/commonTest/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStoreTest.kt b/outbox-ksqlite/src/commonTest/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStoreTest.kt deleted file mode 100644 index b9be430..0000000 --- a/outbox-ksqlite/src/commonTest/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStoreTest.kt +++ /dev/null @@ -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() - } - } -} diff --git a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Snapshot.kt b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Snapshot.kt index ba9fab3..70b1705 100644 --- a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Snapshot.kt +++ b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Snapshot.kt @@ -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 diff --git a/server/src/commonMain/kotlin/pw/binom/agentik/server/OutboxRoutes.kt b/server/src/commonMain/kotlin/pw/binom/agentik/server/OutboxRoutes.kt index a6689a2..53a907e 100644 --- a/server/src/commonMain/kotlin/pw/binom/agentik/server/OutboxRoutes.kt +++ b/server/src/commonMain/kotlin/pw/binom/agentik/server/OutboxRoutes.kt @@ -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) } diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt index 0ca1635..b64d8d6 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt @@ -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() override fun agentEvents(after: Cursor?) = emptyFlow() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() - 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() override fun agentEvents(after: Cursor?) = emptyFlow() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() - 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 diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt index d469cf4..389a3a6 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt @@ -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() override fun agentEvents(after: Cursor?) = emptyFlow() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() - 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 diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt index 9dff69c..1513f35 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt @@ -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() - 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 = diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt index 7229689..0affcab 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt @@ -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() override fun agentEvents(after: Cursor?) = emptyFlow() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() - 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 = diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/SnapshotRouteTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/SnapshotRouteTest.kt index 3f479da..5e498a8 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/SnapshotRouteTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/SnapshotRouteTest.kt @@ -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 = emptyList(), ) : OutboxStore { override fun events(after: Cursor?): Flow = flowOf(*replay.toTypedArray()) override fun agentEvents(after: Cursor?): Flow = emptyFlow() override fun conversationEvents(after: Cursor?, conversationId: String?): Flow = 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) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6f3876b..3682642 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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.` с `TelegramBridgeComponent` +// (реализация `pw.binom.agentik.agent.Component`). Подключаются в +// `:standalone` при наличии соответствующего токена в env (см. AppConfig). +include(":integrations-telegram") diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt index 4e9699c..1860a80 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt @@ -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() // Онлайн-поток — дельты ответа (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 -> diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt deleted file mode 100644 index 6c94fde..0000000 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt +++ /dev/null @@ -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 { - val rows = workingMemoryStore.list(conversationId) - val pairs = mutableListOf() - 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.text(): String = - filterIsInstance().joinToString("\n") { it.body } diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt index 26a48ab..10bb18a 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -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=.json (формат Claude Desktop) * - Skills: AGENTIK_SKILLS_DIR= (папка с 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 { diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index ac3d279..cd21499 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -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 = 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 = - eventStore.events(after = null) + eventStore.events() .filterIsInstance() .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() - 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) } diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt index 1d34c3f..3353079 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt @@ -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 commit(writeState: suspend (seq: Long) -> T, event: (seq: Long) -> DurableEvent): T = + suspend fun 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 = - durableLog.outbox.conversationEvents(after = after, conversationId = conversationId) + /** + * Cold-flow durable-событий диалога. В новой модели outbox не + * фильтрует по `after` (нет replay) — подписчик держит свой + * [Cursor] и применяет дельты сам. Возвращает все события диалога, + * эмитированные **после** вызова [events]. + */ + fun events(): Flow = + durableLog.outbox.conversationEvents(conversationId = conversationId) .map { it.event } /** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */ diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt index 87e90de..f997f3c 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt @@ -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, ), ) }, diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/DurableLog.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/DurableLog.kt index 80e3379..a924e32 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/DurableLog.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/DurableLog.kt @@ -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 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 } -} +} \ No newline at end of file diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionScheduler.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionScheduler.kt index 9aa4cae..f3b2867 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionScheduler.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionScheduler.kt @@ -66,11 +66,11 @@ internal class ReflectionScheduler( if (subscriptionJob?.isActive == true) return subscriptionJob = scope.launch { merge( - eventStore.events(after = null) + eventStore.events() .filterIsInstance() .filter { it.event is DurableEvent.ConversationClosing && it.conversationId == conversationIdProvider() } .onEach { onClosing() }, - eventStore.events(after = null) + eventStore.events() .filterIsInstance() .filter { it.event is DurableEvent.ToolFailed && it.conversationId == conversationIdProvider() } .onEach { onToolFailure() }, diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt index 2f571ca..34a0b4c 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt @@ -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, ), ) }, diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt index 267691a..f186529 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt @@ -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) diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt index 32266b9..98dbe84 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt @@ -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() } } } diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index c253166..5050a0f 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -338,7 +338,7 @@ class ChatAgentTest { val events = mutableListOf() 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() val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { @@ -380,7 +380,7 @@ class ChatAgentTest { val durable = mutableListOf() val online = mutableListOf() 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() 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() val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { @@ -484,7 +484,7 @@ class ChatAgentTest { // Подписываемся ДО send — SharedFlow без replay val events = mutableListOf() 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; // распаковываем .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() - 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() + 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() 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 }) } } diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt index 5096849..e94ad19 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt @@ -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) diff --git a/sync-core/README.md b/sync-core/README.md deleted file mode 100644 index 12137d0..0000000 --- a/sync-core/README.md +++ /dev/null @@ -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` останется референсом и песочницей для новых идей). diff --git a/sync-core/build.gradle.kts b/sync-core/build.gradle.kts deleted file mode 100644 index 5129b8e..0000000 --- a/sync-core/build.gradle.kts +++ /dev/null @@ -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) - } - } -} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/AssistantImpl.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/AssistantImpl.kt deleted file mode 100644 index e89dc50..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/AssistantImpl.kt +++ /dev/null @@ -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, -) : Assistant { - override fun run(input: RunInput): Flow = flow { - flowBuilder(input).collect { e -> - emit(e) - } - } -} - -/** - * Удобный конструктор для тестов: возвращает готовый [Flow] фиксированных - * событий. Не использовать в production — это test-fixture. - */ -fun scriptedAssistant(events: List): 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) - } - } -} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Cursor.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Cursor.kt deleted file mode 100644 index aacfd8f..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Cursor.kt +++ /dev/null @@ -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 спеки: «строго возрастает, без дыр»). - * - * Строковое представление — `"-"`, например `"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) - - /** Строковое представление `"-"`. */ - 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]. Кодируется как - * примитивная строка формата `"-"`. - */ -object CursorSerializer : KSerializer { - 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()) -} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/CursorExpiredReason.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/CursorExpiredReason.kt deleted file mode 100644 index bd59988..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/CursorExpiredReason.kt +++ /dev/null @@ -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 } diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/DefaultReducer.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/DefaultReducer.kt deleted file mode 100644 index 72ebf85..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/DefaultReducer.kt +++ /dev/null @@ -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 { - - 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 Map.update(key: K, block: (V) -> V): Map { - val old = this[key] ?: return this - return this + (key to block(old)) -} - -private fun Map>.updateMessages( - chatId: ChatId, - block: (List) -> List, -): Map> { - val old = this[chatId] ?: emptyList() - return this + (chatId to block(old)) -} - -private fun Map>.appendMessage( - chatId: ChatId, - message: Message, -): Map> { - val old = this[chatId] ?: emptyList() - return this + (chatId to (old + message)) -} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Domain.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Domain.kt deleted file mode 100644 index 3e0ca8a..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Domain.kt +++ /dev/null @@ -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 = emptyMap(), - val messages: Map> = 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 -} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Errors.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Errors.kt deleted file mode 100644 index 8d67168..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Errors.kt +++ /dev/null @@ -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)") diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Events.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Events.kt deleted file mode 100644 index 75befa3..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Events.kt +++ /dev/null @@ -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?, -) diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Interfaces.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Interfaces.kt deleted file mode 100644 index ddcb4cc..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Interfaces.kt +++ /dev/null @@ -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 - - /** Текущая вершина журнала. `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 - - /** Добавить локальное событие в очередь (статус 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 { - fun reduce(state: S, event: LoggedEvent): S -} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/SyncEngine.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/SyncEngine.kt deleted file mode 100644 index 4540623..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/SyncEngine.kt +++ /dev/null @@ -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 = 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, -) diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Transport.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Transport.kt deleted file mode 100644 index e51c7a9..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Transport.kt +++ /dev/null @@ -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): List - - fun subscribeLive(after: Cursor): Flow - - override fun close() -} - -sealed interface UpdatesResponse { - /** Успешный replay — пачка событий + индикатор «есть ещё» + текущий курсор. */ - data class Ok( - val events: List, - 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`. - * - Каждое событие несёт **стабильный** `eventId` (для идемпотентности). - * - `Flow` завершается, когда ассистент закончил генерацию. Ошибка внутри - * потока пробрасывается вызывающему. - */ -fun interface Assistant { - fun run(input: RunInput): Flow -} - -/** - * Параметры запуска ассистента (§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, -) diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/EventCodec.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/EventCodec.kt deleted file mode 100644 index 35e530c..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/EventCodec.kt +++ /dev/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) -} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/Schema.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/Schema.kt deleted file mode 100644 index 327d5d4..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/Schema.kt +++ /dev/null @@ -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()}')") - } -} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLog.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLog.kt deleted file mode 100644 index 3005db5..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLog.kt +++ /dev/null @@ -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 = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 64, - ) - - /** Публичный read-only view. */ - val appendFlow: SharedFlow get() = appendChannel - - private val appendListeners: MutableList 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 = 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() - 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 { - 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, - ) - } -} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLogReplica.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLogReplica.kt deleted file mode 100644 index aa2b8ea..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLogReplica.kt +++ /dev/null @@ -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 = 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 = withContext(Dispatchers.Default) { - selectPendingStmt.reset() - selectPendingStmt.clearBindings() - selectPendingStmt.bindText(1, PendingStatus.PENDING.name) - val out = mutableListOf() - selectPendingStmt.executeQuery().use { rs -> - while (rs.next()) out.add(rs.toPending()) - } - out - } - - /** Все pending-события (для тестов). */ - suspend fun allPending(): List = withContext(Dispatchers.Default) { - selectAllPendingStmt.reset() - selectAllPendingStmt.clearBindings() - val out = mutableListOf() - 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 - } -} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteStateStore.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteStateStore.kt deleted file mode 100644 index 3493521..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteStateStore.kt +++ /dev/null @@ -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 = 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() - 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>() - 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 - } -} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteSyncBundle.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteSyncBundle.kt deleted file mode 100644 index 22adda1..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteSyncBundle.kt +++ /dev/null @@ -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, - ) - } - } -} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InMemoryEventLog.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InMemoryEventLog.kt deleted file mode 100644 index cfb6fc0..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InMemoryEventLog.kt +++ /dev/null @@ -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 = DefaultChatReducer, - initialEpoch: String = Cursor.ZERO.epoch, -) : EventLogWriter, EventLogReplica, StateStore { - - private val mutex = Mutex() - - /** Журнал событий: append-only. */ - private val events: MutableList = mutableListOf() - /** Для идемпотентности applyRemote: уже применённые `eventId`. */ - private val appliedEventIds: MutableSet = mutableSetOf() - - /** Материализованное состояние (обновляется атомарно с append/applyRemote). */ - private var state: ChatState = ChatState() - - /** Pending-event'ы. Стартуют со статусом PENDING → SENT/FAILED. */ - private val pending: MutableMap = mutableMapOf() - - /** Подписчики на append (используется [InProcessTransport] для live-канала). */ - internal val appendListeners: MutableList 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 = 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 = 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 = mutex.withLock { pending.values.toList() } - - /** Текущая эпоха (read-only). */ - suspend fun epoch(): String = mutex.withLock { currentEpoch } - - override fun close() { - // Ничего не делаем — in-memory. - } -} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InProcessTransport.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InProcessTransport.kt deleted file mode 100644 index f2c8c8d..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InProcessTransport.kt +++ /dev/null @@ -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( - 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): List { - val out = ArrayList(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 { - 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 runBlocking(block: suspend () -> T): T = - kotlinx.coroutines.runBlocking { block() } \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/testing/SyncBundle.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/testing/SyncBundle.kt deleted file mode 100644 index 9706e8d..0000000 --- a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/testing/SyncBundle.kt +++ /dev/null @@ -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) - // переопределяют. - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqlitePersistenceTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqlitePersistenceTest.kt deleted file mode 100644 index f92de50..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqlitePersistenceTest.kt +++ /dev/null @@ -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) - } - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqliteSyncSpecTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqliteSyncSpecTest.kt deleted file mode 100644 index 1c9fdaf..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqliteSyncSpecTest.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/AssistantTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/AssistantTest.kt deleted file mode 100644 index 3c63e06..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/AssistantTest.kt +++ /dev/null @@ -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() - 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", - ) -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CompactionAndOrderTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CompactionAndOrderTest.kt deleted file mode 100644 index fedbb95..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CompactionAndOrderTest.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CursorTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CursorTest.kt deleted file mode 100644 index b1b2803..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CursorTest.kt +++ /dev/null @@ -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 представление собирается как "-". - 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 { Cursor.fromString("") } - } - - @Test - fun `fromString бросает на строку без разделителя`() { - assertFailsWith { Cursor.fromString("noseparator") } - } - - @Test - fun `fromString бросает на строку с несколькими разделителями`() { - assertFailsWith { Cursor.fromString("v1-1-2") } - } - - @Test - fun `fromString бросает на невалидный hex в number`() { - assertFailsWith { Cursor.fromString("v1-zz") } - } - - @Test - fun `constructor бросает на пустую epoch`() { - assertFailsWith { Cursor("", 0uL) } - } - - @Test - fun `constructor бросает на epoch с разделителем`() { - assertFailsWith { Cursor("v-1", 0uL) } - } - - @Test - fun `number в верхнем регистре hex при fromString`() { - // fromString использует toULong(16), который case-insensitive. - val c = Cursor.fromString("v1-FF") - assertEquals(255uL, c.number) - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EditDeleteTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EditDeleteTest.kt deleted file mode 100644 index f3bb1cf..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EditDeleteTest.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EpochMismatchTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EpochMismatchTest.kt deleted file mode 100644 index 3844661..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EpochMismatchTest.kt +++ /dev/null @@ -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)) - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/IdempotencyTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/IdempotencyTest.kt deleted file mode 100644 index f44824f..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/IdempotencyTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package pw.binom.agentik.sync.test - -import pw.binom.agentik.sync.Cursor -import pw.binom.agentik.sync.test.memory.InMemorySyncBundle -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * Проверки §8.1 спеки — идемпотентность по `eventId`. - */ -class IdempotencyTest { - - @Test - fun `повторный append с тем же eventId возвращает тот же cursor`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - 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 — no-op`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - b.server.append(samples.messageAppended(0, 0)) - b.engine.sync() - val snapshot = b.clientReadState() - val msgCountBefore = snapshot.messages.values.sumOf { it.size } - - // Применяем первое событие ещё раз — клиентский state не должен меняться. - val first = b.server.readUpdates(Cursor.ZERO, 100).first() - b.client.applyRemote(first) - val msgCountAfter = b.clientReadState().messages.values.sumOf { it.size } - assertEquals(msgCountBefore, msgCountAfter) - b.close() - } - - @Test - fun `postPending c дубликатом eventId принимает оба - но без дублей строк`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - val userMsg = samples.messageAppended(0, 0, content = "hello") - - // Клиент создаёт pending (локально). - b.client.enqueuePending(userMsg, someInstant) - val pendingList = b.client.pendingEvents() - assertEquals(1, pendingList.size) - - // Сервер уже знает это сообщение (например, другое устройство отправило). - b.server.append(userMsg) - - // postPending должен принять (идемпотентно), строка одна. - val results = b.transport.postPending(pendingList) - assertEquals(1, results.size) - assertTrue(results.first() is pw.binom.agentik.sync.PostResult.Accepted) - b.client.markPendingAsSent( - pendingList.first().localId, - (results.first() as pw.binom.agentik.sync.PostResult.Accepted).cursor, - ) - // Клиент видит только одно сообщение. - val msgs = b.clientReadState().messages.values.flatten() - assertEquals(1, msgs.count { it.content == "hello" }) - b.close() - } - - @Test - fun `cursor монотонен после повторного append`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - val last1 = b.server.lastCursor() - // Повторный append с тем же eventId. - b.server.append(samples.chatCreated(0)) - assertEquals(last1, b.server.lastCursor(), "повторный append не двигает cursor") - // Новый eventId — сдвигает. - b.server.append(samples.messageAppended(0, 0)) - assertEquals(last1.next(), b.server.lastCursor()) - b.close() - } - - @Test - fun `updates возвращает одно и то же событие при повторных вызовах`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - val first = b.transport.fetchUpdates(Cursor.ZERO, 100) - val second = b.transport.fetchUpdates(Cursor.ZERO, 100) - assertEquals(first, second) - b.close() - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/InvariantsTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/InvariantsTest.kt deleted file mode 100644 index c76e353..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/InvariantsTest.kt +++ /dev/null @@ -1,166 +0,0 @@ -package pw.binom.agentik.sync.test - -import pw.binom.agentik.sync.ChatId -import pw.binom.agentik.sync.ChatState -import pw.binom.agentik.sync.Cursor -import pw.binom.agentik.sync.DomainEvent -import pw.binom.agentik.sync.StateSnapshot -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.assertNotNull -import kotlin.test.assertTrue - -/** - * Проверяет **семь инвариантов** из §2 спеки: - * 1. cursor монотонно возрастает (нет дыр, нет сбросов). - * 2. Журнал append-only (события не изменяются, не удаляются кроме как через compact). - * 3. Материализация консистентна журналу: apply(journal[0..N]) == SELECT state WHERE last_cursor <= N. - * 4. События самодостаточны (несут полный payload). - * 5. Full resync = replace (не merge). - * 6. Клиент читает только локальную БД (после sync clientReadState() == serverReadState()). - * 7. Компакция не удаляет события, нужные активным клиентам (cursor_expired → resync). - */ -class InvariantsTest { - - private suspend fun withBundle(block: suspend (SyncBundleFacade) -> Unit) { - val b = InMemorySyncBundle.create() - try { - block(SyncBundleFacade(b)) - } finally { - b.close() - } - } - - /** Лёгкая обёртка — выносим engine.sync() чтобы не дублировать. */ - class SyncBundleFacade(val b: pw.binom.agentik.sync.testing.SyncBundle) { - suspend fun sync() = b.engine.sync() - } - - // ─── Invariant 1: cursor монотонен ────────────────────────────────────── - - @Test - fun `1 cursor монотонно возрастает на append`() = kotlinx.coroutines.runBlocking { - withBundle { f -> - val s1 = f.b.server.append(samples.chatCreated(0)) - val s2 = f.b.server.append(samples.messageAppended(0, 0)) - val s3 = f.b.server.append(samples.messageAppended(0, 1)) - assertEquals(s1.cursor.next(), s2.cursor) - assertEquals(s2.cursor.next(), s3.cursor) - } - } - - @Test - fun `1 cursor number начинается с 1 на свежем журнале`() = kotlinx.coroutines.runBlocking { - withBundle { f -> - assertEquals(Cursor.ZERO, f.b.server.lastCursor()) - val s1 = f.b.server.append(samples.chatCreated(0)) - assertEquals(1uL, s1.cursor.number) - } - } - - // ─── Invariant 2: append-only ─────────────────────────────────────────── - - @Test - fun `2 события не изменяются после append`() = kotlinx.coroutines.runBlocking { - withBundle { f -> - val e = f.b.server.append(samples.chatCreated(0)) - val again = f.b.server.readUpdates(Cursor.ZERO, 100).single() - assertEquals(e, again) - } - } - - // ─── Invariant 3: materialization consistent with journal ──────────────── - - @Test - fun `3 материализация сервера консистентна журналу`() = kotlinx.coroutines.runBlocking { - withBundle { f -> - val e1 = f.b.server.append(samples.chatCreated(0)) - val e2 = f.b.server.append(samples.messageAppended(0, 0)) - val e3 = f.b.server.append(samples.messageAppended(0, 1)) - val state = f.b.serverReadState() - assertTrue(state.chats.isNotEmpty(), "чат должен быть в state") - assertEquals(2, state.messages[ChatId("chat0")]?.size, "два сообщения в state") - assertEquals(e3.cursor, state.lastCursor) - assertTrue(e1.cursor <= state.lastCursor) - assertTrue(e2.cursor <= state.lastCursor) - } - } - - // ─── Invariant 4: events self-contained ───────────────────────────────── - - @Test - fun `4 событие несёт полный payload - не дельту`() = kotlinx.coroutines.runBlocking { - withBundle { f -> - val created = DomainEvent.ChatCreated( - eventId = "e-create-0", - chatId = ChatId("chat-X"), - title = "Полное название", - createdAt = someInstant, - ) - val logged = f.b.server.append(created) - val read = f.b.server.readUpdates(Cursor.ZERO, 100).single() - assertEquals("Полное название", (read.payload as DomainEvent.ChatCreated).title) - assertEquals(ChatId("chat-X"), (read.payload as DomainEvent.ChatCreated).chatId) - assertEquals(logged.cursor, read.cursor) - } - } - - // ─── Invariant 5: replace, не merge ───────────────────────────────────── - - @Test - fun `5 replaceState полностью заменяет state - не мержит`() = kotlinx.coroutines.runBlocking { - withBundle { f -> - f.b.server.append(samples.chatCreated(0)) - f.b.server.append(samples.chatCreated(1)) - f.b.server.append(samples.messageAppended(0, 0)) - f.sync() - assertEquals(2, f.b.clientReadState().chats.size) - f.b.server.append(samples.chatDeleted(0)) - // Стираем клиентский state вручную. - f.b.client.replaceState(StateSnapshot(ChatState())) - // Сервер делает compact → cursor_expired → replace. - f.b.compactServer(upTo = f.b.serverLastCursor()) - f.sync() - val chats = f.b.clientReadState().chats - assertEquals(1, chats.size, "после replace лишний чат должен исчезнуть") - assertNotNull(chats[ChatId("chat1")]) - assertEquals(null, chats[ChatId("chat0")], "удалённый чат не должен быть в state") - } - } - - // ─── Invariant 6: client state == server state после sync ─────────────── - - @Test - fun `6 clientReadState совпадает с serverReadState после sync`() = kotlinx.coroutines.runBlocking { - withBundle { f -> - f.b.server.append(samples.chatCreated(0)) - f.b.server.append(samples.messageAppended(0, 0)) - f.b.server.append(samples.chatCreated(1)) - f.b.server.append(samples.messageAppended(1, 0)) - f.sync() - assertEquals(f.b.serverReadState(), f.b.clientReadState()) - } - } - - // ─── Invariant 7: cursor_expired → full resync ─────────────────────────── - - @Test - fun `7 compact делает cursor протухшим - sync делает replace`() = kotlinx.coroutines.runBlocking { - withBundle { f -> - f.b.server.append(samples.chatCreated(0)) - f.b.server.append(samples.messageAppended(0, 0)) - f.sync() - val clientCursor = f.b.clientLastCursor() - f.b.server.append(samples.messageAppended(0, 1)) - f.b.server.append(samples.messageAppended(0, 2)) - f.b.compactServer(upTo = clientCursor) - val resp = f.b.transport.fetchUpdates(clientCursor, 100) - assertTrue(resp is UpdatesResponse.CursorExpired, "должен быть CursorExpired") - val report = f.b.engine.sync() - assertTrue(report.resolvedCursorGap, "sync должен resolve cursor gap") - assertEquals(3, f.b.clientReadState().messages[ChatId("chat0")]?.size) - } - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/OfflineWriteTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/OfflineWriteTest.kt deleted file mode 100644 index 9bed8c1..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/OfflineWriteTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package pw.binom.agentik.sync.test - -import pw.binom.agentik.sync.ChatId -import pw.binom.agentik.sync.LocalId -import pw.binom.agentik.sync.PendingStatus -import pw.binom.agentik.sync.PostResult -import pw.binom.agentik.sync.test.memory.InMemorySyncBundle -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * Проверки §3.2 (PendingEvent), §5.3 (userSendsMessage), §5.4 (localAssistant), - * §8.4 (офлайн-запись). - */ -class OfflineWriteTest { - - @Test - fun `client enqueuePending оптимистично применяет событие`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - val pe = b.client.enqueuePending(samples.messageAppended(0, 0, content = "hi"), someInstant) - val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty() - assertEquals(1, msgs.size) - assertEquals("hi", msgs.single().content) - assertEquals(PendingStatus.PENDING, pe.status) - b.close() - } - - @Test - fun `sync отправляет pending на сервер и обновляет статус`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - b.client.enqueuePending(samples.messageAppended(0, 0, content = "hi"), someInstant) - val report = b.engine.sync() - assertEquals(1, report.postedPending) - assertEquals(0, report.failedPending) - // Сервер тоже видит это сообщение. - val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty() - assertEquals(1, serverMsgs.size) - assertEquals("hi", serverMsgs.single().content) - b.close() - } - - @Test - fun `сначала client пишёт офлайн - потом sync - потом новое сообщение и опять sync`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline-1"), someInstant) - b.engine.sync() - b.client.enqueuePending(samples.messageAppended(0, 1, content = "online-1"), someInstant) - b.engine.sync() - val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty() - assertEquals(2, serverMsgs.size) - b.close() - } - - @Test - fun `PendingEvent остаётся после replaceState`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - b.client.enqueuePending(samples.messageAppended(0, 0, content = "keep-me"), someInstant) - b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState())) - val pending = b.client.pendingEvents() - assertEquals(1, pending.size) - assertEquals(PendingStatus.PENDING, pending.single().status) - val report = b.engine.sync() - assertEquals(1, report.postedPending) - val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty() - assertEquals(1, serverMsgs.size) - assertEquals("keep-me", serverMsgs.single().content) - b.close() - } - - @Test - fun `markPendingAsFailed переводит pending в FAILED`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - val pe = b.client.enqueuePending(samples.messageAppended(0, 0, content = "x"), someInstant) - b.client.markPendingAsFailed(pe.localId) - val pending = b.client.pendingEvents() - // markPendingAsFailed — теперь pending в FAILED. - // pendingEvents() отдаёт только PENDING — поэтому проверяем через allPending(). - val all = (b.client as pw.binom.agentik.sync.memory.InMemoryEventLog).allPending() - assertEquals(PendingStatus.FAILED, all.single().status) - b.close() - } - - @Test - fun `postPending возвращает Rejected если сервер отверг`() = kotlinx.coroutines.runBlocking { - val b = InMemorySyncBundle.create() - b.server.append(samples.chatCreated(0)) - // Сначала кладём pending в локальную очередь. - b.client.enqueuePending(samples.messageAppended(0, 0, content = "x"), someInstant) - // Симулируем ответ сервера — rejected. - val results: List = listOf(PostResult.Rejected(localId = LocalId("evt-ma-0-0"), reason = "validation failed")) - for (r in results) { - when (r) { - is PostResult.Accepted -> b.client.markPendingAsSent(r.localId, r.cursor) - is PostResult.Rejected -> b.client.markPendingAsFailed(r.localId) - } - } - val all = (b.client as pw.binom.agentik.sync.memory.InMemoryEventLog).allPending() - assertEquals(PendingStatus.FAILED, all.single().status) - b.close() - } -} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/Samples.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/Samples.kt deleted file mode 100644 index 7ec1462..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/Samples.kt +++ /dev/null @@ -1,63 +0,0 @@ -package pw.binom.agentik.sync.test - -import pw.binom.agentik.sync.ChatId -import pw.binom.agentik.sync.DomainEvent -import pw.binom.agentik.sync.MessageId -import kotlin.time.Instant - -/** - * Утилиты для тестов: удобные конструкторы событий с детерминированными - * `eventId` (чтобы тесты могли ссылаться на них в проверках). - */ -object samples { - - private fun eid(tag: String, n: Int): String = "evt-$tag-$n" - - fun chatCreated(n: Int): DomainEvent.ChatCreated = DomainEvent.ChatCreated( - eventId = eid("cc", n), - chatId = ChatId("chat$n"), - title = "Чат #$n", - createdAt = someInstant, - ) - - fun chatRenamed(n: Int, newTitle: String = "Чат #$n — переименован"): DomainEvent.ChatRenamed = - DomainEvent.ChatRenamed( - eventId = eid("cr", n), - chatId = ChatId("chat$n"), - newTitle = newTitle, - ) - - fun chatDeleted(n: Int): DomainEvent.ChatDeleted = DomainEvent.ChatDeleted( - eventId = eid("cd", n), - chatId = ChatId("chat$n"), - ) - - fun messageAppended(chatN: Int, msgN: Int, content: String = "msg-$chatN-$msgN"): DomainEvent.MessageAppended = - DomainEvent.MessageAppended( - eventId = eid("ma-$chatN", msgN), - messageId = MessageId("msg-$chatN-$msgN"), - chatId = ChatId("chat$chatN"), - role = if (msgN % 2 == 0) pw.binom.agentik.sync.MessageRole.USER else pw.binom.agentik.sync.MessageRole.ASSISTANT, - content = content, - parentId = null, - createdAt = someInstant, - ) - - fun messageEdited(chatN: Int, msgN: Int, newContent: String): DomainEvent.MessageEdited = - DomainEvent.MessageEdited( - eventId = eid("me-$chatN", msgN), - messageId = MessageId("msg-$chatN-$msgN"), - chatId = ChatId("chat$chatN"), - newContent = newContent, - ) - - fun messageDeleted(chatN: Int, msgN: Int): DomainEvent.MessageDeleted = - DomainEvent.MessageDeleted( - eventId = eid("md-$chatN", msgN), - messageId = MessageId("msg-$chatN-$msgN"), - chatId = ChatId("chat$chatN"), - ) -} - -/** Фиксированный момент времени — тесты не зависят от часов. */ -val someInstant: Instant = Instant.parse("2026-10-01T00:00:00Z") \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/memory/InMemorySyncBundle.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/memory/InMemorySyncBundle.kt deleted file mode 100644 index da25739..0000000 --- a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/memory/InMemorySyncBundle.kt +++ /dev/null @@ -1,64 +0,0 @@ -package pw.binom.agentik.sync.test.memory - -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.InMemoryEventLog -import pw.binom.agentik.sync.memory.InProcessTransport -import pw.binom.agentik.sync.testing.SyncBundle - -/** - * Бэкенд для тестов на чистой памяти. Серверная и клиентская сторона — - * разные [InMemoryEventLog] (writer-роль и replica-роль), объединённые - * общим [InProcessTransport] (имитирует HTTP/WS). - */ -class InMemorySyncBundle private constructor( - private val serverLog: InMemoryEventLog, - private val clientLog: InMemoryEventLog, - private val transportImpl: InProcessTransport, -) : SyncBundle() { - - override val server: EventLogWriter = serverLog - override val serverState: StateStore = serverLog - override val client: EventLogReplica = clientLog - override val clientState: StateStore = clientLog - 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 = - serverLog.beginNewEpoch(newEpoch) - - /** Отдельно сменить эпоху на клиенте (для тестов сценария «клиент уже - * синхронизировался, а потом сервер wipe'нул»). */ - suspend fun clientBeginNewEpoch(newEpoch: String): Cursor = clientLog.beginNewEpoch(newEpoch) - - override fun close() { - serverLog.close() - clientLog.close() - transportImpl.close() - } - - companion object { - /** Свежий изолированный бандл. */ - fun create(): InMemorySyncBundle { - val server = InMemoryEventLog() - val client = InMemoryEventLog() - val transport = InProcessTransport( - server = server, - stateStore = server, - compactionState = { server.compaction() }, - ) - return InMemorySyncBundle(serverLog = server, clientLog = client, transportImpl = transport) - } - } -} \ No newline at end of file diff --git a/sync/src/commonMain/kotlin/pw/binom/CursorExpiredException.kt b/sync/src/commonMain/kotlin/pw/binom/CursorExpiredException.kt index 17c2d18..33f1daa 100644 --- a/sync/src/commonMain/kotlin/pw/binom/CursorExpiredException.kt +++ b/sync/src/commonMain/kotlin/pw/binom/CursorExpiredException.kt @@ -1,3 +1,6 @@ package pw.binom +/** + * Сигнал о том, что запрошенный курсор уже скомпактирован и запрошенные события недоступны. + */ class CursorExpiredException : RuntimeException() \ No newline at end of file diff --git a/sync/src/commonMain/kotlin/pw/binom/Event.kt b/sync/src/commonMain/kotlin/pw/binom/Event.kt index af0559a..3697d79 100644 --- a/sync/src/commonMain/kotlin/pw/binom/Event.kt +++ b/sync/src/commonMain/kotlin/pw/binom/Event.kt @@ -2,4 +2,7 @@ package pw.binom import pw.binom.agentik.cursor.Cursor +/** + * Событие журнала: полезная нагрузка [data] и её позиция [seq] в общем потоке. + */ data class Event(val data: EVENT, val seq: Cursor) \ No newline at end of file diff --git a/sync/src/commonMain/kotlin/pw/binom/EventSeqResult.kt b/sync/src/commonMain/kotlin/pw/binom/EventSeqResult.kt index e120b4d..5c82507 100644 --- a/sync/src/commonMain/kotlin/pw/binom/EventSeqResult.kt +++ b/sync/src/commonMain/kotlin/pw/binom/EventSeqResult.kt @@ -3,6 +3,13 @@ package pw.binom import kotlinx.coroutines.flow.Flow import kotlin.jvm.JvmInline +/** + * Результат чтения журнала от курсора: либо поток событий, либо признак того, что запрошенный + * курсор уже скомпактирован ([isExpired]). + * + * Реализован как value-класс без боксинга потока; вместо исключения «протухший» курсор + * возвращается явно, а [getOrException] бросает [CursorExpiredException]. + */ @JvmInline value class EventSeqResult private constructor(val raw: Any) { diff --git a/sync/src/commonMain/kotlin/pw/binom/EventStore.kt b/sync/src/commonMain/kotlin/pw/binom/EventStore.kt index 11e19c1..3599c01 100644 --- a/sync/src/commonMain/kotlin/pw/binom/EventStore.kt +++ b/sync/src/commonMain/kotlin/pw/binom/EventStore.kt @@ -2,6 +2,13 @@ package pw.binom import pw.binom.agentik.cursor.Cursor +/** + * Журнал событий, читаемый по курсору (только чтение). + * + * [minCursor]/[maxCursor] — границы хранимого диапазона (null для пустого журнала). [get] отдаёт + * все события начиная с [cursor] включительно, либо [EventSeqResult.expired], если [cursor] + * старше минимального хранимого. Запись — в [MutableEventStore]. + */ interface EventStore : AutoCloseable { val minCursor: Cursor? diff --git a/sync/src/commonMain/kotlin/pw/binom/KSqliteMutableEventStore.kt b/sync/src/commonMain/kotlin/pw/binom/KSqliteMutableEventStore.kt index 821ed13..55eab8e 100644 --- a/sync/src/commonMain/kotlin/pw/binom/KSqliteMutableEventStore.kt +++ b/sync/src/commonMain/kotlin/pw/binom/KSqliteMutableEventStore.kt @@ -40,7 +40,7 @@ import pw.binom.db.ksqlite.transaction * выбрасывается посреди потока. */ @OptIn(ExperimentalSerializationApi::class) -class KSqliteMutableEventStore( +class KSqliteMutableEventStore private constructor( private val connection: SQLiteConnection, private val ownsConnection: Boolean, private val tableName: String, diff --git a/sync/src/commonMain/kotlin/pw/binom/MutableEventStore.kt b/sync/src/commonMain/kotlin/pw/binom/MutableEventStore.kt index 06af8fe..f8fcbec 100644 --- a/sync/src/commonMain/kotlin/pw/binom/MutableEventStore.kt +++ b/sync/src/commonMain/kotlin/pw/binom/MutableEventStore.kt @@ -2,6 +2,10 @@ package pw.binom import pw.binom.agentik.cursor.Cursor +/** + * Изменяемый журнал событий: к чтению из [EventStore] добавляет дозапись ([push]), полную очистку + * ([clear]) и компакцию ([dropAllBefore], удаляет всё до [cursor] включительно). + */ interface MutableEventStore : EventStore { fun push(event: Event) = push(event.data, event.seq) fun push(event: EVENT, cursor: Cursor) diff --git a/sync/src/commonMain/kotlin/pw/binom/MutableStateStore.kt b/sync/src/commonMain/kotlin/pw/binom/MutableStateStore.kt index c38b9c9..c4cc1e8 100644 --- a/sync/src/commonMain/kotlin/pw/binom/MutableStateStore.kt +++ b/sync/src/commonMain/kotlin/pw/binom/MutableStateStore.kt @@ -2,6 +2,10 @@ package pw.binom import pw.binom.agentik.cursor.Cursor +/** + * Материализует [STATE] из событий: [apply] применяет событие с позицией [cursor] к состоянию. + * Как именно — решает сам стор, отдельный редьюсер не требуется. + */ interface MutableStateStore : StateStore { fun apply(event: EVENT, cursor: Cursor) fun apply(event: Event) = apply(event.data, event.seq) diff --git a/sync/src/commonMain/kotlin/pw/binom/MutableStorage.kt b/sync/src/commonMain/kotlin/pw/binom/MutableStorage.kt index 8c49b7b..fbbb75f 100644 --- a/sync/src/commonMain/kotlin/pw/binom/MutableStorage.kt +++ b/sync/src/commonMain/kotlin/pw/binom/MutableStorage.kt @@ -2,11 +2,12 @@ package pw.binom import pw.binom.agentik.cursor.Cursor -interface MutableStorage: AutoCloseable { +/** + * Хранилище, совмещающее материализованное состояние ([StateStore]) и журнал событий + * ([EventStore]) в одном месте — локальная БД клиента, из которой рендерится UI. + * [push] дозаписывает событие в журнал. + */ +interface MutableStorage: StateStore,EventStore { fun push(event: EVENT, cursor: Cursor) - - /** - * Возвращает состояние на момент [cursor] - */ - fun getState(cursor: Cursor): STATE + fun clear() } \ No newline at end of file diff --git a/sync/src/commonMain/kotlin/pw/binom/StateStore.kt b/sync/src/commonMain/kotlin/pw/binom/StateStore.kt index 5f28b62..694391c 100644 --- a/sync/src/commonMain/kotlin/pw/binom/StateStore.kt +++ b/sync/src/commonMain/kotlin/pw/binom/StateStore.kt @@ -2,9 +2,11 @@ package pw.binom import pw.binom.agentik.cursor.Cursor -interface StateStore { - - +/** + * Материализованное из журнала состояние, читаемое по курсору (только чтение). + * Наполнение — в [MutableStateStore]. + */ +interface StateStore : AutoCloseable { /** * Возвращает состояние на момент [cursor] */ diff --git a/sync/src/commonTest/kotlin/pw/binom/KSqliteMutableEventStoreTest.kt b/sync/src/commonTest/kotlin/pw/binom/KSqliteMutableEventStoreTest.kt index 26c974a..b66d62a 100644 --- a/sync/src/commonTest/kotlin/pw/binom/KSqliteMutableEventStoreTest.kt +++ b/sync/src/commonTest/kotlin/pw/binom/KSqliteMutableEventStoreTest.kt @@ -17,6 +17,9 @@ private const val EPOCH = 1_700_000_000_000L @Serializable private data class TestEvent(val value: Int) +/** + * Тесты ksqlite-реализации журнала событий [KSqliteMutableEventStore] на in-memory SQLite. + */ class KSqliteMutableEventStoreTest { private fun cursor(offset: ULong, createdAt: Long = EPOCH) = Cursor(createdAt = createdAt, offset = offset)