refactor(storage): drop StorageBundle + legacy EventStore, add HttpEventStore
ci / JVM build + tests (push) Successful in 6m13s

- Remove legacy pw.binom.agentik.messageStore.events.EventStore (EventRecord,
  EventType) and all three impls (in-memory, sqlite, ksqlite) + tests + .sq
- Drop :storage-bundle module entirely; ChatAgent / ChatConversation /
  ConversationLoop / DebugRoutes now take stores individually
  (conversationStore, messageStore, workingMemoryStore, reflectionStore,
  eventStore) instead of StorageBundle
- Delete server endpoints /events/replay and /conversations/{id}/events/replay;
  Route.agentikAgent no longer takes eventStore param
- Add :client/HttpEventStore implementing :event-store/EventStore over HTTP:
  events() -> GET /events/all, agentEvents() -> GET /events,
  conversationEvents(convId) -> GET /conversations/{id}/events;
  exposed via AgentClient.eventStore
- :event-store: add macosX64/macosArm64/linuxArm64 targets to match :client KMP
- :working-memory-api: drop api dep on :message-store-api (no longer needed)
- :storage-{inmemory,sqlite,ksqlite}: drop deps on :storage-bundle
This commit is contained in:
2026-09-20 18:06:46 +03:00
parent 15f3952eba
commit d74af621d8
36 changed files with 313 additions and 1259 deletions
-1
View File
@@ -25,7 +25,6 @@ kotlin {
commonMain.dependencies {
api(project(":message-store-api"))
api(project(":message-log-api"))
api(project(":storage-bundle"))
api(project(":working-memory-api"))
// ksqlite ещё не опубликован в Maven Central — только в локальном
@@ -1,139 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import pw.binom.agentik.messageStore.events.EventRecord
import pw.binom.agentik.messageStore.events.EventStore
import pw.binom.agentik.messageStore.events.EventType
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import pw.binom.db.ksqlite.SQLiteResultSet
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* KMP-реализация [EventStore] поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
*
* Схема таблицы `agent_event` повторяет [pw.binom.agentik.storage.sqlite.SqliteEventStore],
* чтобы данные были совместимы между двумя backend'ами (можно мигрировать через дамп SQL).
*
* **Идемпотентность**: append использует `INSERT OR REPLACE` (id — PRIMARY KEY).
* Для retry с тем же id и тем же payload это no-op; для retry с тем же id но
* другим payload — replace. EventStore contract обещает idempotent no-op; это
* ослабление для SQLite (см. [pw.binom.agentik.storage.sqlite.SqliteEventStore]).
*
* **Thread-safety**: ksqlite API синхронный; один [Mutex] сериализует операции
* внутри одного store. Между разными store'ами (если делят [SQLiteConnection])
* SQLite сам сериализует через внутренний lock.
*
* **KMP coverage**: работает на JVM (через JNI к .so), linuxX64/mingwX64
* (static C amalgamation), Android Native (нужны NDK headers для включения target'а).
*
* @param connection открытое соединение с БД. Caller владеет lifecycle —
* должен закрыть после [close] EventStore.
*/
class KsqliteEventStore(
private val connection: SQLiteConnection,
) : EventStore {
private val mutex = Mutex()
/**
* Диспатчер для блокирующего SQLite I/O. На JVM и native у [Dispatchers.IO]
* разная видимость (на native internal), поэтому для общности используем
* [Dispatchers.Default] — на JVM это ~64 worker thread'а, на native — пул
* для cinterop-blocking вызовов. Mutex сериализует операции внутри store
* и так, так что contention минимальный.
*/
private companion object {
private val IO_DISPATCHER: CoroutineDispatcher = Dispatchers.Default
}
override suspend fun append(record: EventRecord): Unit = withContext(IO_DISPATCHER) {
mutex.withLock {
connection.prepare(
"INSERT OR REPLACE INTO agent_event " +
"(id, conversation_id, created_at, type, payload) VALUES (?, ?, ?, ?, ?)"
).use { stmt ->
stmt.bindText(1, record.id)
val convId = record.conversationId
if (convId != null) {
stmt.bindText(2, convId)
} else {
stmt.bindNull(2)
}
stmt.bindLong(3, record.createdAt.toEpochMilliseconds())
stmt.bindText(4, record.type.name)
stmt.bindText(5, record.payload)
stmt.executeUpdate()
}
}
}
override suspend fun query(
conversationId: String?,
afterId: String?,
limit: Int,
): List<EventRecord> = withContext(IO_DISPATCHER) {
mutex.withLock {
val sql = buildString {
append("SELECT id, conversation_id, created_at, type, payload FROM agent_event WHERE 1=1")
if (conversationId != null) append(" AND conversation_id = ?")
if (afterId != null) append(" AND id > ?")
append(" ORDER BY created_at ASC, id ASC LIMIT ?")
}
connection.prepare(sql).use { stmt ->
var idx = 1
if (conversationId != null) stmt.bindText(idx++, conversationId)
if (afterId != null) stmt.bindText(idx++, afterId)
stmt.bindLong(idx, limit.toLong())
collectQuery(stmt)
}
}
}
private fun collectQuery(stmt: SQLitePreparedStatement): List<EventRecord> {
val result = mutableListOf<EventRecord>()
stmt.executeQuery().use { rs: SQLiteResultSet ->
while (rs.next()) {
result.add(
EventRecord(
id = rs.getText(0)!!,
conversationId = rs.getText(1),
createdAt = Instant.fromEpochMilliseconds(rs.getLong(2)!!),
type = runCatching { EventType.valueOf(rs.getText(3)!!) }
.getOrDefault(EventType.AGENT_CREATED),
payload = rs.getText(4)!!,
)
)
}
}
return result
}
override suspend fun pruneOlderThan(olderThan: Instant): Int = withContext(IO_DISPATCHER) {
mutex.withLock {
connection.prepare("DELETE FROM agent_event WHERE created_at < ?").use { stmt ->
stmt.bindLong(1, olderThan.toEpochMilliseconds())
stmt.executeUpdate()
}
}
}
override suspend fun count(): Int = withContext(IO_DISPATCHER) {
mutex.withLock {
// Не нашёл queryForInt в API, делаем через prepare.
connection.prepare("SELECT COUNT(*) FROM agent_event").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) (rs.getLong(0) ?: 0L).toInt() else 0
}
}
}
}
override fun close() {
// Connection lifecycle — на caller'е (фабрика KsqliteStores).
}
}
@@ -3,16 +3,14 @@ package pw.binom.agentik.storage.ksqlite
import pw.binom.agentik.messageStore.ConversationStore
import pw.binom.agentik.messageLog.MessageStore
import pw.binom.agentik.messageStore.ReflectionStore
import pw.binom.agentik.storageBundle.StorageBundle
import pw.binom.agentik.workingMemory.WorkingMemoryStore
import pw.binom.agentik.messageStore.events.EventStore
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Фабрика всех 5 store'ов из :storage-core поверх ksqlite.
* Фабрика 4 store'ов поверх ksqlite.
*
* Lifecycle: открывает [SQLiteConnection], гарантирует наличие таблиц
* (CREATE TABLE IF NOT EXISTS), возвращает bundle из 5 store'ов. Caller
* (CREATE TABLE IF NOT EXISTS), возвращает bundle из 4 store'ов. Caller
* ДОЛЖЕН вызвать [close] при завершении.
*
* @param path путь к .db файлу, либо URI для in-memory/shared-cache.
@@ -23,24 +21,14 @@ class KsqliteStores private constructor(
val messages: MessageStore,
val workingMemory: WorkingMemoryStore,
val reflections: ReflectionStore,
val events: EventStore,
) : AutoCloseable {
fun asBundle(): StorageBundle = StorageBundle(
conversationStore = conversations,
messageStore = messages,
workingMemoryStore = workingMemory,
reflectionStore = reflections,
eventStore = events,
)
override fun close() {
// Закрытие в правильном порядке: зависимые → владелец connection.
conversations.close()
messages.close()
workingMemory.close()
reflections.close()
events.close()
connection.close()
}
@@ -88,16 +76,6 @@ class KsqliteStores private constructor(
);
CREATE INDEX IF NOT EXISTS idx_reflection_created ON reflection(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_reflection_conv ON reflection(conversation_id, created_at DESC);
CREATE TABLE IF NOT EXISTS agent_event (
id TEXT NOT NULL PRIMARY KEY,
conversation_id TEXT,
created_at INTEGER NOT NULL,
type TEXT NOT NULL,
payload TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_event_conv_time ON agent_event(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_agent_event_time ON agent_event(created_at);
"""
fun open(path: String): KsqliteStores {
@@ -121,7 +99,6 @@ class KsqliteStores private constructor(
messages = messages,
workingMemory = working,
reflections = KsqliteReflectionStore(conn),
events = KsqliteEventStore(conn),
)
}
}
@@ -1,150 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.messageStore.events.EventRecord
import pw.binom.agentik.messageStore.events.EventType
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
class KsqliteEventStoreTest {
private lateinit var stores: KsqliteStores
@BeforeTest
fun setup() {
stores = KsqliteStores.inMemory("test-${kotlin.random.Random.nextLong()}")
}
@AfterTest
fun tearDown() {
stores.close()
}
private fun rec(
id: String,
ts: Long,
conv: String? = null,
type: EventType = EventType.CONVERSATION_APPEND_TEXT,
): EventRecord = EventRecord(
id = id,
conversationId = conv,
createdAt = Instant.fromEpochMilliseconds(ts),
type = type,
payload = "{\"i\":\"$id\"}",
)
@Test
fun testAppendThenQueryReturnsRecord() = runTest {
val r = rec("ev-1", ts = 1000)
stores.events.append(r)
assertEquals(listOf(r), stores.events.query())
}
@Test
fun testQueryWithConversationIdFilters() = runTest {
stores.events.append(rec("ev-1", ts = 1000, conv = "c-1"))
stores.events.append(rec("ev-2", ts = 2000, conv = "c-2"))
stores.events.append(rec("ev-3", ts = 3000, conv = "c-1"))
val c1 = stores.events.query(conversationId = "c-1")
assertEquals(listOf("ev-1", "ev-3"), c1.map { it.id })
}
@Test
fun testQueryWithAfterIdReturnsEventsStrictlyAfterCursor() = runTest {
stores.events.append(rec("ev-1", ts = 1000))
stores.events.append(rec("ev-2", ts = 2000))
stores.events.append(rec("ev-3", ts = 3000))
// id-based cursor: id > 'ev-1' returns ev-2, ev-3
assertEquals(listOf("ev-2", "ev-3"), stores.events.query(afterId = "ev-1").map { it.id })
}
@Test
fun testAppendIsIdempotentOnIdWithInsertOrReplace() = runTest {
val r1 = rec("ev-1", ts = 1000, type = EventType.CONVERSATION_APPEND_TEXT)
val r2 = rec("ev-1", ts = 1000, type = EventType.CONVERSATION_TOOL_CALL)
stores.events.append(r1)
stores.events.append(r2)
// INSERT OR REPLACE means replace wins. Documented in KsqliteEventStore KDoc.
val result = stores.events.query()
assertEquals(1, result.size)
assertEquals(EventType.CONVERSATION_TOOL_CALL, result[0].type)
}
@Test
fun testRecordsOrderedByCreatedAtThenId() = runTest {
stores.events.append(rec("ev-c", ts = 2000))
stores.events.append(rec("ev-a", ts = 1000))
stores.events.append(rec("ev-d", ts = 1000))
stores.events.append(rec("ev-b", ts = 1500))
assertEquals(listOf("ev-a", "ev-d", "ev-b", "ev-c"),
stores.events.query().map { it.id })
}
@Test
fun testQueryWithLimitCaps() = runTest {
repeat(10) { i -> stores.events.append(rec("ev-$i", ts = (i * 100).toLong())) }
val first3 = stores.events.query(limit = 3)
assertEquals(3, first3.size)
assertEquals(listOf("ev-0", "ev-1", "ev-2"), first3.map { it.id })
}
@Test
fun testPruneOlderThanRemovesOld() = runTest {
stores.events.append(rec("ev-1", ts = 1000))
stores.events.append(rec("ev-2", ts = 2000))
stores.events.append(rec("ev-3", ts = 3000))
val removed = stores.events.pruneOlderThan(Instant.fromEpochMilliseconds(2500))
assertEquals(2, removed)
assertEquals(listOf("ev-3"), stores.events.query().map { it.id })
}
@Test
fun testCountReturnsTotal() = runTest {
assertEquals(0, stores.events.count())
stores.events.append(rec("ev-1", ts = 1000))
stores.events.append(rec("ev-2", ts = 2000))
assertEquals(2, stores.events.count())
}
@Test
fun testUnknownEventTypeLoadedAsFallback() = runTest {
// Forward-compat: write record with unknown type, read via store.
stores.connection.exec(
"INSERT INTO agent_event (id, conversation_id, created_at, type, payload) VALUES " +
"('ev-future', NULL, 5000, 'SOME_FUTURE_TYPE', '{}')"
)
val result = stores.events.query()
assertEquals(1, result.size)
assertEquals("ev-future", result[0].id)
assertEquals(EventType.AGENT_CREATED, result[0].type)
}
@Test
fun testNullConversationIdStoredAndRetrieved() = runTest {
stores.events.append(rec("ev-no-conv", ts = 1000, conv = null))
val result = stores.events.query()
assertEquals(1, result.size)
assertNull(result[0].conversationId)
}
@Test
fun testSchemaCreatedOnFirstOpen() = runTest {
val count = stores.connection.prepare(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agent_event'"
).use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) (rs.getLong(0) ?: 0L).toInt() else 0
}
}
assertEquals(1, count)
}
}