Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.

Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.

outbox-api:
  - Cursor (offset: Long) — курсор в журнале событий агента.
  - OffsetSequencer — интерфейс резервирования уникального offset.
  - CursorStore — персистентное хранилище текущего offset'а.
  - PersistentOffsetSequencer — декоратор над любым OutboxStore,
    обновляет CursorStore на каждом append (atomic transaction).
  - OutboxGapException — клиент запросил after < earliestCursor() →
    сервер не может удовлетворить, клиент обязан делать full resync.
  - DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
    был общим sealed-типом, теперь это термин из спеки).
  - MutableOutboxStore и OutboxStore теперь читают offset через
    CursorStore вместо in-memory counter'а.

outbox-inmemory:
  - InMemoryOffsetSequencer — для тестов и dev-режима.
  - InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.

outbox-ksqlite (новый модуль):
  - KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
    offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
  - KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).

proto + server:
  - Snapshot.proto — server-state snapshot endpoint для клиентов,
    которым нужна полная материализация (использование TBD).
  - Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).

journal-ksqlite:
  - KsqliteJournalStore.listFlow/append — без изменений по API,
    нотации минимальные (codecs).

standalone:
  - DurableLog (бывший ChatAgent-orchestration) — атомарный commit
    события в OutboxStore + PersistentOffsetSequencer + materialization
    (через Reducer) одной транзакцией.
  - SqliteStores — добавляет KsqliteCursorStore в bundle, единая
    shared-connection для всех ksqlite-сторов standalone-агента.
  - ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
    ToolDispatcher — переход на новые абстракции.
  - standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
    включено (раньше было закомментировано — модуль только создавался).

client:
  - AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
    ReconnectingOutbox — используют Cursor через transport API.
  - client/README.md — синхронизирован с новым поведением (468 строк
    diff — это в основном оформление и примеры).

kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).

SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.

Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.

sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
@@ -39,6 +39,20 @@ fun Route.journalRoutes(
route(path) {
get("/conversations/{id}/messages") {
val id = call.parameters["id"]!!
// Cursor-режим (keyset по seq): `?afterSeq=&upToSeq=&limit=`.
val afterSeqRaw = call.request.queryParameters["afterSeq"]
if (afterSeqRaw != null) {
val afterSeq = afterSeqRaw.toLongOrNull()
if (afterSeq == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid 'afterSeq' (expected Long)")
return@get
}
val upToSeq = call.request.queryParameters["upToSeq"]?.toLongOrNull() ?: Long.MAX_VALUE
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
call.respond(journal.list(id, afterSeq, upToSeq, limit))
return@get
}
// Legacy-режим (по `createdAt`): `?after=&offset=&limit=`.
val after = call.parseAfter() ?: return@get
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
@@ -46,11 +60,18 @@ fun Route.journalRoutes(
}
get("/conversations/{id}/count") {
val id = call.parameters["id"]!!
val after = call.parseAfter()
val count = if (after == null) {
journal.count(id)
// Cursor-режим: `?afterSeq=`.
val afterSeqRaw = call.request.queryParameters["afterSeq"]
val count = if (afterSeqRaw != null) {
val afterSeq = afterSeqRaw.toLongOrNull()
if (afterSeq == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid 'afterSeq' (expected Long)")
return@get
}
journal.count(id, afterSeq)
} else {
journal.count(id, after)
val after = call.parseAfter()
if (after == null) journal.count(id) else journal.count(id, after)
}
call.respond(CountResponse(count = count))
}
@@ -1,11 +1,29 @@
package pw.binom.agentik.server
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
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.outbox.OutboxStore
/**
* Тело ответа `410 Gone`: клиентский курсор вне retention'а (или чужая эпоха).
*
* Не ошибка протокола — сигнал сделать полный resync: взять снапшот
* (`/snapshot`, `/conversations/{id}/snapshot`) и подписаться с его курсора.
*/
@Serializable
data class OutboxGapResponse(
val requested: Cursor?,
val oldest: Cursor,
val current: Cursor,
)
/**
* HTTP-фасад для [OutboxStore] (bounded-tail live event stream агента).
*
@@ -15,16 +33,20 @@ import pw.binom.agentik.outbox.OutboxStore
* итоговый URL = `{path агента}/outbox/...`.
*
* **Endpoint'ы под `{path}/outbox`:**
* - `GET /events?after=` — SSE (catchup + live) в формате `data: <json>\n\n`,
* где `<json>` — сериализованный [CommonEvent].
* Семантика `after` идентична [OutboxStore.events]:
* - `after` отсутствует → только live (события с момента подписки).
* - `after` задан → сначала catchup всех буферизованных событий с
* `date > after`, потом live.
* - `GET /events?epoch=&offset=` — SSE (catchup + live) в формате
* `data: <json>\n\n`, где `<json>` — сериализованный [CommonEvent].
* - `epoch`/`offset` отсутствуют → только live (события с момента подписки).
* - оба заданы → catchup всех буферизованных событий с `offset > offset`,
* затем live. См. [OutboxStore.events].
*
* **Покрытие:** outbox — это короткий bounded tail с auto-TTL. Для событий
* старше буфера клиент должен идти в `/journal/conversations/{id}/messages`
* (полный audit log), см. KDoc [OutboxStore].
* **Gap:** если курсор старше [OutboxStore.oldestCursor] (retention) или
* принадлежит другой эпохе → `410 Gone` c [OutboxGapResponse]. Проверка
* делается **до** старта SSE (иначе заголовки уже отправлены), тем же
* snapshot-чтением `oldestCursor()/currentCursor()`; гонка с конкурентной
* эвикцией закрыта внутренним lock'ом store'а на момент подписки.
*
* **Покрытие:** outbox — короткий bounded tail. Для событий старше буфера
* клиент идёт в снапшот (`/snapshot`, `/conversations/{id}/snapshot`).
*
* **Read-only:** [OutboxStore] не имеет `append` — запись только через
* writer-референс, который ChatAgent держит внутри (тип `MutableOutboxStore`,
@@ -36,9 +58,60 @@ fun Route.outboxRoutes(
) {
route(path) {
get("/events") {
val after = call.parseAfter() ?: return@get
// SSE-стрим: catchup (если `after` != DISTANT_PAST) + live tail.
val after = call.parseCursor() ?: return@get
if (!call.requireCursorAlive(outbox, after)) return@get
call.streamJsonSse(outbox.events(after), CommonEvent.serializer())
}
get("/cursor") {
call.respond(
OutboxCursorResponse(
current = outbox.currentCursor(),
oldest = outbox.oldestCursor(),
)
)
}
}
}
/** Тело `GET {path}/outbox/cursor`. */
@Serializable
data class OutboxCursorResponse(
val current: Cursor,
val oldest: Cursor,
)
/**
* Валидирует курсор перед подпиской. `null` (live-only) — всегда ок.
* Иначе: несовпадение эпохи, `offset < oldest` или `offset > current`
* → отвечает `410 Gone` и возвращает `false`.
*/
internal suspend fun ApplicationCall.requireCursorAlive(outbox: OutboxStore, after: Cursor?): Boolean {
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) {
respond(
HttpStatusCode.Gone,
OutboxGapResponse(requested = after, oldest = oldest, current = current),
)
return false
}
return true
}
/**
* Парсит курсор из query-параметров `epoch` + `offset`.
* Оба отсутствуют → `null` (live-only). Задан только один или невалидный
* `offset` → `400` и `null`.
*/
internal suspend fun ApplicationCall.parseCursor(): Cursor? {
val epoch = request.queryParameters["epoch"]
val offsetRaw = request.queryParameters["offset"]
if (epoch == null && offsetRaw == null) return null
val offset = offsetRaw?.toLongOrNull()
if (epoch == null || offset == null) {
respond(HttpStatusCode.BadRequest, "Invalid cursor (expected 'epoch' + 'offset' query params)")
return null
}
return Cursor(epoch = epoch, offset = offset)
}
@@ -24,7 +24,7 @@ import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.DurableEvent
import pw.binom.agentik.outbox.OnlineEvent
import kotlin.time.Instant
@@ -161,11 +161,15 @@ internal fun Route.agentikRoutes(agent: Agent) {
call.respond(HttpStatusCode.NotFound)
return@get
}
val after = call.parseAfter() ?: return@get
val after = call.parseCursor() ?: return@get
if (!call.requireCursorAlive(agent.outbox, after)) return@get
// Live-источник событий — `OutboxStore` (единая точка истины);
// разворачиваем `CommonEvent.Conversation` → `Event` для совместимости
// wire-формата (клиент десериализует как `Event`, не как `CommonEvent.Conversation`).
call.streamJsonSse(agent.outbox.conversationEvents(after, id).map { it.event }, Event.serializer())
call.streamJsonSse(
agent.outbox.conversationEvents(after = after, conversationId = id),
CommonEvent.Conversation.serializer(),
)
}
/**
@@ -199,13 +203,14 @@ internal fun Route.agentikRoutes(agent: Agent) {
}
get("/events") {
val after = call.parseAfter() ?: return@get
val after = call.parseCursor() ?: return@get
if (!call.requireCursorAlive(agent.outbox, after)) return@get
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
// распаковываем .event для обратной совместимости с прежним
// форматом (когда был Agent.events(): Flow<AgentEvent>).
call.streamJsonSse(
agent.outbox.agentEvents(after).map { it.event },
AgentEvent.serializer(),
agent.outbox.agentEvents(after),
CommonEvent.Agent.serializer(),
)
}
@@ -215,9 +220,30 @@ internal fun Route.agentikRoutes(agent: Agent) {
* Для UI достаточно `/events` + `/conversations/{id}/events`.
*/
get("/events/all") {
val after = call.parseAfter() ?: return@get
val after = call.parseCursor() ?: return@get
if (!call.requireCursorAlive(agent.outbox, after)) return@get
call.streamJsonSse(agent.outbox.events(after), CommonEvent.serializer())
}
// ---- Snapshot (resync) ----
/**
* `GET /snapshot` — полное состояние агента (список диалогов) + его курсор.
* Клиент вызывает после `410 Gone`, чтобы сбросить локальный кэш и
* возобновить инкрементальную подписку с [pw.binom.agentik.proto.ConversationsSnapshot.cursor].
*/
get("/snapshot") {
call.respond(agent.conversationsSnapshot())
}
/**
* `GET /conversations/{id}/snapshot` — сообщения диалога (отсечены
* `seq <= cursor.offset`) + курсор. Парный к `GET /snapshot` для per-chat resync'а.
*/
get("/conversations/{id}/snapshot") {
val id = call.parameters["id"]!!
call.respond(agent.chatSnapshot(id))
}
}
// ---------- helpers ----------
@@ -24,6 +24,9 @@ import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineOutbox
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.proto.AgentInfo
import pw.binom.agentik.proto.Conversation
import kotlin.test.AfterTest
@@ -63,16 +66,19 @@ class AgentInfoRouteTest {
)
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L
override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L
override suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {}
}
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
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 fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -81,6 +87,11 @@ class AgentInfoRouteTest {
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {}
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
@@ -133,16 +144,19 @@ class AgentInfoRouteTest {
override val info: AgentInfo = AgentInfo(name = "agentik")
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L
override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L
override suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {}
}
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
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 fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -151,6 +165,11 @@ class AgentInfoRouteTest {
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {}
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
@@ -20,6 +20,9 @@ import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineOutbox
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.proto.AgentInfo
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
@@ -47,16 +50,19 @@ class BearerTokenTest {
) : Agent {
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L
override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L
override suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {}
}
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
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 fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -65,6 +71,11 @@ class BearerTokenTest {
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {}
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
@@ -23,6 +23,9 @@ import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineOutbox
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.proto.AgentInfo
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -161,17 +164,27 @@ class ConversationRoutesTest {
override val info: AgentInfo = AgentInfo(name = "test")
override val journal: JournalStore = js
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) =
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 earliestEventDate(): Instant = Instant.DISTANT_PAST
override suspend fun currentCursor(): Cursor = Cursor("test", 0L)
override suspend fun oldestCursor(): Cursor = Cursor("test", 0L)
override fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
override val conversationStore: ConversationStore = cs
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
TODO("not used")
@@ -27,6 +27,9 @@ import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineOutbox
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.proto.AgentInfo
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -182,10 +185,11 @@ class JournalRoutesCountTest {
override val info: AgentInfo = AgentInfo(name = "test")
override val journal: JournalStore = js
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
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 fun close() {}
}
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -195,6 +199,15 @@ class JournalRoutesCountTest {
override fun close() {}
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L))
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
TODO("not used")
override suspend fun getConversation(id: String): pw.binom.agentik.proto.Conversation? = null
@@ -0,0 +1,162 @@
package pw.binom.agentik.server
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationRecord
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.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.ChatSnapshot
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.ConversationsSnapshot
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* HTTP-контракт курсорного протокола:
* - `GET /snapshot` → состояние + курсор (JSON);
* - `GET /outbox/events?epoch=&offset=` → `410 Gone` (со `oldest`/`current`)
* при мёртвом курсоре, `400` при невалидном, до старта SSE;
* - `GET /outbox/cursor` → текущий/старейший курсор.
*/
class SnapshotRouteTest {
private lateinit var server: EmbeddedServer<*, *>
private var port: Int = 0
private val epoch = "epoch-1"
private class FakeOutbox(
private val epoch: String,
private val oldest: Long,
private val current: Long,
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 fun close() {}
}
private fun fakeAgent(): Agent = object : Agent {
override val id: String = "test"
override val info: AgentInfo = AgentInfo(name = "test")
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) =
emptyList<MessageRecord>()
override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) =
emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L
override suspend fun count(conversationId: String, after: Instant): Long = 0L
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 onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String) = null
override suspend fun list(offset: Int, limit: Int) = listOf(
ConversationRecord(
id = "conv-1",
title = "First",
isTemporal = false,
createdAt = Instant.fromEpochMilliseconds(0),
updatedAt = Instant.fromEpochMilliseconds(0),
),
)
override fun close() {}
}
override suspend fun conversationsSnapshot(): ConversationsSnapshot =
ConversationsSnapshot(conversations = conversationStore.list(0, 100), cursor = Cursor(epoch, 11L))
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot =
ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(epoch, 11L))
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
override suspend fun renameConversation(id: String, title: String?): Instant? = null
override fun close() {}
}
@BeforeTest
fun setup() {
server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
routing { agentikAgent(fakeAgent(), path = "/agentik", token = null) }
}.start(wait = false)
port = runBlocking { server.engine.resolvedConnectors()[0].port }
}
@AfterTest
fun tearDown() {
server.stop(100, 200)
}
private fun client() = HttpClient(CIO)
@Test
fun `snapshot endpoint returns conversations plus cursor`() = runBlocking {
val response = client().get("http://127.0.0.1:$port/agentik/snapshot")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue("conv-1" in body, "body=$body")
assertTrue("\"cursor\"" in body, "body=$body")
assertTrue("\"$epoch\"" in body, "body=$body")
assertTrue("\"offset\":11" in body, "body=$body")
}
@Test
fun `stale cursor returns 410 with oldest and current`() = runBlocking {
val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=$epoch&offset=0")
assertEquals(HttpStatusCode.Gone, response.status)
val body = response.bodyAsText()
assertTrue("\"oldest\"" in body, "body=$body")
assertTrue("\"offset\":5" in body, "body=$body")
assertTrue("\"offset\":11" in body, "body=$body")
}
@Test
fun `foreign epoch returns 410`() = runBlocking {
val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=other&offset=6")
assertEquals(HttpStatusCode.Gone, response.status)
}
@Test
fun `invalid cursor returns 400`() = runBlocking {
val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=$epoch")
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun `cursor endpoint returns current and oldest`() = runBlocking {
val response = client().get("http://127.0.0.1:$port/agentik/outbox/cursor")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue("\"current\"" in body && "\"oldest\"" in body, "body=$body")
}
}