refactor: migrate EventStore and MessageStore to :outbox-api and :journal-api
ci / JVM build + tests (push) Failing after 1m9s
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:
@@ -24,6 +24,16 @@ kotlin {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":proto"))
|
||||
|
||||
// Read-only storage handles, которые HTTP-фасад выставляет наружу
|
||||
// под {path}/journal/* и {path}/outbox/*. Типы JournalStore/
|
||||
// OutboxStore фигурируют в сигнатурах internal-функций
|
||||
// journalRoutes/outboxRoutes, поэтому нужны в compile classpath.
|
||||
// Транзитивные api-висимости :proto (:journal-api, :outbox-api)
|
||||
// не доходят до :server из-за implementation(:proto), поэтому
|
||||
// объявляем напрямую.
|
||||
implementation(project(":journal-api"))
|
||||
implementation(project(":outbox-api"))
|
||||
|
||||
// Ktor (без engine — engine подключает потребитель, см. :standalone).
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.content.negotiation)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user