feat(events): EventStore + AllEvent unified stream + replay endpoints
ci / JVM build + tests (push) Has been cancelled
release / Publish KMP libraries → caffeine Nexus (release) Successful in 32s

EventStore (persistent event log) и AllEvent (sealed wrapper для
третьего типа подписки — ВСЕ events в одном потоке). Touches 7 modules.

Архитектура:
  Producer (ChatAgent + ConversationEvents) → EventStore + SharedFlow
  ↓                                            ↓
  Live SSE (cold, no replay)         Replay endpoints (cursor-based)

(1) :storage-core — EventStore interface
  - append(record): idempotent по record.id (INSERT OR IGNORE)
  - query(conversationId?, afterId?, limit): пагинированный catchup
  - pruneOlderThan(instant): TTL cleanup
  - count(): maintenance метрика
  - @Serializable EventRecord(id, conversationId?, createdAt, type, payload)
  - enum EventType: AGENT_*/CONVERSATION_* (forward-compat fallback)
  - StorageBundle дополнен eventStore: EventStore? = null (backward-compat)

(2) :storage-inmemory — InMemoryEventStore
  - Thread-safe (Mutex), binarySearch для упорядоченной вставки
  - Записи сортируются по createdAt ASC, ties по id ASC (стабильно)
  - Idempotency по id (повторный append no-op)

(3) :storage-sqlite — SqliteEventStore
  - sqldelight schema: agent_event (id PK, conversation_id?, created_at,
    type, payload BLOB) + 2 индекса (conversation_id+created_at,
    created_at)
  - Миграция v3: CREATE TABLE IF NOT EXISTS (additive)
  - 5 запросов: insert, queryGlobal, queryByConv, pruneOlderThan, count
  - Forward-compat: неизвестный EventType в БД → fallback AGENT_CREATED
    (чтобы старые клиенты не падали на новых enum values)
  - Добавлен в SqliteStores (open/inMemory + asBundle())

(4) :standalone — Producer wiring
  - ChatAgent.persistAgentEvent() — fire-and-forget append при каждом
    AgentEvent (Created/Deleted/Renamed)
  - ConversationEvents — персистит в EventStore при каждом tryEmit/emit
    (концертный случай от connect disconnect)
  - ChatAgent.allEvents() — merge agent-events + snapshot всех живых
    диалогов в единый Flow<AllEvent>

(5) :proto — AllEvent sealed interface
  - AllEvent.Agent(date, event: AgentEvent)
  - AllEvent.Conversation(date, conversationId, event: Event)
  - Agent.allEvents(after): Flow<AllEvent> — третий тип подписки
    (в дополнение к events() и Conversation.events)

(6) :server — Endpoints
  - GET /events/all — SSE поток AllEvent (cold)
  - GET /events/replay?after_id=&limit= — пагинированный catchup
    (503 если EventStore не сконфигурирован)
  - GET /conversations/{id}/events/replay?after_id=&limit= — то же per-conv
  - Module.kt принимает eventStore: EventStore? параметром

(7) :client — Client API
  - AgentClient.allEvents(after) — подписка на /events/all SSE
  - AgentClient.replayAllEvents(afterId, limit) — catchup /events/replay
  - AgentClient.replayConversationEvents(convId, afterId, limit)
  - EventRecordDto — wire-зеркало EventRecord (клиент не зависит
    от :storage-core, определяет DTO локально; формат совместим с
    серверным JSON)

Тесты: 22 новых теста (12 InMemory + 10 Sqlite), все зелёные.
Все три слоя синхронизированы: proto contract + standalone impl +
server endpoint + client API.
This commit is contained in:
2026-09-20 02:51:58 +03:00
parent c140d0b758
commit 4ad59d5f5d
18 changed files with 1065 additions and 19 deletions
@@ -1,5 +1,7 @@
package pw.binom.agentik.storage
import pw.binom.agentik.storage.events.EventStore
/**
* Агрегатор всех storage-интерфейсов, нужных агенту для работы с историей диалога.
*
@@ -11,7 +13,10 @@ package pw.binom.agentik.storage
* `SkillStore` НЕ входит сюда — он живёт в модуле `:skills` (другая ответственность:
* не сообщения/рефлексии, а контент-файлы навыков) и принимается отдельно в `ChatAgent`.
*
* AutoCloseable: один `close()` закрывает все четыре store'а. В реализациях,
* [EventStore] входит начиная с commit "event-store" — для replay после
* disconnect (см. `/events/replay` endpoint в `:server`).
*
* AutoCloseable: один `close()` закрывает все store'ы. В реализациях,
* которые не владеют ресурсами (in-memory), close — no-op.
*/
data class StorageBundle(
@@ -19,11 +24,13 @@ data class StorageBundle(
val messageStore: MessageStore,
val workingMemoryStore: WorkingMemoryStore,
val reflectionStore: ReflectionStore,
val eventStore: EventStore? = null,
) : AutoCloseable {
override fun close() {
conversationStore.close()
messageStore.close()
workingMemoryStore.close()
reflectionStore.close()
eventStore?.close()
}
}
@@ -0,0 +1,109 @@
package pw.binom.agentik.storage.events
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Persistent event log для replay после disconnect.
*
* Зачем: SSE-подписка на `/events` и `/conversations/{id}/events` — cold (no replay).
* Если клиент отвалился на час, он пропустил всё. [EventStore] даёт:
* - append() — producer (ChatAgent) пишет при каждом event
* - query() — consumer (server SSE replay endpoint) читает по cursor
* - prune() — maintenance: удалить старые events по TTL
*
* Не заменяет live-подписку на [MutableSharedFlow] — это для долговременного
* хранения, а live-streaming идёт через in-memory channel.
*
* Платформо-агностичный interface (KMP): impl в `:storage-sqlite` (JVM-only),
* `:storage-inmemory` (KMP, для тестов и dev), и в будущем `:storage-sqlite-android`
* для Android-агента.
*
* Payload — opaque JSON string. [storage-core] не должен знать про
* kotlinx.serialization или [AgentEvent]/[Conversation.Event] типы (это `:proto`-шный
* слой). Конвертация — на стороне producer'а (:standalone ChatAgent).
*/
interface EventStore : AutoCloseable {
/**
* Записать event. Идемпотентен по [EventRecord.id] — повторный append с тем же
* id это no-op (важно для retry при network failure между producer'ом и БД).
*/
suspend fun append(record: EventRecord)
/**
* Catchup query для reconnect.
*
* @param conversationId если `null` — глобальный catchup (для `/events/replay`).
* если задан — только этот диалог (для `/conversations/{id}/events/replay`).
* @param afterId exclusive cursor: вернуть events СТРОГО после этого id.
* Если `null` — с начала.
* @param limit max количество records (default 100). Caller делает пагинацию
* пока `result.size == limit`.
*
* Сортировка: по [EventRecord.createdAt] ASC, ties broken по [EventRecord.id] ASC
* (т.к. id содержит timestamp-like prefix в нашей схеме, это даёт стабильный порядок).
*/
suspend fun query(
conversationId: String? = null,
afterId: String? = null,
limit: Int = 100,
): List<EventRecord>
/**
* Maintenance: удалить events старше [olderThan]. Возвращает количество удалённых.
* Default вызывается из background scope раз в час (TTL = 24h типично).
*/
suspend fun pruneOlderThan(olderThan: Instant): Int
/** Сколько events всего хранится (для observability). */
suspend fun count(): Int
override fun close()
}
/**
* Платформо-агностичная запись event'а.
*
* @param id уникальный в пределах EventStore. Convention: `"ev-<uuid>"`.
* Используется как cursor для [EventStore.query].
* @param conversationId `null` для agent-level events (Created/Deleted/Renamed).
* Задан для conversation events.
* @param createdAt UTC timestamp. Используется для сортировки в query() и для TTL в prune().
* @param type kind of event (для индексирования/фильтрации; payload всё равно opaque).
* @param payload opaque JSON string. Producer (:standalone ChatAgent) сериализует
* [pw.binom.agentik.proto.AgentEvent] или [pw.binom.agentik.proto.Event]
* в JSON перед append. Consumer (:server Routes) парсит обратно.
*
* Note: payload хранится as String, не ByteArray, чтобы не зависеть от kotlinx
* serialization и platform-specific binary encoding в [storage-core].
*/
@Serializable
data class EventRecord(
val id: String,
val conversationId: String?,
val createdAt: Instant,
val type: EventType,
val payload: String,
)
/**
* Категория event'а — для индексирования и для фильтрации в query().
*
* Naming: AGENT_* — agent-level, CONVERSATION_* — turn-level.
*/
enum class EventType {
AGENT_CREATED,
AGENT_DELETED,
AGENT_RENAMED,
CONVERSATION_START_REASONING,
CONVERSATION_START_RESPONSE,
CONVERSATION_APPEND_TEXT,
CONVERSATION_APPEND_IMAGE,
CONVERSATION_TOOL_CALL,
CONVERSATION_TOOL_RESULT,
CONVERSATION_END,
CONVERSATION_INTERRUPTED,
CONVERSATION_ERROR,
// reserved for future — adding new variants doesn't break older consumers
}