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
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация :cursor-api `CursorHolder` поверх ksqlite.
// Минимальная — только таблица `outbox_cursor` (одна строка: epoch + next offset).
// Даёт production-агенту персистентный счётчик: обычный рестарт сервера
// не ротирует epoch, клиент продолжает инкрементально.
//
// Цели сборки — jvm() + linuxX64() + mingwX64() (как у остальных ksqlite-модулей).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
implementation(libs.ksqlite)
api(project(":cursor-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,132 @@
package pw.binom.agentik.cursor.ksqlite
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.cursor.CursorHolder
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
/**
* ksqlite-реализация [CursorHolder] — таблица `outbox_cursor` (одна строка,
* `id = 1`).
*
* На создании читает сохранённую позицию (`createdAt` + next offset) и держит
* её в памяти. Позиция переживает рестарт процесса, поэтому обычный рестарт
* сервера **не меняет** `createdAt`, offset'ы остаются монотонными — клиент
* продолжает инкрементально, а не получает gap на каждой перезагрузке.
*
* ## Стартовый offset
*
* Если таблица пустая (первый запуск / апгрейд БД, где offset уже накоплен),
* `createdAt` генерируется ([newCreatedAt]), а next берётся из [initialNext]
* (по умолчанию `1`, апгрейд должен передать `maxOffset + 1`).
*
* ## Смена эпохи
*
* Смена «мира» (сброс/восстановление БД) — у вызывающего: очисти таблицу,
* следующий старт сгенерирует новый `createdAt`.
*
* ## Lifecycle соединения
*
* Две формы, как у остальных ksqlite-store'ов:
* - `KsqliteCursorHolder(connection, ...)` — внешнее соединение, holder НЕ
* закрывает его в [close]. Для shared-connection bundle'а
* (`SqliteStores.assemble`).
* - `KsqliteCursorHolder(path, ...)` — открывает файловое соединение и
* закрывает его в [close].
*
* `[next]` синхронный — ksqlite-запись блокирующая. Вызовы сериализует
* владелец (в `standalone` — мьютекс [pw.binom.agentik.outbox.MutableOutboxStore]),
* так что собственная синхронизация не нужна; prepared statements закрываются
* в [close] ДО owned-connection (иначе финалайзеры stmt'ов дёргают уже
* закрытый parent → SIGSEGV).
*/
class KsqliteCursorHolder private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
initialNext: () -> ULong,
newCreatedAt: () -> Long,
) : CursorHolder, AutoCloseable {
/** Внешнее соединение — holder НЕ закрывает его в [close]. */
constructor(
connection: SQLiteConnection,
initialNext: () -> ULong = { 1u },
newCreatedAt: () -> Long = { Clock.System.now().toEpochMilliseconds() },
) : this(connection, ownsConnection = false, initialNext, newCreatedAt)
/** Файловое соединение — holder закрывает его в [close]. */
constructor(
path: String,
initialNext: () -> ULong = { 1u },
newCreatedAt: () -> Long = { Clock.System.now().toEpochMilliseconds() },
) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
initialNext,
newCreatedAt,
)
init {
Schema.migrate(connection)
}
private val getStmt: SQLitePreparedStatement = connection.prepare(
"SELECT ${Schema.COL_CREATED_AT}, ${Schema.COL_OFFSET} " +
"FROM ${Schema.TABLE} WHERE ${Schema.COL_ID} = 1"
)
private val setStmt: SQLitePreparedStatement = connection.prepare(
"INSERT INTO ${Schema.TABLE}(${Schema.COL_ID}, ${Schema.COL_CREATED_AT}, ${Schema.COL_OFFSET}) " +
"VALUES(1, ?, ?) ON CONFLICT(${Schema.COL_ID}) DO UPDATE SET " +
"${Schema.COL_CREATED_AT}=excluded.${Schema.COL_CREATED_AT}, " +
"${Schema.COL_OFFSET}=excluded.${Schema.COL_OFFSET}"
)
private val createdAt: Long
private var next: ULong
init {
val saved = load()
if (saved == null) {
createdAt = newCreatedAt()
next = initialNext()
// Фиксируем createdAt сразу, чтобы он не «прыгал» до первого события.
save(Cursor(createdAt = createdAt, offset = next))
} else {
createdAt = saved.createdAt
next = saved.offset
}
}
override fun current(): Cursor = Cursor(createdAt, next)
override fun next(): Cursor {
val assigned = Cursor(createdAt, next)
next += 1u
save(Cursor(createdAt, next))
return assigned
}
private fun load(): Cursor? {
getStmt.reset()
getStmt.clearBindings()
getStmt.executeQuery().use { rs ->
if (!rs.next()) return null
return Cursor(createdAt = rs.getLong(0)!!, offset = rs.getLong(1)!!.toULong())
}
}
private fun save(cursor: Cursor) {
setStmt.reset()
setStmt.clearBindings()
setStmt.bindLong(1, cursor.createdAt)
setStmt.bindLong(2, cursor.offset.toLong())
setStmt.executeUpdate()
}
override fun close() {
getStmt.close()
setStmt.close()
if (ownsConnection) connection.close()
}
}
@@ -0,0 +1,46 @@
package pw.binom.agentik.cursor.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок для ksqlite-реализации [pw.binom.agentik.cursor.CursorHolder].
*
* Владеет одной таблицей `outbox_cursor` — ровно одна строка (`id = 1`) с
* персистентной позицией счётчика событий агента ([Cursor.createdAt] +
* next offset), см. [KsqliteCursorHolder].
*
* `user_version` как gate не используется (split-world: несколько модулей
* ставят его независимо) — [migrate] просто идемпотентно прогоняет DDL.
*/
object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 2
const val TABLE = "outbox_cursor"
const val COL_ID = "id"
/** Время создания счётчика (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_CREATED_AT INTEGER NOT NULL,
$COL_OFFSET INTEGER NOT NULL
);
"""
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
try {
conn.exec(ddl)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
}
@@ -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()
}
}
}