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
+1
View File
@@ -18,6 +18,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
api(project(":content-api"))
api(project(":cursor-api"))
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
@@ -14,48 +14,27 @@ import kotlin.time.Instant
* Никаких обновлений, никакого удаления (кроме каскадного вместе
* с ConversationStore.delete).
*
* ## Два способа адресации позиции
* - **по [Instant] `createdAt`** — legacy, «дай всё после даты»;
* - **по [MessageRecord.seq]** (монотонный per-agent offset) — протокол
* снапшотов: диапазон `afterSeq < seq <= upToSeq` даёт **конечное и
* стабильное** множество строк. Catch-up: `afterSeq = <курсор>, upToSeq = MAX`.
* Снапшот с курсором C: `afterSeq = -1, upToSeq = C`.
*
* [listFlowSeq] использует keyset-пагинацию (`seq > last`), а не `OFFSET` —
* иначе конкурентная вставка/удаление сдвигает окно и молча теряет строки.
* Адресация позиции — по [Instant] `createdAt` («дай всё после даты»).
* Курсора/`seq` здесь нет: монотонный `(эпоха, offset)` для удалённого
* resume и снапшотов живёт в sync-слое, который строит свой поток поверх
* журнала.
*/
interface JournalStore : AutoCloseable {
/** Legacy-страница по `createdAt > [after]`, `ORDER BY createdAt ASC` + `OFFSET`. */
/** Страница по `createdAt > [after]`, `ORDER BY createdAt ASC` + `OFFSET`. */
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/**
* Keyset-страница записей `[afterSeq] < seq <= [upToSeq]`, `ORDER BY seq ASC`.
*
* @param afterSeq нижняя эксклюзивная граница (для «с начала» — `-1`).
* @param upToSeq верхняя **инклюзивная** граница (для «без отсечки» —
* `Long.MAX_VALUE`).
*/
suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int): List<MessageRecord>
/** Сколько сообщений в диалоге [conversationId] всего. */
suspend fun count(conversationId: String): Long
/**
* Сколько сообщений создано **позже** [after] (строго `createdAt > after`).
* Legacy unread-бейдж по времени.
* Unread-бейдж по времени.
*/
suspend fun count(conversationId: String, after: Instant): Long
/**
* Сколько сообщений имеют `seq > [afterSeq]`. Cursor-версия unread-бейджа:
* `count(convId, afterSeq = lastSeenOffset)`.
*/
suspend fun count(conversationId: String, afterSeq: Long): Long
/**
* Cold-flow paging (legacy, по [Instant]). Default-реализация делает N+1
* round-trip.
* Cold-flow paging по [Instant]. Default-реализация делает N+1 round-trip.
*/
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
var offset = 0
@@ -68,26 +47,6 @@ interface JournalStore : AutoCloseable {
}
}
/**
* Cold-flow paging по `seq` (keyset). `cursor` растёт по мере эмиссии;
* `upToSeq` ограничивает сверху (снапшот с курсором).
*/
fun listFlowSeq(
conversationId: String,
afterSeq: Long = -1L,
upToSeq: Long = Long.MAX_VALUE,
pageSize: Int = PAGE_SIZE,
): Flow<MessageRecord> = flow {
var cursor = afterSeq
while (true) {
val page = list(conversationId, cursor, upToSeq, pageSize)
if (page.isEmpty()) return@flow
for (rec in page) emit(rec)
cursor = page.last().seq
if (page.size < pageSize) return@flow
}
}
companion object {
const val PAGE_SIZE = 100
}
@@ -10,28 +10,20 @@ import kotlin.time.Instant
/**
* Запись в таблице `message` (append-only audit).
*
* ## [seq] — курсор записи
* [seq] — **тот же монотонный per-agent offset**, что и `offset` соответствующего
* events-события (`OffsetSequencer.reserve()`). Writer резервирует offset и
* пишет строку с `seq = offset` **до** append'а события в outbox (инвариант
* «сначала состояние, потом событие»).
* Позиция записи в потоке задаётся [createdAt] (UTC). Для разрешения ничьих
* (несколько сообщений с одинаковым `Instant`) реализация добавляет вторичный
* ключ (`id`) при сортировке.
*
* Нужен для снапшота с «курсором»: клиент берёт `currentCursor() = C` и читает
* `listUpTo(convId, C)` — конечное, стабильное множество строк, отражающее
* состояние на момент C. Всё, что появится позже, имеет `seq > C` и приедет
* потоком событий.
*
* Значение по умолчанию `0L` — для legacy-записей и тестов; production-путь
* (`ConversationLoop` / `ToolDispatcher`) всегда выставляет реальный offset.
* Запись с `seq = 0` в снапшоте всегда «≤ C», поэтому попадает в снапшот и
* (если её событие ещё и в потоке) применяется дважды — идемпотентно, безвредно.
* **Курсора здесь нет.** Монотонный `(эпоха, offset)` и вся семантика
* удалённого доступа (снапшоты, resume, gap) — ответственность sync-слоя,
* который строит свой упорядоченный поток поверх журнала. Журнал — это просто
* durable история диалогов.
*/
@Serializable
sealed interface MessageRecord {
val id: String
val conversationId: String
val createdAt: Instant
val seq: Long
@Serializable
sealed interface Body : MessageRecord {
@@ -46,7 +38,6 @@ sealed interface MessageRecord {
override val content: List<Content>,
override val createdAt: Instant,
val context: MessageContext? = null,
override val seq: Long = 0L,
) : Body
@Serializable
@@ -63,7 +54,6 @@ sealed interface MessageRecord {
* их не раскрывает.
*/
val reasoning: String? = null,
override val seq: Long = 0L,
) : Body
@Serializable
@@ -75,7 +65,6 @@ sealed interface MessageRecord {
val toolTitle: String?,
val toolArgsJson: String,
override val createdAt: Instant,
override val seq: Long = 0L,
) : MessageRecord
@Serializable
@@ -94,7 +83,6 @@ sealed interface MessageRecord {
val toolName: String? = null,
val result: String?,
override val createdAt: Instant,
override val seq: Long = 0L,
) : MessageRecord
@Serializable
@@ -105,6 +93,5 @@ sealed interface MessageRecord {
val message: String,
val code: String?,
override val createdAt: Instant,
override val seq: Long = 0L,
) : MessageRecord
}