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

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

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

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

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

server / client:
  * Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
  * AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
  * ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
  * JournalStore API расширен count(after=Instant?) для unread-badge.
This commit is contained in:
2026-10-05 23:33:35 +03:00
parent 22a167ed03
commit 9e888227a3
115 changed files with 2147 additions and 6025 deletions
+31
View File
@@ -0,0 +1,31 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализации :cursor-api в памяти — для тестов, dev-режима и
// embedded-сценариев (Android core, CLI):
// - `InMemoryCursorHolder` (серверный allocator): эпоха (`createdAt`)
// генерируется при создании и не переживает пересоздание инстанса;
// - `InMemoryMutableCursorHolder` (клиентская закладка).
//
// Зависимости: только `:cursor-api`. Никакого I/O — pure in-memory.
//
// Цели сборки — jvm() + linuxX64() + mingwX64() (как у `:outbox-inmemory`).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":cursor-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,29 @@
package pw.binom.agentik.cursor.inmemory
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.cursor.CursorHolder
import kotlin.time.Clock
/**
* In-memory [CursorHolder] — для тестов, dev-режима и ephemeral runtime.
*
* `createdAt` генерируется при создании и **не переживает** пересоздание
* инстанса: новый holder → новая эпоха → клиент с прежним курсором получит
* `OutboxGapException` и сделает resync. Для production-агента нужен
* персистентный holder (см. `:cursor-ksqlite`).
*/
class InMemoryCursorHolder(
private val createdAt: Long = Clock.System.now().toEpochMilliseconds(),
initialOffset: ULong = 1u,
) : CursorHolder {
private var counter: ULong = initialOffset
override fun current(): Cursor = Cursor(createdAt, counter)
override fun next(): Cursor {
val assigned = current()
counter += 1u
return assigned
}
}
@@ -0,0 +1,27 @@
package pw.binom.agentik.cursor.inmemory
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.cursor.MutableCursorHolder
/**
* In-memory [MutableCursorHolder].
*
* Курсор живёт только в RAM: после пересоздания инстанса (рестарт процесса)
* он теряется, и клиент снова пойдёт за снапшотом. Для переживания рестарта
* нужен durable-impl (файл / DataStore / SQLite) со стороны хоста — этот класс
* лишь даёт готовую реализацию для dev/test/ephemeral-сценариев.
*
* @param initial стартовое значение; обычно результат загрузки из хранилища.
*/
class InMemoryMutableCursorHolder(
initial: Cursor? = null,
) : MutableCursorHolder {
private var value: Cursor? = initial
override fun current(): Cursor? = value
override suspend fun set(cursor: Cursor) {
value = cursor
}
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.cursor.inmemory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.cursor.Cursor
class InMemoryMutableCursorHolderTest {
@Test
fun startsEmpty() {
assertNull(InMemoryMutableCursorHolder().current())
}
@Test
fun startsFromInitial() {
val c = Cursor(createdAt = 1_700_000_000_000L, offset = 42u)
assertEquals(c, InMemoryMutableCursorHolder(initial = c).current())
}
@Test
fun setReplacesValue() = runTest {
val holder = InMemoryMutableCursorHolder()
val first = Cursor(createdAt = 1_700_000_000_000L, offset = 1u)
val second = Cursor(createdAt = 1_700_000_000_000L, offset = 2u)
holder.set(first)
assertEquals(first, holder.current())
holder.set(second)
assertEquals(second, holder.current())
}
}