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