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:
@@ -0,0 +1,33 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// KMP-реализация MutableMessageStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
|
||||
//
|
||||
// Цели сборки (параллельны :storage-ksqlite):
|
||||
// - jvm() — основная, тесты гоняются здесь (in-memory DB без файла)
|
||||
// - linuxX64() / mingwX64() — smoke-проверка что KMP реально KMP
|
||||
//
|
||||
// Apple targets невозможно собрать на Linux — Kotlin Multiplatform plugin auto-disables их.
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":message-log-api"))
|
||||
|
||||
implementation("pw.binom.db:ksqlite:0.1.0")
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package pw.binom.agentik.messageLog.ksqlite
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.agentik.messageLog.MessageRecord
|
||||
import pw.binom.agentik.messageLog.MutableMessageStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [MutableMessageStore]. Схема таблицы `message` повторяет
|
||||
* [pw.binom.agentik.storage.sqlite.SqliteMessageStore] для совместимости данных.
|
||||
*
|
||||
* Все четыре [SQLitePreparedStatement] компилируются один раз в конструкторе и
|
||||
* переиспользуются: bind → execute → reset (cursor) + clearBindings (memory)
|
||||
* перед следующим вызовом. Это устраняет per-call overhead на sqlite3_prepare_v2.
|
||||
*
|
||||
* encoding helpers (`encodeRecord`/`toMessageRecord`/`CallPayload`/...) — копия
|
||||
* `:storage-sqlite`, живут в этом же модуле. См. [MessageCodecs].
|
||||
*
|
||||
* **Threading**: ksqlite документирует соединение как single-threaded by convention
|
||||
* (sqlite3 prepared statements safe to use serially from any thread). Мы сериализуем
|
||||
* доступ через [Mutex] — операции вызываются на [Dispatchers.Default] и поток
|
||||
* между вызовами может меняться; Mutex гарантирует что в любой момент времени
|
||||
* только одна корутина использует statement.
|
||||
*
|
||||
* `clear(conversationId)` — каскадный helper, вызывается из
|
||||
* [pw.binom.agentik.storage.ksqlite.KsqliteConversationStore.delete].
|
||||
* Не часть публичного [MutableMessageStore] API (audit log append-only);
|
||||
* экспортируется через lambda в [pw.binom.agentik.storage.ksqlite.KsqliteStores.assemble].
|
||||
*/
|
||||
class KsqliteMessageStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
) : MutableMessageStore {
|
||||
|
||||
/**
|
||||
* Основной конструктор — принимает **уже открытое** [SQLiteConnection].
|
||||
* Соединение остаётся под управлением вызывающего (например, [pw.binom.agentik.storage.ksqlite.KsqliteStores.assemble]
|
||||
* закрывает его сам после [close] всех своих store'ов).
|
||||
*/
|
||||
constructor(connection: SQLiteConnection) : this(connection, ownsConnection = false)
|
||||
|
||||
/**
|
||||
* Convenience-конструктор для standalone-сценариев: открывает файл-БД
|
||||
* (`SQLiteConnection.open(path)`) и **сам закрывает её в [close]**.
|
||||
* Не использовать если соединение шарится с другими store'ами —
|
||||
* двойной [SQLiteConnection.close] не идемпотентен.
|
||||
*/
|
||||
constructor(path: String) : this(SQLiteConnection.open(path), ownsConnection = true)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"INSERT INTO message (id, conversation_id, kind, payload_json, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT id, conversation_id, kind, payload_json, created_at FROM message " +
|
||||
"WHERE conversation_id = ? AND created_at > ? ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?"
|
||||
)
|
||||
private val deleteStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM message WHERE conversation_id = ?"
|
||||
)
|
||||
|
||||
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val (kind, payload) = encodeRecord(record)
|
||||
insertStmt.execDml(
|
||||
record.id, record.conversationId, kind, payload, record.createdAt.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
after: Instant,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
): List<MessageRecord> = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
listStmt.runQuery(conversationId, after.toEpochMilliseconds(), limit.toLong(), offset.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
insertStmt.close()
|
||||
listStmt.close()
|
||||
deleteStmt.close()
|
||||
if (ownsConnection) connection.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Каскадный clear всех сообщений диалога — вызывается из
|
||||
* [pw.binom.agentik.storage.ksqlite.KsqliteConversationStore.delete].
|
||||
*
|
||||
* Публичный (не internal) чтобы `:storage-ksqlite` мог передать его как lambda.
|
||||
* Не часть [MutableMessageStore] — audit log append-only.
|
||||
*/
|
||||
suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
deleteStmt.execDml(conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLitePreparedStatement.bindAll(params: Array<out Any>) {
|
||||
params.forEachIndexed { i, v ->
|
||||
val idx = i + 1
|
||||
when (v) {
|
||||
is String -> bindText(idx, v)
|
||||
is Long -> bindLong(idx, v)
|
||||
is Int -> bindLong(idx, v.toLong())
|
||||
else -> error("Unsupported bind type: ${v::class}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Выполнить INSERT/DELETE statement. reset+clearBindings+bind+executeUpdate. */
|
||||
private fun SQLitePreparedStatement.execDml(vararg params: Any): Int {
|
||||
reset()
|
||||
clearBindings()
|
||||
bindAll(params)
|
||||
return executeUpdate()
|
||||
}
|
||||
|
||||
/** Выполнить SELECT statement, вернуть список [MessageRecord]. reset+clearBindings+bind+executeQuery+drain. */
|
||||
private fun SQLitePreparedStatement.runQuery(vararg params: Any): List<MessageRecord> {
|
||||
reset()
|
||||
clearBindings()
|
||||
bindAll(params)
|
||||
val out = mutableListOf<MessageRecord>()
|
||||
executeQuery().use { rs ->
|
||||
while (rs.next()) out.add(rs.toMessageRecord(json))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package pw.binom.agentik.messageLog.ksqlite
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.agentik.messageLog.MessageRecord
|
||||
import pw.binom.agentik.messageLog.decodeBodyPayload
|
||||
import pw.binom.agentik.messageLog.encodeBodyPayload
|
||||
import pw.binom.db.ksqlite.SQLiteResultSet
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
|
||||
*
|
||||
* Копия [pw.binom.agentik.storage.sqlite.SqliteMessageStore] — `private` helpers
|
||||
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
|
||||
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
|
||||
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
|
||||
*/
|
||||
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
|
||||
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
|
||||
content = record.content,
|
||||
context = record.context,
|
||||
)
|
||||
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
|
||||
content = record.content,
|
||||
tokens = record.tokens,
|
||||
)
|
||||
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
|
||||
CallPayload.serializer(),
|
||||
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
|
||||
)
|
||||
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
|
||||
ResultPayload.serializer(),
|
||||
ResultPayload(toolCallId = record.toolCallId, result = record.result),
|
||||
)
|
||||
is MessageRecord.Error -> "error" to Json.encodeToString(
|
||||
ErrorPayload.serializer(),
|
||||
ErrorPayload(message = record.message, code = record.code),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
val id = getText(0)!!
|
||||
val convId = getText(1)!!
|
||||
val kind = getText(2)!!
|
||||
val payload = getText(3)!!
|
||||
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
|
||||
return when (kind) {
|
||||
"user" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
|
||||
}
|
||||
"assistant" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens)
|
||||
}
|
||||
"tool_call" -> {
|
||||
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
||||
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
|
||||
}
|
||||
"tool_result" -> {
|
||||
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
|
||||
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, result = p.result, createdAt = createdAt)
|
||||
}
|
||||
"error" -> {
|
||||
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
|
||||
}
|
||||
else -> error("Unknown message kind in audit log: $kind")
|
||||
}
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class ResultPayload(val toolCallId: String, val result: String?)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class ErrorPayload(val message: String, val code: String?)
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package pw.binom.agentik.messageLog.ksqlite
|
||||
|
||||
import kotlinx.coroutines.flow.count
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.messageLog.Content
|
||||
import pw.binom.agentik.messageLog.MessageRecord
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Instant
|
||||
|
||||
class KsqliteMessageStoreTest {
|
||||
|
||||
private lateinit var conn: SQLiteConnection
|
||||
private lateinit var store: KsqliteMessageStore
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
conn = SQLiteConnection.memory("msg-${kotlin.random.Random.nextLong()}")
|
||||
conn.exec(SCHEMA)
|
||||
store = KsqliteMessageStore(conn)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() = conn.close()
|
||||
|
||||
@Test
|
||||
fun testAppendUserAndRetrieve() = runTest {
|
||||
store.append(MessageRecord.UserMessage(
|
||||
id = "m1",
|
||||
conversationId = "conv1",
|
||||
content = listOf(Content.Text("hello")),
|
||||
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||
context = null,
|
||||
))
|
||||
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
|
||||
assertEquals(1, list.size)
|
||||
val msg = list[0]
|
||||
assertEquals("m1", msg.id)
|
||||
assertEquals(MessageRecord.UserMessage::class, msg::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppendAssistantWithTokens() = runTest {
|
||||
store.append(MessageRecord.AssistantMessage(
|
||||
id = "m1",
|
||||
conversationId = "conv1",
|
||||
content = listOf(Content.Text("hi")),
|
||||
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||
tokens = pw.binom.agentik.messageLog.TurnTokens(input = 50, output = 30),
|
||||
))
|
||||
val all = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
|
||||
assertEquals(1, all.size)
|
||||
val msg = all[0] as MessageRecord.AssistantMessage
|
||||
assertEquals(50, msg.tokens?.input)
|
||||
assertEquals(30, msg.tokens?.output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListAfterFiltersByTimestamp() = runTest {
|
||||
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
||||
val t2 = Instant.parse("2026-09-15T10:02:00Z")
|
||||
val t3 = Instant.parse("2026-09-15T10:03:00Z")
|
||||
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
|
||||
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
|
||||
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
|
||||
|
||||
val after = store.list("conv1", after = t1, offset = 0, limit = 10)
|
||||
assertEquals(2, after.size)
|
||||
assertEquals(listOf("m2", "m3"), after.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListAllReturnsAllInOrder() = runTest {
|
||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||
for (i in 1..3) store.append(
|
||||
MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t + kotlin.time.Duration.parse("PT${i}S"), null)
|
||||
)
|
||||
assertEquals(listOf("m1", "m2", "m3"), store.listFlow("conv1", Instant.DISTANT_PAST).toList().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearRemovesByConversation() = runTest {
|
||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
|
||||
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), t, null))
|
||||
store.clear("conv1")
|
||||
assertEquals(0, store.listFlow("conv1", Instant.DISTANT_PAST).count())
|
||||
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListWithNullContext() = runTest {
|
||||
store.append(MessageRecord.UserMessage(
|
||||
id = "m1",
|
||||
conversationId = "conv1",
|
||||
content = listOf(Content.Text("hi")),
|
||||
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||
context = null,
|
||||
))
|
||||
val msg = store.listFlow("conv1", Instant.DISTANT_PAST).first() as MessageRecord.UserMessage
|
||||
assertNull(msg.context)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_msg_conv ON message(conversation_id, created_at);
|
||||
"""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user