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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user