Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.

Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.

outbox-api:
  - Cursor (offset: Long) — курсор в журнале событий агента.
  - OffsetSequencer — интерфейс резервирования уникального offset.
  - CursorStore — персистентное хранилище текущего offset'а.
  - PersistentOffsetSequencer — декоратор над любым OutboxStore,
    обновляет CursorStore на каждом append (atomic transaction).
  - OutboxGapException — клиент запросил after < earliestCursor() →
    сервер не может удовлетворить, клиент обязан делать full resync.
  - DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
    был общим sealed-типом, теперь это термин из спеки).
  - MutableOutboxStore и OutboxStore теперь читают offset через
    CursorStore вместо in-memory counter'а.

outbox-inmemory:
  - InMemoryOffsetSequencer — для тестов и dev-режима.
  - InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.

outbox-ksqlite (новый модуль):
  - KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
    offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
  - KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).

proto + server:
  - Snapshot.proto — server-state snapshot endpoint для клиентов,
    которым нужна полная материализация (использование TBD).
  - Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).

journal-ksqlite:
  - KsqliteJournalStore.listFlow/append — без изменений по API,
    нотации минимальные (codecs).

standalone:
  - DurableLog (бывший ChatAgent-orchestration) — атомарный commit
    события в OutboxStore + PersistentOffsetSequencer + materialization
    (через Reducer) одной транзакцией.
  - SqliteStores — добавляет KsqliteCursorStore в bundle, единая
    shared-connection для всех ksqlite-сторов standalone-агента.
  - ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
    ToolDispatcher — переход на новые абстракции.
  - standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
    включено (раньше было закомментировано — модуль только создавался).

client:
  - AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
    ReconnectingOutbox — используют Cursor через transport API.
  - client/README.md — синхронизирован с новым поведением (468 строк
    diff — это в основном оформление и примеры).

kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).

SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.

Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.

sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
@@ -15,21 +15,12 @@ import kotlin.time.Instant
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
*
* **Контракт `list`**: возвращает подмножество с
* `conversationId == conversationId && createdAt > after`, отсортированное
* по `createdAt ASC`. `offset/limit` — paging поверх отфильтрованного списка.
* **Контракт `list`**: legacy — `createdAt > after`, `ORDER BY createdAt ASC`
* (+`offset/limit`); seq-версия — `afterSeq < seq <= upToSeq`,
* `ORDER BY seq ASC` (keyset).
*
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
* на сервере). [close] — no-op.
*
* Типичный кэш-паттерн в клиенте:
* ```
* val local = InMemoryJournalStore()
* val remote = HttpJournalStore(httpClient, baseUrl)
* // backfill + кэширование:
* remote.listFlow(convId, Instant.DISTANT_PAST).collect { local.append(it) }
* // после этого `local.list(convId, after, offset, limit)` отдаёт из кэша.
* ```
*/
class InMemoryJournalStore : MutableJournalStore {
@@ -54,6 +45,19 @@ class InMemoryJournalStore : MutableJournalStore {
.toList()
}
override suspend fun list(
conversationId: String,
afterSeq: Long,
upToSeq: Long,
limit: Int,
): List<MessageRecord> = mutex.withLock {
records.asSequence()
.filter { it.conversationId == conversationId && it.seq > afterSeq && it.seq <= upToSeq }
.sortedWith(compareBy({ it.seq }, { it.createdAt }, { it.id }))
.take(limit)
.toList()
}
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
records.removeAll { it.conversationId == conversationId }
@@ -67,6 +71,10 @@ class InMemoryJournalStore : MutableJournalStore {
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
}
override suspend fun count(conversationId: String, afterSeq: Long): Long = mutex.withLock {
records.count { it.conversationId == conversationId && it.seq > afterSeq }.toLong()
}
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
suspend fun size(): Int = mutex.withLock { records.size }