Добавляет 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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user