feat(client): client-side caching for conversation list via agent.outbox events

Adds `agent.conversationStore` (read-only view on `conversation` table) to
the :proto Agent interface, plus `agent.renameConversation(id, title?)`
command. Client-side cache in :client is built from a snapshot
(`remote.listFlow(0)` → `local.upsert(...)`) + live updates via
`outbox.agentEvents()` (Created/Deleted/Renamed/Touched).

Changes:
- :journal-api — split `ConversationStore` (read-only: get/list) and
  `MutableConversationStore` (CRUD: upsert/delete/rename/touch);
  `ConversationStore` gained `listFlow` (cold-flow paging via `list`).
- :outbox-api — `AgentEvent.Touched(date, id, updatedAt)` event so
  client cache stays fresh after `send()` (which bumps `updatedAt`).
- :proto.Agent — added `conversationStore: ConversationStore` property,
  added `renameConversation(id, title?): Instant?` command, removed
  `getConversations(offset, limit)` (now: `conversationStore.list(...)`).
- :server — `GET /conversations` now returns `List<ConversationRecord>`
  (lightweight metadata, no handle/image-support flags); `PATCH
  /conversations/{id}` uses `agent.renameConversation` and returns
  the updated `ConversationRecord`.
- :journal-inmemory — expanded targets to jvm+macos+linux+mingw (matches
  :client); moved `InMemoryMutableConversationStore` here from
  :storage-inmemory so :client can use it without pulling ios targets.
- :storage-inmemory — depends on :journal-inmemory.
- :storage-ksqlite — pre-staged rename `KsqliteConversationStore` →
  `KsqliteMutableConversationStore` to match the new interface split.
- :standalone — `ChatAgent` exposes `conversationStore` as a read-only
  view of its `mutableConversationStore`; emits `AgentEvent.Touched`
  after each `send()` (after `conversationStore.touch(id, ts)`).
- :client — new `HttpConversationStore` (read-only HTTP impl);
  `AgentikAgent` wraps the agent with `wrapWithLocalConversationCache`
  so the client sees an in-memory cache (snapshot + outbox events)
  instead of direct HTTP. Cache scope + HttpClient + background job
  all cancelled in `agent.close()`.
- :client/README — new «Кэш списка бесед» section with the
  `listFlow → upsert` / `agentEvents → apply` pattern and a note that
  `conversationStore` is read-only (writes only via Agent commands).

All 96 jvmTest tasks green.
This commit is contained in:
2026-09-22 05:21:01 +03:00
parent acb4ee6186
commit 5f0e0da361
29 changed files with 424 additions and 135 deletions
+7 -6
View File
@@ -2,12 +2,10 @@ plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация [MutableJournalStore] на `MutableList` + `Mutex` — для
// тестов, dev-режима, embedded-сценариев (Android core, CLI, in-process кэш
// в клиенте) и как образец для своей реализации.
//
// `list` фильтрует по `conversationId`+`createdAt>after` и сортирует
// по `createdAt ASC`. Paging — поверх отфильтрованного списка.
// KMP-реализация [MutableJournalStore] и [MutableConversationStore] на
// `MutableList`/`MutableMap` + `Mutex` — для тестов, dev-режима,
// embedded-сценариев (Android core, CLI, in-process кэш в клиенте) и как
// образец для своей реализации.
//
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
@@ -15,7 +13,10 @@ kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
@@ -0,0 +1,75 @@
package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MutableConversationStore
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Thread-safe Map-импл [MutableConversationStore] для клиентских
* in-process кэшей (и тестов/dev-режима).
*
* Использует `Mutex` для атомарности read-modify-write операций
* (rename, touch) — иначе два параллельных `rename` могут потерять обновления
* (lost-update race), что в SQLite невозможно из-за driver-level locking.
*
* **Сортировка**: `list()` сортирует по `updatedAt DESC`.
*
* **Типичный кэш-паттерн в клиенте** (см. `client/README.md`):
* ```
* val local = InMemoryMutableConversationStore()
* // seed: remote.listFlow → local.upsert
* // live-refresh: outbox.agentEvents → local.upsert/delete/rename/touch
* // UI: local.list(0, PAGE_SIZE)
* ```
*/
class InMemoryMutableConversationStore(
private val clock: Clock = Clock.System,
) : MutableConversationStore {
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf()
private val mutex = Mutex()
override suspend fun upsert(record: ConversationRecord) {
mutex.withLock { byId[record.id] = record }
}
override suspend fun get(id: String): ConversationRecord? {
mutex.withLock { return byId[id] }
}
override suspend fun delete(id: String): Boolean {
mutex.withLock { return byId.remove(id) != null }
}
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> {
mutex.withLock {
return byId.values
.sortedByDescending { it.updatedAt }
.drop(offset)
.take(limit)
}
}
override suspend fun rename(id: String, title: String?): Instant? {
mutex.withLock {
val existing = byId[id] ?: return null
val now = clock.now()
byId[id] = existing.copy(title = title, updatedAt = now)
return now
}
}
override suspend fun touch(id: String, now: Instant) {
mutex.withLock {
val existing = byId[id] ?: return
byId[id] = existing.copy(updatedAt = now)
}
}
override fun close() {
// no-op
}
}
@@ -0,0 +1,119 @@
package pw.binom.agentik.journal.inmemory
import pw.binom.agentik.journal.ConversationRecord
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.test.runTest
class InMemoryMutableConversationStoreTest {
@Test
fun `upsert and get roundtrip preserves all fields`() = runTest {
val store = InMemoryMutableConversationStore()
val rec = ConversationRecord(
id = "c1",
title = "test",
isTemporal = false,
createdAt = Instant.parse("2026-09-15T10:00:00Z"),
updatedAt = Instant.parse("2026-09-15T10:00:00Z"),
)
store.upsert(rec)
val got = store.get("c1")
assertEquals(rec, got)
}
@Test
fun `get returns null for missing id`() = runTest {
val store = InMemoryMutableConversationStore()
assertNull(store.get("nope"))
}
@Test
fun `delete removes the record and returns true`() = runTest {
val store = InMemoryMutableConversationStore()
store.upsert(
ConversationRecord(
"c1", null, false,
Instant.parse("2026-09-15T10:00:00Z"),
Instant.parse("2026-09-15T10:00:00Z"),
)
)
assertTrue(store.delete("c1"))
assertNull(store.get("c1"))
// повторный delete — false
assertEquals(false, store.delete("c1"))
}
@Test
fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest {
val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0))
store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S"))))
store.upsert(ConversationRecord("c3", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT120S"))))
store.upsert(ConversationRecord("c4", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT180S"))))
val page0 = store.list(offset = 0, limit = 2)
assertEquals(listOf("c4", "c3"), page0.map { it.id })
val page1 = store.list(offset = 2, limit = 2)
assertEquals(listOf("c2", "c1"), page1.map { it.id })
val page2 = store.list(offset = 4, limit = 2)
assertEquals(emptyList(), page2)
}
@Test
fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0))
val newUpdated = store.rename("c1", "new title")
assertNotNull(newUpdated)
assertTrue(newUpdated > t0)
val got = store.get("c1")
assertEquals("new title", got?.title)
assertEquals(newUpdated, got?.updatedAt)
}
@Test
fun `rename with null title clears it`() = runTest {
val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", "old", false, t0, t0))
store.rename("c1", null)
assertNull(store.get("c1")?.title)
}
@Test
fun `rename returns null for missing conversation`() = runTest {
val store = InMemoryMutableConversationStore()
assertNull(store.rename("nope", "x"))
}
@Test
fun `touch bumps updatedAt without changing other fields`() = runTest {
val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
store.touch("c1", t1)
val got = store.get("c1")
assertEquals("title", got?.title)
assertEquals(t1, got?.updatedAt)
assertEquals(t0, got?.createdAt)
}
@Test
fun `close is idempotent and does nothing`() {
val store = InMemoryMutableConversationStore()
store.close()
store.close() // должно быть no-op
}
}