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
@@ -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<IllegalArgumentException> {
store.push(
MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("b"))),
cursorAt(5u),
)
}
assertFailsWith<IllegalArgumentException> {
store.push(
MessageEvent.AddUserMessage("m3", t0, "c", listOf(Content.Text("c"))),
cursorAt(4u),
)
}
// Проекция откатилась: только первое сообщение, курсор на месте.
assertEquals(
listOf<MessageRecord>(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>(MessageRecord.Conversation("c1", "First")),
store.getState(created).toList(),
)
val renamed = nextCursor()
store.push(MessageEvent.RenameConversation("c1", "Renamed"), renamed)
assertEquals(
listOf<MessageRecord>(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>(
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>(
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>(
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>(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>(
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())
}
}
}