refactor: migrate EventStore and MessageStore to :outbox-api and :journal-api
ci / JVM build + tests (push) Failing after 1m9s

- Replaced usages of `:message-store-api` and `:working-memory-api` with `:journal-api`, `:outbox-api`, and `:context-api`.
- Deprecated legacy `EventStore` and `MessageStore` interfaces, added `typealias` for backward compatibility.
- Updated imports across all modules with references to `:journal-api` and `:outbox-api`.
- Introduced `journalRoutes` and `outboxRoutes` in `:server` for audit log and live event stream endpoints.
- Adjusted `Agent` to expose read-only `journal` and `outbox` stores for improved modularity and clarity.
- Removed legacy Event and AgentEvent definitions from `:proto`, migrated to `:outbox-api`.
- Storage-related modules have been updated to support the new APIs consistently.
This commit is contained in:
2026-09-21 02:38:40 +03:00
parent 499db812ef
commit aef5083801
64 changed files with 978 additions and 960 deletions
@@ -0,0 +1,44 @@
package pw.binom.agentik.server
import io.ktor.http.HttpStatusCode
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 pw.binom.agentik.journal.JournalStore
/**
* HTTP-фасад для [JournalStore] (append-only audit log сообщений диалога).
*
* **Основа:** функция-продолжение для [Route.agentikAgent] — внутри неё уже
* создан роут под `{path}` агента. [journalRoutes] добавляет под-prefix
* [path] (по умолчанию `"/journal"`) к **этому же** родительскому роуту,
* итоговый URL = `{path агента}/journal/...`.
*
* **Endpoint'ы под `{path}/journal`:**
* - `GET /conversations/{id}/messages?after=&offset=&limit=` — список raw
* [pw.binom.agentik.journal.MessageRecord] (все типы: UserMessage /
* AssistantMessage / ToolCall / ToolResult / Error). В отличие от
* `GET /conversations/{id}/messages` в [agentikRoutes] (который отдаёт
* project'нутые proto-[pw.binom.agentik.proto.Message]), здесь клиент
* получает полный transcript с tool-call/tool-result/error payload-ами,
* turn-tokens и context-метаданными.
*
* **Read-only:** [JournalStore] не имеет `append` — запись только через
* writer-референс, который ChatAgent держит внутри (тип `MutableJournalStore`,
* не выставлен наружу через [pw.binom.agentik.proto.Agent]).
*/
fun Route.journalRoutes(
journal: JournalStore,
path: String = "/journal",
) {
route(path) {
get("/conversations/{id}/messages") {
val id = call.parameters["id"]!!
val after = call.parseAfter() ?: return@get
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
call.respond(journal.list(id, after, offset, limit))
}
}
}
@@ -20,25 +20,30 @@ import pw.binom.agentik.proto.Agent
* }.start(wait = true)
* ```
*
* Под префиксом [path] монтируются:
* - `POST /conversations` — создать диалог
* - `GET /conversations` — список
* - `GET /conversations/{id}` — один диалог
* - `PATCH /conversations/{id}` — переименовать
* - `DELETE /conversations/{id}` — удалить
* - `POST /conversations/{id}/messages` — `send` (202 Accepted)
* - `POST /conversations/{id}/interrupt` — `interrupt`
* - `GET /conversations/{id}/messages` — история
* - `GET /conversations/{id}/events` — SSE: события хода (catchup + live)
* - `GET /events` — SSE: события агента (catchup + live)
* - `GET /events/all` — SSE: всё в одном потоке
* - `GET /health` — `"ok"`
* **Все** дочерние фасады ([agentikRoutes] / [journalRoutes] / [outboxRoutes])
* монтируются внутри `{path}` — внутренний `route(path)` создаёт родительский
* роут агента, и storage-фасады добавляют свои под-prefix'ы **к этому же**
* роуту, а не к корню. Итоговая раскладка:
*
* Event-эндпоинты сами по себе дают catchup + live в одном Flow: клиент
* передаёт `?after=<ISO8601>`, сервер сначала отдаёт буферизованные события
* с `date > after`, потом переключается на live. Отдельный replay-endpoint
* не нужен — для событий старше буфера клиент должен идти в
* `:message-log-api` (полный audit log).
* ```
* POST {path}/conversations
* GET {path}/conversations
* GET {path}/conversations/{id}
* PATCH {path}/conversations/{id}
* DELETE {path}/conversations/{id}
* POST {path}/conversations/{id}/messages
* POST {path}/conversations/{id}/interrupt
* GET {path}/conversations/{id}/messages
* GET {path}/conversations/{id}/events (SSE: события хода)
* GET {path}/events (SSE: agent-level events)
* GET {path}/health
* GET {path}/journal/conversations/{id}/messages
* GET {path}/outbox/events (SSE: outbox catchup+live)
* ```
*
* Под-prefix'ы `/journal` и `/outbox` выбраны чтобы не пересекаться с
* существующим `/events` (agent-level SSE) и
* `/conversations/{id}/messages` (proto-Message'ы, не raw records).
*/
fun Route.agentikAgent(
agent: Agent,
@@ -54,6 +59,11 @@ fun Route.agentikAgent(
this.token = token
}
}
// Proto-роуты: диалоги, send/interrupt, events (agent-level).
agentikRoutes(agent)
// Storage-фасады: те же `this` (роут агента), свои под-prefix'ы.
journalRoutes(agent.journal)
outboxRoutes(agent.outbox)
}
}
@@ -0,0 +1,44 @@
package pw.binom.agentik.server
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.CommonEvent
/**
* HTTP-фасад для [OutboxStore] (bounded-tail live event stream агента).
*
* **Основа:** функция-продолжение для [Route.agentikAgent] — внутри неё уже
* создан роут под `{path}` агента. [outboxRoutes] добавляет под-prefix
* [path] (по умолчанию `"/outbox"`) к **этому же** родительскому роуту,
* итоговый 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.
*
* **Покрытие:** outbox — это короткий bounded tail с auto-TTL. Для событий
* старше буфера клиент должен идти в `/journal/conversations/{id}/messages`
* (полный audit log), см. KDoc [OutboxStore].
*
* **Read-only:** [OutboxStore] не имеет `append` — запись только через
* writer-референс, который ChatAgent держит внутри (тип `MutableOutboxStore`,
* не выставлен наружу через [pw.binom.agentik.proto.Agent]).
*/
fun Route.outboxRoutes(
outbox: OutboxStore,
path: String = "/outbox",
) {
route(path) {
get("/events") {
val after = call.parseAfter() ?: return@get
// SSE-стрим: catchup (если `after` != DISTANT_PAST) + live tail.
call.streamJsonSse(outbox.events(after), CommonEvent.serializer())
}
}
}
@@ -16,6 +16,7 @@ import io.ktor.server.routing.post
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import pw.binom.agentik.proto.Agent
@@ -132,7 +133,13 @@ internal fun Route.agentikRoutes(agent: Agent) {
get("/events") {
val after = call.parseAfter() ?: return@get
call.streamJsonSse(agent.events(after), AgentEvent.serializer())
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
// распаковываем .event для обратной совместимости с прежним
// форматом (когда был Agent.events(): Flow<AgentEvent>).
call.streamJsonSse(
agent.outbox.agentEvents(after).map { it.event },
AgentEvent.serializer(),
)
}
/**
@@ -142,7 +149,7 @@ internal fun Route.agentikRoutes(agent: Agent) {
*/
get("/events/all") {
val after = call.parseAfter() ?: return@get
call.streamJsonSse(agent.allEvents(after), CommonEvent.serializer())
call.streamJsonSse(agent.outbox.events(after), CommonEvent.serializer())
}
}
@@ -152,7 +159,7 @@ internal fun Route.agentikRoutes(agent: Agent) {
* Парсит query-параметр `after` как ISO-8601 [Instant]. Отсутствие = [Instant.DISTANT_PAST].
* При невалидном значении отвечает 400 и возвращает `null`.
*/
private suspend fun ApplicationCall.parseAfter(): Instant? {
internal suspend fun ApplicationCall.parseAfter(): Instant? {
val raw = request.queryParameters["after"]
if (raw == null) return Instant.DISTANT_PAST
return try {
@@ -163,7 +170,7 @@ private suspend fun ApplicationCall.parseAfter(): Instant? {
}
}
private suspend fun <T> ApplicationCall.streamJsonSse(
internal suspend fun <T> ApplicationCall.streamJsonSse(
flow: Flow<T>,
serializer: KSerializer<T>,
json: Json = agentikJson,
@@ -11,16 +11,17 @@ 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.runBlocking
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.CommonEvent
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* Тесты route-scoped плагина [BearerTokenPlugin]:
@@ -30,13 +31,32 @@ import kotlin.time.Instant
*/
class BearerTokenTest {
private class FakeAgent(override val id: String = "test") : Agent {
/**
* Stub-реализации storage-handle для теста bearer-токена — контент не
* используется, тесты проверяют только что endpoint'ы закрыты/открыты
* по токену. Storage routes регистрируются сразу в [agentikAgent] и
* вычитывают [journal]/[outbox] eagerly, поэтому возвращаем noop-реализации
* (а не `error("...")`), иначе старт сервера валится на инициализации.
*/
private class FakeAgent(
override val id: String = "test",
) : Agent {
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
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 close() {}
}
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
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = emptyList()
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
override fun allEvents(after: Instant): Flow<CommonEvent> = emptyFlow()
}
private suspend fun startServer(token: String?): Pair<EmbeddedServer<*, *>, Int> {