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
@@ -8,7 +8,7 @@ import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.outbox.OutboxStore
/**
@@ -89,7 +89,7 @@ internal suspend fun ApplicationCall.requireCursorAlive(outbox: OutboxStore, aft
if (after == null) return true
val oldest = outbox.oldestCursor()
val current = outbox.currentCursor()
if (after.epoch != oldest.epoch || after.offset < oldest.offset || after.offset > current.offset) {
if (after.createdAt != oldest.createdAt || after.offset < oldest.offset || after.offset > current.offset) {
respond(
HttpStatusCode.Gone,
OutboxGapResponse(requested = after, oldest = oldest, current = current),
@@ -101,17 +101,20 @@ internal suspend fun ApplicationCall.requireCursorAlive(outbox: OutboxStore, aft
/**
* Парсит курсор из query-параметров `epoch` + `offset`.
* Оба отсутствуют → `null` (live-only). Задан только один или невалидный
* `offset` → `400` и `null`.
* Оба отсутствуют → `null` (live-only). Задан только один или невалидные
* значения → `400` и `null`.
*
* `epoch` — [Cursor.createdAt] (Unix ms), `offset` — [Cursor.offset] (ULong).
*/
internal suspend fun ApplicationCall.parseCursor(): Cursor? {
val epoch = request.queryParameters["epoch"]
val epochRaw = request.queryParameters["epoch"]
val offsetRaw = request.queryParameters["offset"]
if (epoch == null && offsetRaw == null) return null
val offset = offsetRaw?.toLongOrNull()
if (epochRaw == null && offsetRaw == null) return null
val epoch = epochRaw?.toLongOrNull()
val offset = offsetRaw?.toULongOrNull()
if (epoch == null || offset == null) {
respond(HttpStatusCode.BadRequest, "Invalid cursor (expected 'epoch' + 'offset' query params)")
return null
}
return Cursor(epoch = epoch, offset = offset)
return Cursor(createdAt = epoch, offset = offset)
}
@@ -26,7 +26,7 @@ import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.ChatSnapshot
import pw.binom.agentik.proto.ConversationsSnapshot
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Conversation
import kotlin.test.AfterTest
@@ -77,8 +77,8 @@ class AgentInfoRouteTest {
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -88,9 +88,9 @@ class AgentInfoRouteTest {
override fun close() {}
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null
@@ -155,8 +155,8 @@ class AgentInfoRouteTest {
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -166,9 +166,9 @@ class AgentInfoRouteTest {
override fun close() {}
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null
@@ -22,7 +22,7 @@ import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.ChatSnapshot
import pw.binom.agentik.proto.ConversationsSnapshot
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
@@ -61,8 +61,8 @@ class BearerTokenTest {
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -72,9 +72,9 @@ class BearerTokenTest {
override fun close() {}
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
override suspend fun getConversation(id: String): Conversation? = null
@@ -25,7 +25,7 @@ import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.ChatSnapshot
import pw.binom.agentik.proto.ConversationsSnapshot
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.proto.AgentInfo
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -169,8 +169,8 @@ class ConversationRoutesTest {
override fun conversationEvents(after: Cursor?, conversationId: String?) =
emptyFlow<CommonEvent.Conversation>()
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -178,11 +178,11 @@ class ConversationRoutesTest {
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
@@ -29,7 +29,7 @@ import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.ChatSnapshot
import pw.binom.agentik.proto.ConversationsSnapshot
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.proto.AgentInfo
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -80,6 +80,7 @@ class JournalRoutesCountTest {
conversationId = convId,
content = listOf(JContent.Text(text)),
createdAt = at,
cursor = pw.binom.agentik.cursor.Cursor(createdAt = 0L, offset = 0u),
),
)
}
@@ -188,8 +189,8 @@ class JournalRoutesCountTest {
override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
override suspend fun currentCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = 0L, offset = 0u)
override fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -201,11 +202,11 @@ class JournalRoutesCountTest {
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = 0L, offset = 0u))
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
@@ -18,7 +18,7 @@ import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
@@ -45,20 +45,20 @@ class SnapshotRouteTest {
private lateinit var server: EmbeddedServer<*, *>
private var port: Int = 0
private val epoch = "epoch-1"
private val epoch = 123456789L
private class FakeOutbox(
private val epoch: String,
private val oldest: Long,
private val current: Long,
private val epoch: Long,
private val oldest: ULong,
private val current: ULong,
private val replay: List<CommonEvent> = emptyList(),
) : OutboxStore {
override fun events(after: Cursor?): Flow<CommonEvent> = flowOf(*replay.toTypedArray())
override fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> = emptyFlow()
override fun conversationEvents(after: Cursor?, conversationId: String?): Flow<CommonEvent.Conversation> =
emptyFlow()
override suspend fun currentCursor(): Cursor = Cursor(epoch, current)
override suspend fun oldestCursor(): Cursor = Cursor(epoch, oldest)
override suspend fun currentCursor(): Cursor = Cursor(createdAt = epoch, offset = current)
override suspend fun oldestCursor(): Cursor = Cursor(createdAt = epoch, offset = oldest)
override fun close() {}
}
@@ -75,7 +75,7 @@ class SnapshotRouteTest {
override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L
override fun close() {}
}
override val outbox: OutboxStore = FakeOutbox(epoch = epoch, oldest = 5L, current = 11L)
override val outbox: OutboxStore = FakeOutbox(epoch = epoch, oldest = 5u, current = 11u)
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String) = null
@@ -92,10 +92,10 @@ class SnapshotRouteTest {
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = conversationStore.list(0, 100), cursor = Cursor(epoch, 11L))
ConversationsSnapshot(conversations = conversationStore.list(0, 100), cursor = Cursor(createdAt = epoch, offset = 11u))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(epoch, 11L))
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(createdAt = epoch, offset = 11u))
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null
@@ -126,7 +126,7 @@ class SnapshotRouteTest {
val body = response.bodyAsText()
assertTrue("conv-1" in body, "body=$body")
assertTrue("\"cursor\"" in body, "body=$body")
assertTrue("\"$epoch\"" in body, "body=$body")
assertTrue("\"createdAt\":$epoch" in body, "body=$body")
assertTrue("\"offset\":11" in body, "body=$body")
}
@@ -142,7 +142,7 @@ class SnapshotRouteTest {
@Test
fun `foreign epoch returns 410`() = runBlocking {
val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=other&offset=6")
val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=999&offset=6")
assertEquals(HttpStatusCode.Gone, response.status)
}