From 5bdc517988072098a0fd6a77ce76da6a5561b0cd Mon Sep 17 00:00:00 2001 From: subochev Date: Fri, 2 Oct 2026 01:16:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20Cursor/OffsetSequencer=20=D0=B2=20:outbox-api=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D1=82=20PersistentOffsetSequencer=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20:outbox-ksqlite.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Введение монотонного 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) удалён. --- SYNC-SYSTEM.md | 459 +++++++++++++++++ .../kotlin/pw/binom/agentik/tui/FakeAgent.kt | 18 +- client/README.md | 470 ++++++++---------- .../pw/binom/agentik/client/AgentClient.kt | 18 + .../pw/binom/agentik/client/AgentikAgent.kt | 109 ++-- .../pw/binom/agentik/client/HttpEventStore.kt | 182 ++++--- .../binom/agentik/client/HttpJournalStore.kt | 27 + .../agentik/client/ReconnectingOutbox.kt | 51 +- .../agentik/client/ReconnectingOutboxTest.kt | 45 +- docs/ARCHITECTURE.md | 2 +- gradle/libs.versions.toml | 2 +- .../pw/binom/agentik/journal/JournalStore.kt | 65 ++- .../pw/binom/agentik/journal/MessageRecord.kt | 22 + .../journal/inmemory/InMemoryJournalStore.kt | 32 +- .../journal/ksqlite/KsqliteJournalStore.kt | 76 ++- .../agentik/journal/ksqlite/MessageCodecs.kt | 12 +- .../binom/agentik/journal/ksqlite/Schema.kt | 53 +- .../pw/binom/agentik/outbox/CommonEvent.kt | 16 +- .../kotlin/pw/binom/agentik/outbox/Cursor.kt | 51 ++ .../pw/binom/agentik/outbox/CursorStore.kt | 25 + .../outbox/{Event.kt => DurableEvent.kt} | 22 +- .../agentik/outbox/MutableOnlineOutbox.kt | 10 +- .../agentik/outbox/MutableOutboxStore.kt | 60 +-- .../binom/agentik/outbox/OffsetSequencer.kt | 39 ++ .../pw/binom/agentik/outbox/OnlineEvent.kt | 43 +- .../pw/binom/agentik/outbox/OnlineOutbox.kt | 2 +- .../agentik/outbox/OutboxGapException.kt | 34 ++ .../pw/binom/agentik/outbox/OutboxStore.kt | 179 +++---- .../outbox/PersistentOffsetSequencer.kt | 62 +++ .../inmemory/InMemoryOffsetSequencer.kt | 34 ++ .../outbox/inmemory/InMemoryOnlineOutbox.kt | 24 +- .../outbox/inmemory/InMemoryOutboxStore.kt | 175 +++---- .../inmemory/InMemoryOutboxStoreTest.kt | 230 +++++---- outbox-ksqlite/build.gradle.kts | 30 ++ .../outbox/ksqlite/KsqliteCursorStore.kt | 78 +++ .../pw/binom/agentik/outbox/ksqlite/Schema.kt | 44 ++ .../outbox/ksqlite/KsqliteCursorStoreTest.kt | 80 +++ proto/README.md | 6 +- .../kotlin/pw/binom/agentik/proto/Agent.kt | 21 + .../pw/binom/agentik/proto/Conversation.kt | 2 +- .../kotlin/pw/binom/agentik/proto/Snapshot.kt | 40 ++ .../pw/binom/agentik/server/JournalRoutes.kt | 29 +- .../pw/binom/agentik/server/OutboxRoutes.kt | 95 +++- .../kotlin/pw/binom/agentik/server/Routes.kt | 40 +- .../agentik/server/AgentInfoRouteTest.kt | 35 +- .../binom/agentik/server/BearerTokenTest.kt | 19 +- .../agentik/server/ConversationRoutesTest.kt | 21 +- .../agentik/server/JournalRoutesCountTest.kt | 21 +- .../binom/agentik/server/SnapshotRouteTest.kt | 162 ++++++ settings.gradle.kts | 4 + .../skill/mining/SkillMiningComponent.kt | 6 +- standalone/build.gradle.kts | 3 + .../pw/binom/agentik/standalone/A2aBridge.kt | 11 +- .../pw/binom/agentik/standalone/Main.kt | 3 + .../agentik/standalone/agent/ChatAgent.kt | 81 ++- .../standalone/agent/CompactionCoordinator.kt | 2 +- .../standalone/agent/ConversationEvents.kt | 49 +- .../standalone/agent/ConversationLoop.kt | 140 +++--- .../agentik/standalone/agent/DurableLog.kt | 77 +++ .../standalone/agent/ReflectionScheduler.kt | 8 +- .../standalone/agent/ToolDispatcher.kt | 70 ++- .../standalone/persistence/SqliteStores.kt | 62 ++- .../agentik/standalone/agent/ChatAgentTest.kt | 82 ++- 63 files changed, 2953 insertions(+), 1017 deletions(-) create mode 100644 SYNC-SYSTEM.md create mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Cursor.kt create mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CursorStore.kt rename outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/{Event.kt => DurableEvent.kt} (95%) create mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OffsetSequencer.kt create mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxGapException.kt create mode 100644 outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/PersistentOffsetSequencer.kt create mode 100644 outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOffsetSequencer.kt create mode 100644 outbox-ksqlite/build.gradle.kts create mode 100644 outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStore.kt create mode 100644 outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/Schema.kt create mode 100644 outbox-ksqlite/src/commonTest/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStoreTest.kt create mode 100644 proto/src/commonMain/kotlin/pw/binom/agentik/proto/Snapshot.kt create mode 100644 server/src/commonTest/kotlin/pw/binom/agentik/server/SnapshotRouteTest.kt create mode 100644 standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/DurableLog.kt diff --git a/SYNC-SYSTEM.md b/SYNC-SYSTEM.md new file mode 100644 index 0000000..1217323 --- /dev/null +++ b/SYNC-SYSTEM.md @@ -0,0 +1,459 @@ +# ТЗ: Синхронизация клиент-сервер для чат-приложения с локальными и удалёнными ассистентами + +## 0. Контекст и цель + +Есть приложение-чат. В чатах пользователь общается с ассистентами. Ассистенты бывают: +- **удалённые** — работают на сервере; +- **локальные** — работают прямо на устройстве клиента. + +Приложение должно: +- работать **офлайн** (пользователь может читать и писать, пока нет сети); +- отрисовывать UI **мгновенно**, не дожидаясь сети; +- **бесшовно** синхронизироваться, когда сеть появляется; +- использовать **один и тот же код** для клиента и сервера там, где это возможно; +- не хранить события вечно — журнал событий **компактится**. + +Ключевая архитектурная идея: **клиент рисует UI исключительно из своей локальной базы**. Сетевые запросы нужны только для синхронизации, а не для отрисовки. + +--- + +## 1. Основные понятия (глоссарий) + +| Термин | Определение | +|---|---| +| **Событие (Event)** | Атомарный факт изменения состояния. Append-only. Имеет монотонный курсор `seq`. | +| **Журнал событий (Event Log)** | Упорядоченная по `seq` последовательность событий. Append-only. Подвергается компакции. | +| **Материализованное состояние (Materialized State)** | Текущее состояние домена (чаты, сообщения), полученное применением событий. Изменяемое. Каждая строка имеет `last_seq` — курсор последнего события, которое её изменило. | +| **Курсор (seq)** | Монотонно возрастающее целое число. Глобально уникальное. Присваивается сервером при записи события. | +| **Состояние на курсоре N** | Множество строк материализованного состояния, у которых `last_seq <= N`. Это «снимок» состояния на момент N. | +| **Апдейты после N** | Множество событий из журнала, у которых `seq > N`. | +| **Full resync** | Полная замена локального состояния клиента состоянием с сервера. Не merge, а replace. | +| **Live sync** | Инкрементальная догрузка событий после известного курсора. | +| **Компакция** | Удаление старых событий из журнала, которые уже «схлопнуты» в материализованное состояние. | + +--- + +## 2. Инварианты системы + +Эти инварианты должны соблюдаться всегда. Если хоть один нарушен — система некорректна. + +1. **Курсор монотонен.** `seq` строго возрастает. Никаких дыр, никаких сбросов. +2. **Журнал append-only.** События не изменяются и не удаляются, кроме как через компакцию. +3. **Материализация консистентна журналу.** Для любого `N`: `apply(events where seq <= N) == SELECT * FROM state WHERE last_seq <= N`. Это значит, что материализованное состояние — это не «что-то отдельное», а результат применения журнала. +4. **События самодостаточны.** Каждое событие несёт **полный payload** изменённой сущности, а не дельту. Это нужно, чтобы клиент мог применить событие к незнакомой сущности. +5. **Full resync = replace.** При полной синхронизации клиент **заменяет** своё локальное состояние, а не мержит. +6. **Клиент отрисовывает только из локальной базы.** Никакой запрос к серверу не блокирует UI. +7. **Компакция не удаляет события, которые ещё нужны активным клиентам.** Либо удаляет, но тогда клиент обязан сделать full resync. + +--- + +## 3. Модель данных + +### 3.1. На сервере + +#### 3.1.1. Журнал событий + +``` +EventLog: + seq : int64, PK, монотонный + event_id : UUID, уникальный идентификатор события + type : enum (ChatCreated, MessageAppended, MessageEdited, MessageDeleted, + ToolCallRequested, ToolCallSucceeded, ToolCallFailed, ...) + payload : JSON / бинарный blob с полным состоянием изменённой сущности + created_at : timestamp + origin : device_id / user_id / assistant_id (кто породил) +``` + +Индексы: `PK(seq)`, `INDEX(created_at)`. + +#### 3.1.2. Материализованное состояние + +Отдельные таблицы под каждую сущность. Примеры: + +``` +Chat: + id : UUID, PK + title : string + created_at : timestamp + deleted : bool + last_seq : int64 -- курсор последнего события, изменившего строку + +Message: + id : UUID, PK + chat_id : UUID, FK -> Chat.id + role : enum (user, assistant, tool_call, tool_result, error) + content : blob + parent_id : UUID, nullable (для ветвлений) + deleted : bool + last_seq : int64 + created_at : timestamp +``` + +Индексы: `PK(id)`, `INDEX(chat_id, last_seq)`, `INDEX(last_seq)`. + +**Важно:** поле `last_seq` — это не «версия строки» в смысле MVCC. Это **«на каком событии строка стала такой, какая она сейчас»**. Строка всегда хранит только актуальную версию. + +#### 3.1.3. Метаданные компакции + +``` +CompactionState: + min_available_seq : int64 -- самый старый курсор, который ещё можно запросить + last_compacted_at : timestamp +``` + +Если клиент запрашивает курсор `< min_available_seq` — сервер отвечает «курсор протух, делай full resync». + +### 3.2. На клиенте + +Клиент хранит **те же таблицы**, что и сервер (материализованное состояние), плюс дополнительно: + +``` +SyncState: + last_seq : int64 -- курсор, до которого клиент синхронизирован + last_sync_at : timestamp + +PendingEvent: + local_id : UUID, PK + type : enum + payload : JSON + created_at : timestamp + status : enum (pending, sent, failed) +``` + +`PendingEvent` — это события, которые клиент сгенерировал локально (пользователь написал сообщение, локальный ассистент ответил), но которые ещё не подтверждены сервером. + +**Важно:** `PendingEvent` не имеет `seq` — он появится только после подтверждения сервером. + +--- + +## 4. Абстракции кода + +### 4.1. Общие для клиента и сервера + +```kotlin +// Доменные события — общие +sealed interface DomainEvent { + val eventId: UUID + val type: EventType + val payload: EventPayload +} + +// Материализованное состояние — общее +interface StateStore { + fun readState(upToSeq: Long): StateSnapshot + fun applyEvent(event: LoggedEvent) // применяет событие к состоянию + fun lastSeq(): Long +} + +// Журнал — общий интерфейс чтения +interface EventLog { + fun readUpdates(afterSeq: Long, limit: Int): List + fun lastSeq(): Long +} + +// Событие с назначенным курсором +data class LoggedEvent( + val seq: Long, + val eventId: UUID, + val type: EventType, + val payload: EventPayload, + val createdAt: Instant, +) +``` + +### 4.2. Только сервер + +```kotlin +interface EventLogWriter : EventLog { + // Атомарно: назначает seq, пишет в журнал, применяет к материализации + fun append(event: DomainEvent): LoggedEvent + // Компакция + fun compact(upToSeq: Long) +} +``` + +### 4.3. Только клиент + +```kotlin +interface EventLogReplica : EventLog { + // Применить событие от сервера к локальному состоянию + fun applyRemote(event: LoggedEvent) + // Заменить всё локальное состояние состоянием с сервера + fun replaceState(snapshot: StateSnapshot, upToSeq: Long) + // Локальные (ещё не подтверждённые) события + fun pendingEvents(): List + fun markPendingAsSent(localId: UUID, seq: Long) + fun markPendingAsFailed(localId: UUID) +} +``` + +### 4.4. Ассистент — общая абстракция + +Ключевая идея прозрачности: **ассистент — это просто генератор событий**. Клиент не знает, локальный он или удалённый. + +```kotlin +interface Assistant { + // Запускает генерацию, возвращает поток событий + fun run(input: RunInput): Flow +} + +class RemoteAssistant(...) : Assistant { + override fun run(input: RunInput): Flow { + // Стримит события от сервера через WebSocket + } +} + +class LocalAssistant(...) : Assistant { + override fun run(input: RunInput): Flow { + // Генерирует события локально (например, вызывает локальную LLM) + } +} +``` + +Клиент подписывается на поток событий и применяет их так же, как события от сервера. Локальный ассистент порождает `PendingEvent`, который потом уходит на сервер и подтверждается. + +--- + +## 5. Протокол синхронизации + +### 5.1. Точки входа (API) + +**`GET /sync/state?upToSeq=N`** +- Возвращает материализованное состояние на момент N: `SELECT * FROM state WHERE last_seq <= N`. +- Если N не указан — возвращает актуальное состояние. +- Если N < `min_available_seq` — возвращает ошибку `cursor_expired` с указанием актуального `last_seq`. +- Ответ: `{ snapshot: StateSnapshot, upToSeq: N, currentSeq: M }`, где M — текущий максимальный курсор. + +**`GET /sync/updates?afterSeq=N&limit=K`** +- Возвращает события `WHERE seq > N ORDER BY seq LIMIT K`. +- Если N < `min_available_seq` — ошибка `cursor_expired`. +- Ответ: `{ events: [LoggedEvent], hasMore: bool, currentSeq: M }`. + +**`WS /sync/live?afterSeq=N`** +- WebSocket. Сервер пушит события `seq > N` в реальном времени. +- Если N < `min_available_seq` — сервер закрывает соединение с кодом `cursor_expired`. + +**`POST /sync/events`** +- Клиент отправляет `PendingEvent`(ы) на сервер. +- Сервер валидирует, назначает `seq`, применяет, возвращает `LoggedEvent`(ы). +- Идемпотентность: `eventId` (UUID). Повторная отправка того же `eventId` — no-op, возвращается уже назначенный `seq`. + +### 5.2. Алгоритм синхронизации на клиенте + +``` +sync(): + 1. Прочитать local lastSeq. + 2. Попробовать GET /sync/updates?afterSeq=lastSeq&limit=K. + 3. Если ответ cursor_expired: + 3.1. GET /sync/state (без upToSeq) → получить актуальное состояние и currentSeq. + 3.2. replaceState(snapshot, currentSeq). + 3.3. lastSeq = currentSeq. + 3.4. Перейти к шагу 5. + 4. Если ответ ок: + 4.1. Для каждого события в ответе: applyRemote(event). + 4.2. lastSeq = max(seq в ответе). + 4.3. Если hasMore — повторить с шага 2. + 5. Отправить все PendingEvent через POST /sync/events. + 5.1. Для каждого подтверждённого: markPendingAsSent(localId, seq). + 5.2. Для каждого неподтверждённого: markPendingAsFailed(localId). + 6. Открыть WS /sync/live?afterSeq=lastSeq. + 6.1. При получении события: applyRemote(event), lastSeq = event.seq. + 6.2. При разрыве: вернуться к шагу 2. +``` + +**Важно:** шаг 3 (full resync) **заменяет** состояние, а не мержит. Все локальные данные, которых нет в снапшоте, удаляются. Pending-события при этом **сохраняются** и отправляются после resync (шаг 5). + +### 5.3. Обработка локальных событий (пользователь пишет сообщение) + +``` +userSendsMessage(chatId, content): + 1. Создать DomainEvent(MessageAppended, {chatId, content, role: user, ...}). + 2. Сохранить в PendingEvent со status = pending. + 3. Применить событие к локальному состоянию (оптимистично), чтобы UI сразу показал сообщение. + 4. Запустить sync() в фоне. + 5. Когда сервер подтвердит — markPendingAsSent(localId, seq). + Если сервер вернул ошибку — markPendingAsFailed(localId), откатить состояние. +``` + +### 5.4. Обработка локального ассистента + +``` +localAssistantRuns(chatId, input): + 1. Запустить Assistant.run(input), получить Flow. + 2. Для каждого события в потоке: + 2.1. Сохранить в PendingEvent со status = pending. + 2.2. Применить к локальному состоянию. + 3. Запустить sync() — события уйдут на сервер, получат seq, станут частью журнала. +``` + +Клиент **не различает** события от пользователя, от локального ассистента и от удалённого. Все они идут через `PendingEvent` → сервер → `LoggedEvent`. + +--- + +## 6. Компакция журнала + +### 6.1. Зачем + +Журнал растёт бесконечно. Старые события уже «схлопнуты» в материализованное состояние. Их можно удалить. + +### 6.2. Как + +Периодический фоновый процесс на сервере: + +1. Определить `compaction_seq` = минимальный курсор, который ещё нужен активным клиентам. Если неизвестно — использовать `current_seq - safety_margin`. +2. Удалить события `WHERE seq <= compaction_seq`. +3. Обновить `CompactionState.min_available_seq = compaction_seq + 1`. + +### 6.3. Что делать клиенту с протухшим курсором + +Если `lastSeq < min_available_seq`: +- Не пытаться догрузить апдейты (их нет). +- Сделать **full resync**: `GET /sync/state` → `replaceState` → продолжить live sync. + +Это **не ошибка**, это штатный сценарий. Клиент просто получает актуальное состояние и продолжает жить. + +### 6.4. Ключевое правило + +**Full resync = replace, не merge.** Если клиент мержит, удалённые и отредактированные сущности останутся в старом виде. Если заменяет — всё консистентно. + +--- + +## 7. Обработка редактирования и удаления + +### 7.1. Редактирование + +Событие `MessageEdited` несёт **полный новый content** сообщения. + +- В журнал пишется событие с `seq = S`. +- В материализации строка обновляется на месте: `content = newContent`, `last_seq = S`. + +**Клиент на живом курсоре N < S:** +- Получает событие `MessageEdited` из журнала. +- Применяет: обновляет `content` у себя, ставит `last_seq = S`. + +**Клиент на протухшем курсоре, resync на M > S:** +- В снапшоте видит сообщение уже с новым content. +- Событие `S` не приходит (оно ≤ M). +- Всё консистентно. + +### 7.2. Удаление + +Событие `MessageDeleted`. В материализации строка либо **физически удаляется**, либо помечается `deleted = true` с `last_seq = S`. + +Рекомендуется **физическое удаление**, потому что replace при resync всё равно уберёт строку. Tombstones нужны только если требуется показывать «сообщение удалено» в UI. + +**Клиент на живом курсоре N < S:** +- Получает событие `MessageDeleted`. +- Удаляет строку у себя. + +**Клиент на протухшем курсоре, resync на M > S:** +- В снапшоте строки нет. +- replace убирает её у клиента. +- Событие `S` не приходит. +- Всё консистентно. + +--- + +## 8. Гарантии и краевые случаи + +### 8.1. Идемпотентность + +Каждое событие имеет `eventId` (UUID). Применение события с уже известным `eventId` — no-op. Это защищает от: +- повторной отправки `PendingEvent`; +- повторного применения события при реконнекте; +- дублирования в live sync. + +### 8.2. Порядок событий + +Клиент применяет события **в порядке `seq`**. Если пришло событие с `seq > lastSeq + 1`, значит есть пропуск — клиент должен догрузить пропущенные через `GET /sync/updates?afterSeq=lastSeq`. + +### 8.3. Конфликты + +Конфликты не разрешаются «в общем виде». Правила: +- Сообщения **append-only** (редактирование = новое событие, не мутация). +- Правки от разных устройств одного пользователя — сервер применяет в порядке поступления, последняя побеждает (last-write-wins по `seq`). +- Ветвления (regenerate) — через `parent_id`, а не через мутацию. + +### 8.4. Офлайн-запись + +Пользователь пишет офлайн → событие в `PendingEvent` → применяется локально → UI показывает. При появлении сети → sync → сервер назначает `seq` → `markPendingAsSent`. + +Если сервер отверг событие (валидация не прошла) → `markPendingAsFailed` → клиент откатывает локальное изменение. + +### 8.5. Мультиустройство + +У каждого клиента есть `device_id`. События помечаются `origin`. При синке клиент не применяет свои же события повторно (идемпотентность по `eventId` решает это автоматически). + +### 8.6. Большие снапшоты + +`GET /sync/state` может вернуть много данных. Решения: +- Пагинация: `GET /sync/state?upToSeq=N&chatId=X` — по одному чату. +- Стриминг: HTTP chunked / gRPC streaming. +- Сжатие (gzip / zstd). + +Для чата с ассистентом обычно достаточно per-chat снапшотов. + +--- + +## 9. Что должен реализовать кодовый агент + +### 9.1. Сервер + +1. **Хранилище журнала событий** (`EventLog`): append-only, монотонный `seq`, компакция. +2. **Хранилище материализованного состояния** (`StateStore`): таблицы `Chat`, `Message`, поле `last_seq`. +3. **Логика применения события** (`applyEvent`): обновляет материализацию, ставит `last_seq`. +4. **Атомарная операция `append`**: в одной транзакции назначает `seq`, пишет в журнал, применяет к материализации. +5. **HTTP API**: `/sync/state`, `/sync/updates`, `/sync/events`. +6. **WebSocket**: `/sync/live`. +7. **Фоновый компактор**: периодически удаляет старые события, обновляет `min_available_seq`. +8. **Идемпотентность**: таблица `processed_event_ids` или проверка по `eventId`. + +### 9.2. Клиент + +1. **Локальное хранилище состояния** (`StateStore`): те же таблицы, что на сервере. +2. **Локальное хранилище `PendingEvent`**: очередь несинхронизированных событий. +3. **Логика применения события** (`applyEvent`): общая с сервером (один код). +4. **Логика `replaceState`**: полная замена локального состояния. +5. **Sync-клиент**: реализует алгоритм из раздела 5.2. +6. **WebSocket-клиент**: live sync. +7. **UI**: рисует исключительно из локального `StateStore`. Никогда не ждёт сеть. +8. **Ассистенты**: `LocalAssistant` и `RemoteAssistant` через общий интерфейс `Assistant`, возвращающий `Flow`. + +### 9.3. Общее + +1. **Модель `DomainEvent`** с самодостаточным payload. +2. **Интерфейсы** `EventLog`, `StateStore`, `Assistant`. +3. **Логика сериализации/десериализации** событий. +4. **UUID-генерация** для `eventId`. +5. **Логика идемпотентности** по `eventId`. + +--- + +## 10. Чего делать НЕ надо + +1. **Не делать MVCC.** Строки хранят только текущую версию. История — в журнале, но она компактится. +2. **Не делать tombstones**, если не нужно показывать «удалено». Физическое удаление + replace при resync решают всё. +3. **Не мержить при full resync.** Только replace. +4. **Не запрашивать сервер для отрисовки.** UI читает только локальную базу. +5. **Не различать локального и удалённого ассистента на уровне клиента.** Оба — `Assistant`, возвращающий `Flow`. +6. **Не хранить счётчик в БД отдельно.** `seq` — это либо sequence в БД, либо ULID в событии. Отдельный «счётчик» — лишняя сущность. +7. **Не бояться, что «старый курсор протух».** Это штатный сценарий: full resync. + +--- + +## 11. Критерии готовности + +1. Пользователь может писать офлайн, UI обновляется мгновенно. +2. При появлении сети события уходят на сервер, получают `seq`, синхронизируются. +3. Второй клиент видит изменения в реальном времени. +4. Локальный ассистент работает так же, как удалённый, с точки зрения клиента. +5. Компакция журнала не ломает синхронизацию: клиент с протухшим курсором делает full resync и продолжает. +6. Редактирование и удаление сообщений обрабатываются консистентно во всех сценариях. +7. Full resync заменяет состояние, не оставляя «фантомных» строк. +8. Идемпотентность: повторная отправка события не создаёт дублей. + +--- + +## 12. Резюме идеи в одном абзаце + +Есть **журнал событий** с монотонным курсором и **материализованное состояние**, где каждая строка помечена курсором последнего изменившего её события. Состояние на курсоре N — это строки с `last_seq <= N`. Апдейты после N — это события с `seq > N`. Клиент хранит локальную копию состояния и свой `lastSeq`. Для синхронизации он либо догружает апдейты (если курсор жив), либо заменяет состояние целиком (если курсор протух из-за компакции). В обоих случаях результат консистентен: клиент видит актуальные данные, не видит «шума» про отредактированные/удалённые сущности, которые были до его курсора, и продолжает live sync с нового курсора. Локальный и удалённый ассистенты — просто генераторы событий, неотличимые для клиента. \ No newline at end of file diff --git a/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt b/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt index 9671a3c..b5d5776 100644 --- a/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt +++ b/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt @@ -5,9 +5,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.JournalStore +import pw.binom.agentik.outbox.Cursor import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.AgentInfo +import pw.binom.agentik.proto.ChatSnapshot +import pw.binom.agentik.proto.ConversationsSnapshot import pw.binom.agentik.content.Content import pw.binom.agentik.proto.Conversation import pw.binom.agentik.outbox.Event @@ -34,14 +37,21 @@ internal class FakeAgent( // emptyFlow, journal — error-on-access (никто не должен его трогать). override val journal: JournalStore = error("journal not used in TuiBackend tests") override val outbox: OutboxStore = object : OutboxStore { - override fun events(after: Instant?) = emptyFlow() - override fun agentEvents(after: Instant?) = emptyFlow() - override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow() - override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override fun events(after: Cursor?) = emptyFlow() + override fun agentEvents(after: Cursor?) = emptyFlow() + override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() + override suspend fun currentCursor(): Cursor = Cursor("test", 0L) + override suspend fun oldestCursor(): Cursor = Cursor("test", 0L) override fun close() {} } override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests") + override suspend fun conversationsSnapshot(): ConversationsSnapshot = + ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L)) + + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot = + ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L)) + override fun createConversation(temp: Boolean): Conversation { createCount++ val c = conversationFactory() diff --git a/client/README.md b/client/README.md index 7b78f56..5dd6521 100644 --- a/client/README.md +++ b/client/README.md @@ -13,14 +13,19 @@ `deleteConversation` / `journal` / `outbox` / `close`. - `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow`) / `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`. -- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List` - со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens). -- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE). +- `HttpJournalStore` — `list(convId, afterSeq, upToSeq, limit)` / + `count(convId, afterSeq)` → `List` со всеми типами записей + (User/Assistant/ToolCall/ToolResult/Error + tokens). Адресация — по `seq` + (см. «Курсорный протокол»), не по датам. +- `HttpEventStore` — `events(after: Cursor?)` / `agentEvents` / + `conversationEvents` (SSE), `currentCursor()` / `oldestCursor()`. +- `Agent.conversationsSnapshot()` / `Agent.chatSnapshot(convId)` — состояние + + `Cursor`, на котором оно валидно. Точка входа resync'а. - `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с авто-reconnect при обрыве стрима (exponential backoff). Два независимых - потока: `events()` (те же `CommonEvent`) и `connectionStatus()` - (`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается - с основным потоком событий. См. ниже. + потока: `events(after: Cursor?)` (те же `CommonEvent`) и `connectionStatus()` + (`Connecting`/`Connected`/`Disconnected`/`Failed`/`Gap`) — статус НЕ мешается + с основным потоком событий. Мёртвый курсор даёт `Gap` (не ретраится). См. ниже. `Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины. @@ -65,6 +70,95 @@ dependencies { ⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере. +## Курсорный протокол (как получить гарантированно актуальное состояние) + +Всё серьёзное в `:client` крутится вокруг одного понятия — **курсор события** +(`Cursor(epoch, offset)`), аналога Kafka-offset. Он монотонный, сквозной на +все события агента (одна общая нумерация для `AgentEvent` и `Conversation` +-событий) и лежит **над** двумя хранилищами: + +- **`OutboxStore`** — короткий bounded-tail live-поток `CommonEvent` + (уведомления/дельты). Хранится ограниченно (cap/TTL), события вытесняются. +- **`journal` + `conversationStore`** — персистентный источник истины + (полное состояние). У каждой записи есть свой `seq` из того же счётчика. + +Ключевое свойство: **`CommonEvent.offset` == `MessageRecord.seq` == +`ConversationRecord.seq`**. Событие с `offset = N` — это ровно «строка состояния +с `seq = N` изменилась (или появилась/удалилась)». События **абсолютные**: в +`UserMessage`/`AssistantMessage` лежит целая запись, `Renamed` несёт новый +заголовок, `Deleted` — «строки больше нет». Поэтому накатывать их на состояние +можно повторно (идемпотентно по `id`) и в любом порядке относительно снапшота. + +### Инвариант, на котором стоит гарантия + +1. **Писатель** (сервер) сначала пишет строку состояния с `seq = N`, потом + кладёт событие с `offset = N` в outbox. `seq` и `offset` — один счётчик. +2. **Читатель** (клиент) читает **сначала курсор, потом состояние**: + `C = currentCursor()` → `state = listUpTo(C)`. Всё, что `≤ C`, уже в снапшоте; + всё, что `> C`, придёт потоком. +3. **Применение идемпотентно** (upsert/delete/rename по `id`), поэтому + перекрытие снапшота и дельт безвредно. + +Ничего не блокируется. Снапшот — это **не** «заморозка таблицы на время +выгрузки»: это baseline на курсоре `C` плюс накат всех дельт `> C`. + +### Правильная последовательность синхронизации + +``` +1. lastSeen = локально сохранённый курсор (или null при первом запуске) +2. попытка: outbox.events(after = lastSeen) ← если сервер ответил + OutboxGapException / ConnectionStatus.Gap → курсор мёртв, иди в п.3 +3. ПОЛНЫЙ RESYNC: + a. очистить локальную БД (строки + курсор), пометить «resyncing» + b. C = agent.conversationsSnapshot().cursor (или .chatSnapshot(convId)) + c. подписаться events(after = C) и СКОПИРОВАТЬ события в буфер (не применять!) + d. прочитать полное состояние: listUpTo(C) / snapshot.messages + e. применить снапшот целиком + f. применить буфер дельт в порядке offset +4. дальше: применение каждого события из потока (upsert by id) +5. сохранить последний offset как lastSeen +``` + +Порядок из шага 3 критичен: **сначала подписка, потом снапшот**. Если сделать +наоборот (снапшот, потом подписка) — события, пришедшие в промежуток, потеряются. +Буферизация (а не «применять на лету») закрывает delete-resurrection: событие +`Deleted(offset > C)` для строки, которая ещё лежит в необработанной странице +снапшота, при применении «на лету» было бы стёрто, а потом снапшот вставил бы +строку обратно. + +### Курсор мёртв: `OutboxGapException` + +Клиент давно не заходил, outbox вытеснил его события (`after.offset < +oldestCursor().offset`), либо сменилась `epoch` (БД сервера откатили/ +восстановили/скопировали — счётчик начал считаться заново). Сервер отвечает +`410 Gone`. Клиент **не ретраит** — это сигнал «сделай полный resync» +(шаг 3 выше). С `ReconnectingOutbox` это приходит как +`ConnectionStatus.Gap`, поток закрывается, background-loop встаёт. + +**Никогда не ретрай `OutboxGapException`** — ретрай никогда не пройдёт. + +### Простой вариант: пересоздать outbox на resync + +Если своя реализация шага 3 кажется тяжёлой — минимальный корректный путь +через `ReconnectingOutbox`: + +```kotlin +var recon = ReconnectingOutbox(agent.outbox, scope) +scope.launch { recon.events(after = lastSeen).collect { applyEvent(it) } } +scope.launch { + recon.connectionStatus().collect { s -> + if (s is ConnectionStatus.Gap) { + recon.close() + val snap = agent.chatSnapshot(convId) // state + cursor + applySnapshot(snap.messages) // upsert by id + lastSeen = snap.cursor + recon = ReconnectingOutbox(agent.outbox, scope) + scope.launch { recon.events(after = lastSeen).collect { applyEvent(it) } } + } + } +} +``` + ## Быстрый старт: свой клиент за 5 минут Один self-contained пример: создаём агента, открываем диалог, @@ -73,12 +167,11 @@ dependencies { ```kotlin import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.content.Content -import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.DurableEvent import pw.binom.agentik.outbox.OnlineEvent import io.ktor.client.engine.cio.CIO import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlin.time.Clock fun main() = runBlocking { // 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри. @@ -91,17 +184,20 @@ fun main() = runBlocking { val conv = agent.createConversation(temp = false) + // Подписка «после текущего курсора» — событий строго после этой точки. + val cursor = agent.outbox.currentCursor() + // 2. Два независимых потока событий диалога: // durable (outbox) — целые события, с курсором после переподключения; // online (OnlineOutbox) — стриминг ответа, только live (без курсора). launch { - agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id) + agent.outbox.conversationEvents(after = cursor, conversationId = conv.id) .collect { ce -> when (val ev = ce.event) { - is Event.AssistantMessage -> println("[answer ready: ${ev.content}]") - is Event.Interrupted -> println("[interrupted]") - is Event.Error -> println("[error: ${ev.message}]") - else -> Unit + is DurableEvent.AssistantMessage -> println("[answer ready: ${ev.content}]") + is DurableEvent.Interrupted -> println("[interrupted]") + is DurableEvent.Error -> println("[error: ${ev.message}]") + else -> Unit } } } @@ -129,12 +225,12 @@ fun main() = runBlocking { **Это весь клиент.** `:server` сам хранит историю, контекст, события. Ты только получаешь два типизированных `Flow` и рендеришь как хочешь. -> **Durable vs online.** `Event` (в `agent.outbox`) — «целые» события, их +> **Durable vs online.** `DurableEvent` (в `agent.outbox`) — «целые» события, их > можно перезапросить по курсору `after`. `OnlineEvent` (в > `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/ > `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при > обрыве фрагмент невосстановим, но целый ответ всегда придёт durable- -> `Event.AssistantMessage` и/или ляжет в journal. +> `DurableEvent.AssistantMessage` и/или ляжет в journal. `HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто внутри `AgentikAgent`. Один вызов — один готовый `Agent`. @@ -143,19 +239,20 @@ fun main() = runBlocking { ```kotlin import pw.binom.agentik.journal.inmemory.InMemoryJournalStore -import kotlin.time.Instant // Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам. val cache = InMemoryJournalStore() -// Backfill + live-refresh в одном фоне: +// Снапшот на курсоре + подписка ПОСЛЕ него — без потерь (см. «Курсорный протокол»). +val snap = agent.chatSnapshot(conv.id) +cache.appendAll(snap.messages) launch { - agent.journal.listFlow(conv.id, Instant.DISTANT_PAST) - .collect { cache.append(it) } + agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id) + .collect { ce -> applyDurable(ce.event, cache) } // upsert by id } // История — теперь из кэша, без HTTP: -val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE) +val history = cache.list(conv.id, afterSeq = 0L, upToSeq = Long.MAX_VALUE, limit = Int.MAX_VALUE) history.forEach { rec -> when (rec) { is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}") @@ -167,17 +264,18 @@ history.forEach { rec -> } ``` -Шаблон "remote.listFlow → local.append" работает с любым -`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование -"без геморроя". +Шаблон «snapshot(курсор) → local.apply → live-дельты после курсора» работает с +любым `MutableJournalStore` (см. `:journal-api`). Это и есть кэширование +«без геморроя» с гарантией актуальности. ### Что вообще не нужно писать самому -- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует +- HTTP-сериализация `DurableEvent`/`Message` — `agentikHttpClient` регистрирует `agentikJson` и `InstantSerializer`. - SSE-парсер — `readSse()` внутри `:client`. -- Cursor-менеджмент для `listFlow` — дефолтная имплементация в - `JournalStore.listFlow` сама пагинирует. +- Cursor-менеджмент — сервер ведёт единый монотонный `offset`/`seq`, клиент + лишь хранит `Cursor(epoch, offset)`. Никаких `Instant`-сравнений и + pagination-циклов вручную. - Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job. - HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)` с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает. @@ -187,7 +285,7 @@ history.forEach { rec -> ### Что нужно написать самому -- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI). +- UI-рендеринг `DurableEvent`'ов — это твоё (Compose/HTML/CLI). - Диалог с пользователем — ввод текста, отображение кнопок и т.п. - Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore` на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример). @@ -198,7 +296,7 @@ history.forEach { rec -> ```kotlin import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.content.Content -import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.DurableEvent import pw.binom.agentik.outbox.OnlineEvent import io.ktor.client.engine.cio.CIO import kotlinx.coroutines.launch @@ -212,13 +310,14 @@ val agent = AgentikAgent( val conv = agent.createConversation(temp = false) // durable-поток (с курсором): terminal-события хода. +val cursor = agent.outbox.currentCursor() launch { - agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id) + agent.outbox.conversationEvents(after = cursor, conversationId = conv.id) .collect { ce -> when (ce.event) { - is Event.AssistantMessage -> println("\n--- answer ready ---") - is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}") - else -> Unit + is DurableEvent.AssistantMessage -> println("\n--- answer ready ---") + is DurableEvent.Error -> error("agent error: ${(ce.event as DurableEvent.Error).message}") + else -> Unit } } } @@ -235,268 +334,122 @@ launch { conv.send(listOf(Content.Text("Привет, расскажи про себя"))) ``` +## Локальный кэш истории (правильный паттерн) -## История с локальным кэшем - -Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически -(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё -чтение истории — из локального кэша. - -`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать -свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы -реализовывал интерфейс. +Клиент держит свой `MutableJournalStore` и наполняет его **снапшотом на +курсоре + дельтами после курсора** (см. «Курсорный протокол»). Чтение истории — +из локального кэша, без HTTP. ```kotlin import pw.binom.agentik.journal.inmemory.InMemoryJournalStore -import pw.binom.agentik.content.Content -import pw.binom.agentik.outbox.Event -import kotlin.time.Instant +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.outbox.DurableEvent + +// Кэш. Для диска — свой MutableJournalStore (KsqliteJournalStore в :journal-ksqlite). +val cache = InMemoryJournalStore() class ChatSession( private val agent: pw.binom.agentik.proto.Agent, val conversationId: String, ) : AutoCloseable { - - // Локальный кэш. Замените InMemoryJournalStore на свой, если нужна - // персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore` - // (модуль `:journal-api`). - val cache = InMemoryJournalStore() - - // Подписка на live-события этого диалога — будем обновлять кэш на `End`. private val scope = kotlinx.coroutines.CoroutineScope( - kotlinx.coroutines.SupervisorJob() + - kotlinx.coroutines.Dispatchers.Default, + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Default, ) + var lastSeen: pw.binom.agentik.outbox.Cursor? = null init { - // 1. Backfill: забираем всю историю разговора с сервера. scope.launch { - agent.journal.listFlow( - conversationId = conversationId, - after = Instant.DISTANT_PAST, - ).collect { cache.append(it) } - } - // 2. Live: на каждом завершённом ходе (durable AssistantMessage) - // просим у сервера новые записи. - scope.launch { - agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce -> - if (ce.event is Event.AssistantMessage) { - val newest = cache.let { - // last-seen курсор — последний createdAt в кэше - it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt - ?: Instant.DISTANT_PAST - } - agent.journal.list(conversationId, newest, offset = 0, limit = 100) - .forEach { cache.append(it) } + // 1. Снапшот: состояние + курсор, на котором оно валидно. + val snap = agent.chatSnapshot(conversationId) + snap.messages.forEach { cache.append(it) } + lastSeen = snap.cursor + // 2. Дельты строго после курсора снапшота. + agent.outbox.conversationEvents(after = snap.cursor, conversationId = conversationId) + .collect { ce -> + applyToCache(ce.event) + lastSeen = ce.cursor } - } + } + } + + private suspend fun applyToCache(e: DurableEvent) { + when (e) { + is DurableEvent.UserMessage -> cache.append(e.toRecord()) + is DurableEvent.AssistantMessage -> cache.append(e.toRecord()) + is DurableEvent.ToolCall -> cache.append(e.toRecord()) + is DurableEvent.ToolResult -> cache.append(e.toRecord()) + is DurableEvent.Error -> cache.append(e.toRecord()) + is DurableEvent.Interrupted -> Unit } } fun history() = kotlinx.coroutines.runBlocking { - cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE) + cache.list(conversationId, afterSeq = 0L, upToSeq = Long.MAX_VALUE, limit = Int.MAX_VALUE) } - override fun close() { - scope.cancel() - } -} - -// Использование: -val session = ChatSession(agent, conv.id) - -// История — из кэша: -session.history().forEach { rec -> - when (rec) { - is MessageRecord.UserMessage -> println("user: ${rec.content.text()}") - is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}") - is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}") - is MessageRecord.ToolResult -> println("tool-result: ${rec.result}") - is MessageRecord.Error -> println("error: ${rec.message}") - } -} - -// Отправить новое сообщение: -session.scope.launch { - agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз"))) + override fun close() { scope.cancel() } } ``` -`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом -(текст + tool-call/tool-result + tokens). UI сам решает что показать — -`rec is MessageRecord.UserMessage` для реплик пользователя, -`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п. +> `applyToCache` через `cache.append` даёт upsert по `id` (append-only store +> отбрасывает дубликаты `id`), поэтому перекрытие снапшота и дельт безвредно. +> Замените `InMemoryJournalStore` на `KsqliteJournalStore` — код не меняется. + +### Когда курсор мёртв + +Если `conversationEvents(after = ...)` бросает `OutboxGapException` (или +`ReconnectingOutbox` эмитит `ConnectionStatus.Gap`) — клиент был оффлайн дольше +retention'а. Повторите всю последовательность с шага 1 (снапшот), **предварительно +очистив локальную БД** (`cache.clear(conversationId)`), иначе воскреснут +удалённые строки. Полный алгоритм — в «Курсорный протокол» выше. ## Кэш списка бесед -`agent.conversationStore` — read-only view поверх `conversation`-таблицы +`agent.conversationStore` — read-only projection поверх таблицы `conversation` на сервере (`ConversationRecord` = id / title / isTemporal / createdAt / -updatedAt, без `Conversation` handle и без флагов image-support). - -**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы -при открытии UI уже знал названия, не дёргал сервер лишний раз, и -моментально реагировал на создание/удаление/переименование в другой -вкладке. - -Подход — тот же **«remote → local snapshot + live-events»**: +updatedAt). `AgentikAgent` оборачивает его в локальный кэш +(`wrapWithLocalConversationCache`) по тому же протоколу, что и историю: +снапшот на курсоре + live-дельты. ```kotlin import pw.binom.agentik.client.AgentikAgent -import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.outbox.AgentEvent import io.ktor.client.engine.cio.CIO -// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш: -// remote.listFlow → local.upsert (snapshot) -// outbox.agentEvents → local.upsert / delete (live) -val agent = AgentikAgent( - id = "agentik", - baseUrl = "http://localhost:8080/agentik", - engineFactory = CIO, -) +val agent = AgentikAgent(id = "agentik", baseUrl = "http://localhost:8080/agentik", engineFactory = CIO) -// Кэш уже наполняется в фоне, читать можно сразу: -val all = agent.conversationStore.list(0, Int.MAX_VALUE) -all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") } +// Снапшот списка бесед + его курсор (глобальный для агента). +val snap = agent.conversationsSnapshot() +snap.conversations.forEach { println("${it.id} ${it.title ?: "(no title)"} ${it.updatedAt}") } -// И наблюдать live-изменения (Created/Deleted/Renamed/Touched) -agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev -> - when (ev) { - is AgentEvent.Created -> println("+ ${ev.conversationId}") - is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}") - is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})") - is AgentEvent.Deleted -> println("- ${ev.id}") +// Дельты после курсора снапшота: Created / Deleted / Renamed / Touched. +agent.outbox.agentEvents(after = snap.cursor).collect { ce -> + when (val ev = ce.event) { + is AgentEvent.Created -> println("+ ${ev.conversationId}") + is AgentEvent.Renamed -> println("~ ${ev.id} -> ${ev.title}") + is AgentEvent.Touched -> println("~ ${ev.id} (${ev.updatedAt})") + is AgentEvent.Deleted -> println("- ${ev.id}") } } ``` -Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP -для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как -escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен — -подмени его на свою реализацию через `wrapWithLocalConversationCache`, -если нужен SQLite/JSON-store. - -## Стриминг live-ответа - -Для streaming-рендера текущего хода подписывайся на `events()` и -собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** — -только для UI-feedback во время хода. После `End` хода запись уже -появится в кэше через refresh-блок выше. +**Команды** (создать / переименовать / удалить) идут через `agent`; сервер сам +эмитит соответствующее `AgentEvent` в outbox, клиент применяет его к кэшу: ```kotlin -import pw.binom.agentik.outbox.OnlineEvent - -agent.onlineOutbox.onlineEvents(convId).collect { ev -> - when (ev) { - is OnlineEvent.Working -> println("[working]") - is OnlineEvent.StartResponse -> println("[start]") - is OnlineEvent.AppendText -> print(ev.body) - is OnlineEvent.AppendImage -> showImage(ev.body) - is OnlineEvent.End -> println("[end]") - else -> Unit - } -} +val conv = agent.createConversation(temp = false) // POST /conversations -> Created +agent.renameConversation(conv.id, "Новый заголовок") // PATCH /conversations/{id} -> Renamed +agent.deleteConversation(conv.id) // DELETE /conversations/{id} -> Deleted ``` -Инструментальные вызовы и целый ответ — durable-поток -(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и -`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`. - -## Прерывание хода - -```kotlin -agent.getConversation(convId)!!.interrupt() -``` - -## Multi-conversation - -Один `Agent`, много `ChatSession`: - -```kotlin -val sessions = mutableMapOf() - -fun open(convId: String): ChatSession = - sessions.getOrPut(convId) { ChatSession(agent, convId) } - -fun close(convId: String) { - sessions.remove(convId)?.close() -} -``` - -Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) + -UI-обновление списка — отдельная задача, решается `Flow`. - -## Где `:client` НЕ помогает - -- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только - отдаёт типы и потоки. -- **Персистентность кэша** — `InMemoryJournalStore` и - `InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой - `MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore` - в `:journal-ksqlite` как образец). -- **Нестандартные движковые настройки** — для `requestTimeout`, - прокси и т.п. используй `agentikHttpClient(engineFactory, token)` - напрямую. - -## Кэш списка бесед - -`agent.conversationStore`, который видит клиент — это **локальный кэш**, -а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`) -лежит `InMemoryMutableConversationStore`, синхронизированный с сервером: - -1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем - в `localStore.upsert(...)`. -2. **Live-обновления**: подписка на `outbox.agentEvents(after)`: - - `Created(id)` → `remote.get(id)` → `local.upsert(record)` - - `Deleted(id)` → `local.delete(id)` - - `Renamed(id, title)` → `local.rename(id, title)` - - `Touched(id, updatedAt)` → `local.touch(id, updatedAt)` - -UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без -HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит -`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox, -клиент видит их через SSE и применяет к локальной копии. - -**Команды** (создать / переименовать / удалить) идут через `agent`: - -```kotlin -// Создать новую беседу: -val conv = agent.createConversation(temp = false) // → POST /conversations - // → server эмитит Created - // → client cache получает Created - // → UI увидит её в списке -// Переименовать: -agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id} - // → server эмитит Renamed - // → client cache обновляет title -// Удалить: -agent.deleteConversation(conv.id) // → DELETE /conversations/{id} - // → server эмитит Deleted - // → client cache удаляет запись -``` - -`conversationStore` доступен **только для чтения**. Это read-only projection -на серверную таблицу `conversation` (id + title + timestamps). Для активной -работы (send / interrupt) получай handle через `agent.getConversation(id)`. - -**Никогда не пиши в `conversationStore` напрямую.** Все модификации — -командами `agent.createConversation / deleteConversation / renameConversation`. +**Никогда не пиши в `conversationStore` напрямую.** Для активной работы +(send / interrupt) — handle через `agent.getConversation(id)`. ### Если хочется своего cache-импла -`InMemoryMutableConversationStore` подходит для 99% случаев — Map + -Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление -после перезапуска) — реализуй свой `MutableConversationStore` поверх -SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в -`:journal-ksqlite` как образец. - -```kotlin -import pw.binom.agentik.journal.MutableConversationStore -import pw.binom.agentik.journal.ConversationRecord - -class MySqliteConversationStore(db: MyDb) : MutableConversationStore { - override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ } - override suspend fun get(id: String): ConversationRecord? { /* SELECT */ } +`InMemoryMutableConversationStore` подходит для большинства случаев. Для диска — +свой `MutableConversationStore` (см. `KsqliteMutableConversationStore` в +`:journal-ksqlite`). Методы `rename`/`touch` принимают `seq` из общего счётчика: override suspend fun list(offset: Int, limit: Int): List { /* SELECT ORDER BY updatedAt DESC */ } override suspend fun delete(id: String): Boolean { /* DELETE */ } override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ } @@ -511,15 +464,17 @@ class MySqliteConversationStore(db: MyDb) : MutableConversationStore { ./gradlew :client:jvmTest ``` -Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва, -401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв + -reconnect / exhausted attempts → Failed / close → cancel). +Покрывают: JSON-парсинг `DurableEvent`-ов, SSE-стрим, recovery после разрыва, +401/404, reconnect-cycle `ReconnectingOutbox` (5 кейсов: успех / обрыв + +reconnect / exhausted attempts → Failed / мёртвый курсор → Gap (без ретрая) / +close → cancel). ## Auto-reconnect для живого outbox -Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная -сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`. -Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя: +Базовый `OutboxStore.events(after: Cursor?)` — cold SSE-стрим; при обрыве +(мобильная сеть, рестарт сервера) клиент должен сам реконнектиться с курсором +последнего увиденного события. Это повторяется в каждом клиенте, поэтому +`ReconnectingOutbox` берёт это на себя: ```kotlin val recon = ReconnectingOutbox( @@ -528,7 +483,8 @@ val recon = ReconnectingOutbox( policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter ) -scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } } +// lastSeen — курсор из последнего снапшота / последнего события. +scope.launch { recon.events(after = lastSeen).collect { handle(it) } } scope.launch { recon.connectionStatus().collect { status -> when (status) { @@ -536,6 +492,7 @@ scope.launch { is Connected -> ui.hideBanner() is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…") is Failed -> ui.showError(status.cause) + is Gap -> resync(status.cause) // курсор мёртв — полный resync } } } @@ -544,6 +501,11 @@ scope.launch { recon.close() // отменяет background-loop, потоки терминируются ``` +`Gap` — единственный статус, который **не** ретраится: курсор старше +retention'а или чужая эпоха. Обработка — полный resync (см. «Курсорный +протокол»). Если не передать `after`, при старте берётся +`outbox.currentCursor()` (live-only семантика). + Два потока **независимы** — `events()` содержит только `CommonEvent`, `connectionStatus()` содержит только `ConnectionStatus`. Никакого "мешающего" `Connecting`/`Disconnected` в потоке событий. diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt index fc9c358..f06ad60 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt @@ -18,7 +18,9 @@ import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.AgentInfo +import pw.binom.agentik.proto.ChatSnapshot import pw.binom.agentik.proto.Conversation +import pw.binom.agentik.proto.ConversationsSnapshot import kotlin.time.Instant /** @@ -81,6 +83,22 @@ internal class AgentClient private constructor( return rec.updatedAt } + override suspend fun conversationsSnapshot(): ConversationsSnapshot { + val response = httpClient.get("$agentUrl/snapshot") + check(response.status == HttpStatusCode.OK) { + "snapshot: server returned ${response.status}" + } + return response.body() + } + + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot { + val response = httpClient.get("$agentUrl/conversations/$conversationId/snapshot") + check(response.status == HttpStatusCode.OK) { + "conversations/$conversationId/snapshot: server returned ${response.status}" + } + return response.body() + } + override fun close() { httpClient.close() } diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt index ee2fd00..104d0e5 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt @@ -1,12 +1,14 @@ package pw.binom.agentik.client import io.ktor.client.engine.HttpClientEngineFactory +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import pw.binom.agentik.journal.ConversationRecord @@ -14,8 +16,9 @@ import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.MutableConversationStore import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore import pw.binom.agentik.outbox.AgentEvent +import pw.binom.agentik.outbox.OutboxGapException import pw.binom.agentik.proto.Agent -import kotlin.time.Instant +import kotlin.time.Duration.Companion.seconds /** * Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`). @@ -34,8 +37,9 @@ import kotlin.time.Instant * ) * val conv = agent.createConversation(temp = false) * conv.send(listOf(Content.Text("hi"))) - * agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id) - * .map { it.event } + * // Курсор-протокол: сначала снапшот (state + cursor), потом подписка «после»: + * val snap = agent.chatSnapshot(conv.id) + * agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id) * .collect { ... } * agent.close() // закрывает HttpClient + локальный кэш * ``` @@ -76,10 +80,12 @@ import kotlin.time.Instant * * [conversationStore], который видит клиент — это **кэш**, не прямой HTTP. * Внутри лежит [InMemoryMutableConversationStore], который: - * 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`. - * 2. Подписывается на `outbox.agentEvents(after)` → для каждого + * 1. На старте берёт `conversationsSnapshot()` (полный список + курсор) и + * приводит к нему локальную копию. + * 2. Подписывается на `outbox.agentEvents(after = snapshot.cursor)` → для каждого * [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет * соответствующий `upsert/delete/rename/touch` к локальной копии. + * 3. При `OutboxGapException` повторяет с шага 1 (полный resync). * * UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, * без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут @@ -103,17 +109,23 @@ fun AgentikAgent( /** * Оборачивает [Agent] так, что [Agent.conversationStore] становится * локальным in-memory кэшем, синхронизированным с удалённым стором - * через outbox-события. + * по курсор-протоколу. * - * - **Seed**: при создании делает один snapshot через - * `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore]. - * - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет - * `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу. + * **Протокол синхронизации** (гарантирует актуальный список бесед): + * 1. `conversationsSnapshot()` — база (полный список) + курсор `C`. + * 2. `outbox.agentEvents(after = C)` — дельты, применяются поверх базы + * (`Created`/`Deleted`/`Renamed`/`Touched`, все абсолютные и идемпотентные). + * 3. [OutboxGapException] (курсор мёртв — retention / смена epoch) → повтор + * с шага 1 (полный resync: `reconcile` удаляет локальные беседы, которых + * нет в снапшоте, и upsert'ит все из снапшота). + * 4. Прочие ошибки (сеть) → пауза и повтор. * * Возвращает обёртку, у которой переопределён только [Agent.conversationStore] * (на read-only projection локального [InMemoryMutableConversationStore]). * Остальные методы [Agent] — delegated в [delegate]. */ +private val RESYNC_RETRY_DELAY = 2.seconds + private fun wrapWithLocalConversationCache( delegate: Agent, scopeClient: Agent, @@ -124,38 +136,59 @@ private fun wrapWithLocalConversationCache( private val syncJob: Job init { - // Делаем cacheStore read-only view на localStore. - // (Через вложенный класс — см. ниже.) - // Запускаем seed + live-refresh параллельно. - syncJob = cacheScope.launch { - // 1. seed — snapshot всех текущих бесед с сервера - try { - delegate.conversationStore.listFlow(offset = 0, pageSize = ConversationStore.PAGE_SIZE) - .collect { rec -> localStore.upsert(rec) } - } catch (_: Throwable) { - // seed может упасть (offline / 5xx) — не критично, - // live-источник всё равно догонит при первом событии. - } + syncJob = cacheScope.launch { syncLoop() } + } - // 2. live — применяем outbox-события. - // Используем `first()` для knownId после Created — потом отписываемся, - // потому что Created нужно вытянуть полный record через `remote.get(id)`. - // Renamed/Touched меняют локальную копию без round-trip. - delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce -> - when (val ev = ce.event) { - is AgentEvent.Created -> { - // Created не несёт title/timestamps — нужно сходить в remote. - val rec = delegate.conversationStore.get(ev.conversationId) - if (rec != null) localStore.upsert(rec) - } - is AgentEvent.Deleted -> localStore.delete(ev.id) - is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title) - is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt) - } + private suspend fun syncLoop() { + while (cacheScope.isActive) { + try { + val snap = delegate.conversationsSnapshot() + reconcile(snap.conversations) + delegate.outbox.agentEvents(after = snap.cursor).collect { ce -> apply(ce.event) } + // Штатное завершение потока (не должно) → переподключаемся. + } catch (e: CancellationException) { + throw e + } catch (_: OutboxGapException) { + // Курсор мёртв — немедленно новый снапшот. + } catch (_: Throwable) { + // Сеть/5xx — пауза и повтор (локальный кэш сохраняем). + delay(RESYNC_RETRY_DELAY) } } } + /** + * Приводит локальный кэш к снапшоту: чего нет в снапшоте — удаляем, + * всё из снапшота — upsert. Делает полный resync корректным (в т.ч. + * «пропавшие» беседы = удалённые). + */ + private suspend fun reconcile(records: List) { + val fresh = records.mapTo(HashSet()) { it.id } + val stale = ArrayList() + var offset = 0 + while (true) { + val page = localStore.list(offset, ConversationStore.PAGE_SIZE) + if (page.isEmpty()) break + page.forEach { if (it.id !in fresh) stale += it.id } + offset += page.size + } + stale.forEach { localStore.delete(it) } + records.forEach { localStore.upsert(it) } + } + + private suspend fun apply(ev: AgentEvent) { + when (ev) { + is AgentEvent.Created -> { + // Created не несёт title/timestamps — нужно сходить в remote. + val rec = delegate.conversationStore.get(ev.conversationId) + if (rec != null) localStore.upsert(rec) + } + is AgentEvent.Deleted -> localStore.delete(ev.id) + is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title) + is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt) + } + } + /** * Read-only projection локального кэша — клиент через него только * читает (`get` / `list` / `listFlow`). diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt index a3da0e1..e54cf52 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt @@ -1,45 +1,42 @@ package pw.binom.agentik.client import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter import io.ktor.client.request.prepareGet +import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import pw.binom.agentik.outbox.OutboxStore -import pw.binom.agentik.outbox.AgentEvent +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable import pw.binom.agentik.outbox.CommonEvent -import pw.binom.agentik.outbox.Event -import kotlin.time.Clock -import kotlin.time.Instant +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.OutboxGapException +import pw.binom.agentik.outbox.OutboxStore /** - * HTTP-реализация [OutboxStore] (= [pw.binom.agentik.outbox.OutboxStore]), - * ходящая в `:server`-фасад. + * HTTP-реализация [OutboxStore], ходящая в `:server`-фасад. * - * **Endpoint-раскладка** (новый дизайн — storage handles на [Agent]): - * - [events] → `GET {baseUrl}/outbox/events?after=` (полный поток - * [CommonEvent], bounded-tail + live SSE, см. [pw.binom.agentik.server.outboxRoutes]) - * - [agentEvents] → `GET {baseUrl}/events?after=` (legacy proto-роут: - * сервер пробрасывает [pw.binom.agentik.outbox.agentEvents] и распаковывает - * `.event` для обратной совместимости с форматом AgentEvent) - * - [conversationEvents] с `conversationId != null` → `GET /conversations/{id}/events` + * **Endpoint-раскладка**: + * - [events] → `GET {baseUrl}/outbox/events?epoch=&offset=` (полный поток + * [CommonEvent], bounded-tail + live SSE). Без параметров — live-only. + * - [agentEvents] → `GET {baseUrl}/events?epoch=&offset=` (только + * `CommonEvent.Agent`). + * - [conversationEvents] с `conversationId != null` → + * `GET /conversations/{id}/events?epoch=&offset=`; с `null` — fallback на + * default [OutboxStore.conversationEvents] (общий `/outbox/events` + filter). + * - [currentCursor] / [oldestCursor] → `GET {baseUrl}/outbox/cursor`. * - * Для [conversationEvents] с `conversationId == null` (события всех диалогов) - * fallback на default [OutboxStore.conversationEvents] — общий поток - * `/outbox/events` + filter. Это редкий кейс (admin-дашборды), и - * оптимизировать его отдельно нерационально. + * **Gap** (`410 Gone`): сервер отвечает `410` с [GapResponse] (oldest/current + * курсоры) — клиент конвертирует в [OutboxGapException]. Это сигнал сделать + * resync: `agent.conversationsSnapshot()` / `agent.chatSnapshot(id)`. * - * [earliestEventDate] не имеет своего endpoint'а; возвращает `Clock.System.now()` - * (см. KDoc [OutboxStore.earliestEventDate] — для пустого буфера это и есть - * контрактное значение). Клиент, который полагался на gap detection через - * message store, продолжит работать — просто fallback никогда не сработает. - * - * **Импорты [CommonEvent]/[AgentEvent]/[Event] идут напрямую из - * `pw.binom.agentik.outbox`** — typealias'ы в `:proto.CommonEvent` и т.п. - * НЕ поддерживают nested-class access (`CommonEvent.Agent` через alias - * даёт "Unresolved qualified name"), поэтому приходится использовать - * конкретный пакет. Типы идентичны, alias только для удобства внешнего API. + * **Импорты [CommonEvent]/[Cursor] идут напрямую из `pw.binom.agentik.outbox`** — + * typealias'ы в `:proto` не поддерживают nested-class access. */ internal class HttpEventStore( private val httpClient: HttpClient, @@ -48,85 +45,80 @@ internal class HttpEventStore( private val agentUrl: String = baseUrl.trimEnd('/') - override fun events(after: Instant?): Flow = flow { - val url = buildString { - append("$agentUrl/outbox/events") - if (after != null) append("?after=$after") - } - httpClient.prepareGet(url) { noReadTimeout() } - .execute { response -> - check(response.status == HttpStatusCode.OK) { - "events: server returned ${response.status}" - } - readSse(response.bodyAsChannel()) - .collect { payload -> - emit(agentikJson.decodeFromString(CommonEvent.serializer(), payload)) - } - } - } + override fun events(after: Cursor?): Flow = + sse("$agentUrl/outbox/events", after, CommonEvent.serializer()) - /** - * Override: идём в `/events` напрямую — сервер фильтрует только lifecycle-события. - * Default из [EventStore.agentEvents] читал бы `/events/all` + `filterIsInstance`. - */ - override fun agentEvents(after: Instant?): Flow = flow { - val url = buildString { - append("$agentUrl/events") - if (after != null) append("?after=$after") - } - httpClient.prepareGet(url) { noReadTimeout() } - .execute { response -> - check(response.status == HttpStatusCode.OK) { - "agentEvents: server returned ${response.status}" - } - readSse(response.bodyAsChannel()) - .collect { payload -> - val event = agentikJson.decodeFromString(AgentEvent.serializer(), payload) - emit(CommonEvent.Agent(date = event.date, event = event)) - } - } - } + override fun agentEvents(after: Cursor?): Flow = + sse("$agentUrl/events", after, CommonEvent.Agent.serializer()) - /** - * Override с `conversationId != null` — идём в `/conversations/{id}/events`. - * С `null` (события всех диалогов) — fallback на default impl из [EventStore]: - * общий `/events/all` + filter. - */ override fun conversationEvents( - after: Instant?, + after: Cursor?, conversationId: String?, ): Flow { - if (conversationId == null) { - return super.conversationEvents(after, null) + if (conversationId == null) return super.conversationEvents(after, null) + return sse( + "$agentUrl/conversations/$conversationId/events", + after, + CommonEvent.Conversation.serializer(), + ) + } + + override suspend fun currentCursor(): Cursor = cursorResponse().current + + override suspend fun oldestCursor(): Cursor = cursorResponse().oldest + + private suspend fun cursorResponse(): CursorResponse { + val response = httpClient.get("$agentUrl/outbox/cursor") + check(response.status == HttpStatusCode.OK) { + "outbox.cursor: server returned ${response.status}" } - return flow { - val url = buildString { - append("$agentUrl/conversations/$conversationId/events") - if (after != null) append("?after=$after") + return response.body() + } + + private fun sse(url: String, after: Cursor?, serializer: KSerializer): Flow = flow { + httpClient.prepareGet(url) { + noReadTimeout() + if (after != null) { + parameter("epoch", after.epoch) + parameter("offset", after.offset) } - httpClient.prepareGet(url) { noReadTimeout() } - .execute { response -> - check(response.status == HttpStatusCode.OK) { - "conversationEvents: server returned ${response.status}" - } - readSse(response.bodyAsChannel()) - .collect { payload -> - val event = agentikJson.decodeFromString(Event.serializer(), payload) - emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event)) - } + }.execute { response -> + if (response.status == HttpStatusCode.Gone) { + throw response.toGapException(after) + } + check(response.status == HttpStatusCode.OK) { + "$url: server returned ${response.status}" + } + readSse(response.bodyAsChannel()) + .collect { payload -> + emit(agentikJson.decodeFromString(serializer, payload)) } } } - /** - * У HTTP-варианта нет своего endpoint'а для earliest-event-date. - * Контракт [EventStore.earliestEventDate] для пустого буфера говорит - * "сейчас" — для HTTP-клиента буфер на нашей стороне всегда "пуст" - * (мы не держим своё состояние), поэтому возвращаем `Clock.System.now()`. - */ - override suspend fun earliestEventDate(): Instant = Clock.System.now() - override fun close() { // HttpClient закрывает владелец (AgentClient / AgentikAgent). } } + +/** Тело `GET {baseUrl}/outbox/cursor`. */ +@Serializable +internal data class CursorResponse(val current: Cursor, val oldest: Cursor) + +/** Тело `410 Gone` (см. [pw.binom.agentik.server.OutboxGapResponse]). */ +@Serializable +internal data class GapResponse( + val requested: Cursor? = null, + val oldest: Cursor, + val current: Cursor, +) + +private suspend fun HttpResponse.toGapException(requested: Cursor?): OutboxGapException { + val dto = runCatching { agentikJson.decodeFromString(GapResponse.serializer(), bodyAsText()) }.getOrNull() + val fallback = requested ?: Cursor(epoch = "", offset = -1L) + return OutboxGapException( + requested = requested, + oldest = dto?.oldest ?: fallback, + current = dto?.current ?: fallback, + ) +} diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpJournalStore.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpJournalStore.kt index 9190a6c..9ffee2a 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpJournalStore.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpJournalStore.kt @@ -58,6 +58,23 @@ internal class HttpJournalStore( return response.body>() } + override suspend fun list( + conversationId: String, + afterSeq: Long, + upToSeq: Long, + limit: Int, + ): List { + val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/messages") { + parameter("afterSeq", afterSeq) + parameter("upToSeq", upToSeq) + parameter("limit", limit) + } + check(response.status == HttpStatusCode.OK) { + "journal.list(seq): server returned ${response.status}" + } + return response.body>() + } + override suspend fun count(conversationId: String): Long { val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count") check(response.status == HttpStatusCode.OK) { @@ -76,6 +93,16 @@ internal class HttpJournalStore( return response.body().count } + override suspend fun count(conversationId: String, afterSeq: Long): Long { + val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count") { + parameter("afterSeq", afterSeq) + } + check(response.status == HttpStatusCode.OK) { + "journal.count(afterSeq): server returned ${response.status}" + } + return response.body().count + } + override fun close() { // HttpClient закрывает владелец (AgentClient / AgentikAgent). } diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/ReconnectingOutbox.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/ReconnectingOutbox.kt index f9da95d..d8ccfbf 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/ReconnectingOutbox.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/ReconnectingOutbox.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.OutboxGapException import pw.binom.agentik.outbox.OutboxStore import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicReference @@ -60,6 +62,17 @@ sealed interface ConnectionStatus { * outbox и т.п. */ data class Failed(val cause: Throwable) : ConnectionStatus + + /** + * Курсор мёртв ([pw.binom.agentik.outbox.OutboxGapException]): клиент был + * оффлайн дольше retention'а outbox'а или эпоха сменилась. **Не** retry'ится + * (ретрай никогда не пройдёт). Создатель обязан сделать полный resync: + * взять снапшот (`Agent.conversationsSnapshot()` / `Agent.chatSnapshot(id)`), + * применить его и создать новый [ReconnectingOutbox] с курсором снапшота. + * + * Поток [events] закрывается после этого, background-loop останавливается. + */ + data class Gap(val cause: OutboxGapException) : ConnectionStatus } /** @@ -116,8 +129,10 @@ data class BackoffPolicy( * ``` * val outbox = ReconnectingOutbox(httpEventStore, scope) * + * // Курсор берётся из снапшота: state + cursor, затем подписка «после него». + * val snap = agent.chatSnapshot(conversationId) * scope.launch { - * outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) } + * outbox.events(after = snap.cursor).collect { e -> handle(e) } * } * scope.launch { * outbox.connectionStatus().collect { s -> ui.showStatus(s) } @@ -154,19 +169,27 @@ class ReconnectingOutbox( private var job: Job? = null @OptIn(ExperimentalAtomicApi::class) - private val lastSeen: AtomicReference = AtomicReference(null) + private val lastSeen: AtomicReference = AtomicReference(null) /** * Live-события из [outbox] с авто-reconnect. [after] — начальный курсор; * учитывается только при первом вызове (любом из [events] / - * [connectionStatus]). После reconnect курсор берётся из `date` - * последнего виденного события. + * [connectionStatus]). После reconnect курсор берётся из `offset` + * последнего виденного события (та же `epoch`, что и у подписки). + * + * Если [after] == null, при старте background-loop берётся + * [OutboxStore.currentCursor] — это live-only семантика (событий строго + * после текущего) плюс известная `epoch` для будущих reconnect. + * + * При мёртвом курсоре (retention / смена epoch) loop **не** ретраит, а + * эмитит [ConnectionStatus.Gap] и останавливается — клиент обязан сделать + * resync (снапшот + новый [ReconnectingOutbox] с курсором снапшота). * * Коллекторы независимы — каждый получает свою копию потока (shared). * Медленный коллектор может пропускать события при переполнении буфера * (`DROP_OLDEST`). */ - fun events(after: Instant? = null): Flow { + fun events(after: Cursor? = null): Flow { ensureStarted(after) return _events } @@ -183,7 +206,7 @@ class ReconnectingOutbox( } @OptIn(ExperimentalAtomicApi::class) - private fun ensureStarted(initialCursor: Instant?) { + private fun ensureStarted(initialCursor: Cursor?) { if (!started.compareAndSet(false, true)) return lastSeen.store(initialCursor) job = scope.launch { runLoop() } @@ -196,9 +219,13 @@ class ReconnectingOutbox( while (currentCoroutineContext().isActive) { attempt++ _status.emit(ConnectionStatus.Connecting(attempt)) + // Курсор подписки: сохранённый lastSeen, либо (при live-only) + // currentCursor() — чтобы знать epoch и не терять позицию. + val cursor: Cursor? = lastSeen.load() ?: runCatching { outbox.currentCursor() }.getOrNull() + var gap: OutboxGapException? = null val error: Throwable? = try { - outbox.events(after = lastSeen.load()).collect { event -> - lastSeen.store(event.date) + outbox.events(after = cursor).collect { event -> + lastSeen.store(Cursor(epoch = cursor?.epoch ?: "", offset = event.offset)) _events.emit(event) if (!connected) { connected = true @@ -208,10 +235,18 @@ class ReconnectingOutbox( null } catch (t: CancellationException) { throw t + } catch (t: OutboxGapException) { + gap = t + null } catch (t: Throwable) { t } connected = false + if (gap != null) { + // Ретраить бессмысленно: курсор мёртв. Отдаём сигнал наружу. + _status.emit(ConnectionStatus.Gap(gap)) + return + } if (attempt >= policy.maxAttempts) { _status.emit( ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally")) diff --git a/client/src/commonTest/kotlin/pw/binom/agentik/client/ReconnectingOutboxTest.kt b/client/src/commonTest/kotlin/pw/binom/agentik/client/ReconnectingOutboxTest.kt index 5980f5d..24f6cf7 100644 --- a/client/src/commonTest/kotlin/pw/binom/agentik/client/ReconnectingOutboxTest.kt +++ b/client/src/commonTest/kotlin/pw/binom/agentik/client/ReconnectingOutboxTest.kt @@ -11,10 +11,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.Cursor import pw.binom.agentik.outbox.OutboxStore -import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.OutboxGapException +import pw.binom.agentik.outbox.DurableEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -41,7 +42,7 @@ internal class FakeOutbox : OutboxStore { private val channel = Channel(Channel.UNLIMITED) - override fun events(after: Instant?): Flow = flow { + override fun events(after: Cursor?): Flow = flow { for (msg in channel) { when (msg) { is Msg.Err -> throw msg.throwable @@ -53,20 +54,24 @@ internal class FakeOutbox : OutboxStore { fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) } fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) } - override fun agentEvents(after: Instant?): Flow = emptyFlow() + override fun agentEvents(after: Cursor?): Flow = emptyFlow() override fun conversationEvents( - after: Instant?, + after: Cursor?, conversationId: String?, ): Flow = emptyFlow() - override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override suspend fun currentCursor(): Cursor = Cursor(epoch = "test", offset = -1L) + override suspend fun oldestCursor(): Cursor = Cursor(epoch = "test", offset = -1L) override fun close() { channel.close() } } +private const val TEST_EPOCH = "test" + private fun testEvent(dateMs: Long): CommonEvent = CommonEvent.Conversation( date = Instant.fromEpochMilliseconds(dateMs), + offset = dateMs, conversationId = "test", - event = Event.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)), + event = DurableEvent.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)), ) @OptIn(ExperimentalCoroutinesApi::class) @@ -141,6 +146,32 @@ class ReconnectingOutboxTest { assertEquals(0, ctx.eventsLog.size) } + @Test + fun `gap is not retried and emits Gap status`() = runConnectionTest(attempts = 5) { ctx -> + val fake = ctx.fake + val gap = OutboxGapException( + requested = Cursor("test", -1L), + oldest = Cursor("test", 10L), + current = Cursor("test", 20L), + ) + fake.throwAtNextEvent(gap) + ctx.advanceAndDrain(50) + + val gaps = ctx.statusLog.filterIsInstance() + assertEquals(1, gaps.size, "status=${ctx.statusLog}") + assertEquals(gap, gaps[0].cause) + // Ретрая быть не должно: курсор мёртв, следующая попытка ничего не изменит. + assertTrue(ctx.statusLog.none { it is ConnectionStatus.Disconnected }, "status=${ctx.statusLog}") + assertTrue( + ctx.statusLog.none { it is ConnectionStatus.Connecting && it.attempt == 2 }, + "status=${ctx.statusLog}", + ) + // Поток событий закрыт — новые эмиссии не доходят. + fake.push(testEvent(2000)) + ctx.advanceAndDrain(50) + assertEquals(0, ctx.eventsLog.size) + } + @Test fun `close cancels background loop`() = runConnectionTest( attempts = 5, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5b9cb2f..79a0cc1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,7 +68,7 @@ agentik `Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`. События разделены на два потока (оба в `:outbox-api`): -- **durable** `Event` (`outbox.conversationEvents(after, id)`): `UserMessage | +- **durable** `DurableEvent` (`outbox.conversationEvents(after, id)`): `UserMessage | AssistantMessage | ToolCall | ToolResult | ToolFailed | Interrupted | Error | ConversationClosing | CompactionTriggered` — перезапрашиваются по курсору; - **online** `OnlineEvent` (`onlineOutbox.onlineEvents(id)`): `Working | End | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03f52e6..6e3ebbe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ kotlin = "2.4.20" kotlinx-serialization = "1.11.0" kotlinx-coroutines = "1.11.0" -kotlinx-io = "0.8.0" +kotlinx-io = "0.9.1" ktor = "3.1.3" a2a = "1.0.0-SNAPSHOT" kaml = "0.104.0" diff --git a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/JournalStore.kt b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/JournalStore.kt index ef98e2b..4d68261 100644 --- a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/JournalStore.kt +++ b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/JournalStore.kt @@ -13,38 +13,49 @@ import kotlin.time.Instant * * Никаких обновлений, никакого удаления (кроме каскадного вместе * с ConversationStore.delete). + * + * ## Два способа адресации позиции + * - **по [Instant] `createdAt`** — legacy, «дай всё после даты»; + * - **по [MessageRecord.seq]** (монотонный per-agent offset) — протокол + * снапшотов: диапазон `afterSeq < seq <= upToSeq` даёт **конечное и + * стабильное** множество строк. Catch-up: `afterSeq = <курсор>, upToSeq = MAX`. + * Снапшот с курсором C: `afterSeq = -1, upToSeq = C`. + * + * [listFlowSeq] использует keyset-пагинацию (`seq > last`), а не `OFFSET` — + * иначе конкурентная вставка/удаление сдвигает окно и молча теряет строки. */ interface JournalStore : AutoCloseable { + /** Legacy-страница по `createdAt > [after]`, `ORDER BY createdAt ASC` + `OFFSET`. */ suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List /** - * Сколько сообщений в диалоге [conversationId] всего. + * Keyset-страница записей `[afterSeq] < seq <= [upToSeq]`, `ORDER BY seq ASC`. * - * O(1) на SQL-бэкендах (`SELECT COUNT(*) ... WHERE conversation_id = ?`), - * O(N) на in-memory (size простого list'а с фильтром по conversationId). - * Не зависит от cursor'а [after] — для total-размера диалога. + * @param afterSeq нижняя эксклюзивная граница (для «с начала» — `-1`). + * @param upToSeq верхняя **инклюзивная** граница (для «без отсечки» — + * `Long.MAX_VALUE`). */ + suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int): List + + /** Сколько сообщений в диалоге [conversationId] всего. */ suspend fun count(conversationId: String): Long /** - * Сколько сообщений в диалоге [conversationId] создано **позже** [after] - * (строго `createdAt > after`, как и в [list]). - * - * O(1) на SQL-бэкендах, O(N) на in-memory. Полезно для: - * - UI badge "N новых сообщений" — клиент знает последний `lastSeen`, - * сервер говорит `count(convId, after=lastSeen)`; - * - пагинации без получения самих записей: знаем лимит последней страницы, - * надо понять "есть ли ещё"; - * - compaction-метрик: «сколько turn'ов осталось после cutoff». + * Сколько сообщений создано **позже** [after] (строго `createdAt > after`). + * Legacy unread-бейдж по времени. */ suspend fun count(conversationId: String, after: Instant): Long /** - * Cold-flow paging через [list]. Default-реализация делает N+1 round-trip - * (по странице через `list()` пока не получит короткую страницу). Для - * in-memory backend'ов это OK; remote/SQLite impl'ы могут override'нуть - * на `Channel` / cursor-батчинг, чтобы избежать per-page round-trip. + * Сколько сообщений имеют `seq > [afterSeq]`. Cursor-версия unread-бейджа: + * `count(convId, afterSeq = lastSeenOffset)`. + */ + suspend fun count(conversationId: String, afterSeq: Long): Long + + /** + * Cold-flow paging (legacy, по [Instant]). Default-реализация делает N+1 + * round-trip. */ fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow = flow { var offset = 0 @@ -57,6 +68,26 @@ interface JournalStore : AutoCloseable { } } + /** + * Cold-flow paging по `seq` (keyset). `cursor` растёт по мере эмиссии; + * `upToSeq` ограничивает сверху (снапшот с курсором). + */ + fun listFlowSeq( + conversationId: String, + afterSeq: Long = -1L, + upToSeq: Long = Long.MAX_VALUE, + pageSize: Int = PAGE_SIZE, + ): Flow = flow { + var cursor = afterSeq + while (true) { + val page = list(conversationId, cursor, upToSeq, pageSize) + if (page.isEmpty()) return@flow + for (rec in page) emit(rec) + cursor = page.last().seq + if (page.size < pageSize) return@flow + } + } + companion object { const val PAGE_SIZE = 100 } diff --git a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/MessageRecord.kt b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/MessageRecord.kt index ea184a3..7bf0c17 100644 --- a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/MessageRecord.kt +++ b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/MessageRecord.kt @@ -9,12 +9,29 @@ import kotlin.time.Instant /** * Запись в таблице `message` (append-only audit). + * + * ## [seq] — курсор записи + * [seq] — **тот же монотонный per-agent offset**, что и `offset` соответствующего + * events-события (`OffsetSequencer.reserve()`). Writer резервирует offset и + * пишет строку с `seq = offset` **до** append'а события в outbox (инвариант + * «сначала состояние, потом событие»). + * + * Нужен для снапшота с «курсором»: клиент берёт `currentCursor() = C` и читает + * `listUpTo(convId, C)` — конечное, стабильное множество строк, отражающее + * состояние на момент C. Всё, что появится позже, имеет `seq > C` и приедет + * потоком событий. + * + * Значение по умолчанию `0L` — для legacy-записей и тестов; production-путь + * (`ConversationLoop` / `ToolDispatcher`) всегда выставляет реальный offset. + * Запись с `seq = 0` в снапшоте всегда «≤ C», поэтому попадает в снапшот и + * (если её событие ещё и в потоке) применяется дважды — идемпотентно, безвредно. */ @Serializable sealed interface MessageRecord { val id: String val conversationId: String val createdAt: Instant + val seq: Long @Serializable sealed interface Body : MessageRecord { @@ -29,6 +46,7 @@ sealed interface MessageRecord { override val content: List, override val createdAt: Instant, val context: MessageContext? = null, + override val seq: Long = 0L, ) : Body @Serializable @@ -45,6 +63,7 @@ sealed interface MessageRecord { * их не раскрывает. */ val reasoning: String? = null, + override val seq: Long = 0L, ) : Body @Serializable @@ -56,6 +75,7 @@ sealed interface MessageRecord { val toolTitle: String?, val toolArgsJson: String, override val createdAt: Instant, + override val seq: Long = 0L, ) : MessageRecord @Serializable @@ -74,6 +94,7 @@ sealed interface MessageRecord { val toolName: String? = null, val result: String?, override val createdAt: Instant, + override val seq: Long = 0L, ) : MessageRecord @Serializable @@ -84,5 +105,6 @@ sealed interface MessageRecord { val message: String, val code: String?, override val createdAt: Instant, + override val seq: Long = 0L, ) : MessageRecord } diff --git a/journal-inmemory/src/commonMain/kotlin/pw/binom/agentik/journal/inmemory/InMemoryJournalStore.kt b/journal-inmemory/src/commonMain/kotlin/pw/binom/agentik/journal/inmemory/InMemoryJournalStore.kt index 4745bf4..945b5ec 100644 --- a/journal-inmemory/src/commonMain/kotlin/pw/binom/agentik/journal/inmemory/InMemoryJournalStore.kt +++ b/journal-inmemory/src/commonMain/kotlin/pw/binom/agentik/journal/inmemory/InMemoryJournalStore.kt @@ -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 = 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 } diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt index d3dfad7..a89a3a5 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt @@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext * ## Миграция * * [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent - * (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в - * standalone-форме, ни в shared-connection bundle'е, где несколько store'ов - * прогоняют миграцию одной и той же схемы по очереди. + * (CREATE TABLE / INDEX IF NOT EXISTS + гейтированный ADD COLUMN), так что + * лишних эффектов нет ни в standalone-форме, ни в shared-connection bundle'е, + * где несколько store'ов прогоняют миграцию одной и той же схемы по очереди. * * Prepared statements (insert / list / clear) препарируются один раз в * конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого @@ -45,6 +45,12 @@ import kotlinx.coroutines.withContext * `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding * helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат * в [MessageCodecs.kt] рядом. + * + * ## Курсор ([MessageRecord.seq]) + * + * [list] с диапазоном `[afterSeq] < seq <= [upToSeq]` — keyset-пагинация, + * а не `OFFSET`: конкурентная вставка/удаление сдвигает OFFSET-окно и молча + * теряет строки. Индекс `idx_msg_conv_seq` покрывает hot-path. */ class KsqliteJournalStore private constructor( private val connection: SQLiteConnection, @@ -82,14 +88,14 @@ class KsqliteJournalStore private constructor( """ INSERT INTO ${Schema.TABLE_MESSAGE} (${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND}, - ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}) - VALUES (?, ?, ?, ?, ?) + ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ}) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent() ) private val listStmt: SQLitePreparedStatement = connection.prepare( """ SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND}, - ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT} + ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ} FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_CREATED_AT} > ? @@ -97,6 +103,18 @@ class KsqliteJournalStore private constructor( LIMIT ? OFFSET ? """.trimIndent() ) + private val listSeqStmt: SQLitePreparedStatement = connection.prepare( + """ + SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND}, + ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ} + FROM ${Schema.TABLE_MESSAGE} + WHERE ${Schema.COL_CONVERSATION_ID} = ? + AND ${Schema.COL_SEQ} > ? + AND ${Schema.COL_SEQ} <= ? + ORDER BY ${Schema.COL_SEQ} ASC + LIMIT ? + """.trimIndent() + ) private val clearStmt: SQLitePreparedStatement = connection.prepare( "DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?" ) @@ -110,6 +128,13 @@ class KsqliteJournalStore private constructor( AND ${Schema.COL_CREATED_AT} > ? """.trimIndent() ) + private val countAfterSeqStmt: SQLitePreparedStatement = connection.prepare( + """ + SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE} + WHERE ${Schema.COL_CONVERSATION_ID} = ? + AND ${Schema.COL_SEQ} > ? + """.trimIndent() + ) override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) { val (kind, payload) = encodeRecord(record) @@ -121,6 +146,7 @@ class KsqliteJournalStore private constructor( insertStmt.bindText(3, kind) insertStmt.bindText(4, payload) insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds()) + insertStmt.bindLong(6, record.seq) insertStmt.executeUpdate() } } @@ -148,6 +174,29 @@ class KsqliteJournalStore private constructor( } } + override suspend fun list( + conversationId: String, + afterSeq: Long, + upToSeq: Long, + limit: Int, + ): List = withContext(Dispatchers.Default) { + mutex.withLock { + listSeqStmt.reset() + listSeqStmt.clearBindings() + listSeqStmt.bindText(1, conversationId) + listSeqStmt.bindLong(2, afterSeq) + listSeqStmt.bindLong(3, upToSeq) + listSeqStmt.bindLong(4, limit.toLong()) + val out = mutableListOf() + listSeqStmt.executeQuery().use { rs -> + while (rs.next()) { + out.add(rs.toMessageRecord(json)) + } + } + out + } + } + override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) { mutex.withLock { clearStmt.reset() @@ -182,12 +231,27 @@ class KsqliteJournalStore private constructor( } } + override suspend fun count(conversationId: String, afterSeq: Long): Long = withContext(Dispatchers.Default) { + mutex.withLock { + countAfterSeqStmt.reset() + countAfterSeqStmt.clearBindings() + countAfterSeqStmt.bindText(1, conversationId) + countAfterSeqStmt.bindLong(2, afterSeq) + countAfterSeqStmt.executeQuery().use { rs -> + check(rs.next()) { "COUNT(*) must return at least one row" } + (rs.getLong(0) ?: 0L) + } + } + } + override fun close() { insertStmt.close() listStmt.close() + listSeqStmt.close() clearStmt.close() countAllStmt.close() countAfterStmt.close() + countAfterSeqStmt.close() if (ownsConnection) { connection.close() } diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt index 096d3c1..4de256b 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt @@ -43,26 +43,28 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord { val kind = getText(2)!! val payload = getText(3)!! val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!) + // Колонка `seq` — 6-я (индекс 5) в SELECT'ах store'а. + val seq = getLong(5) ?: 0L return when (kind) { "user" -> { val d = decodeBodyPayload(payload) - MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context) + MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context, seq = seq) } "assistant" -> { val d = decodeBodyPayload(payload) - MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning) + MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning, seq = seq) } "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) + MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt, seq = seq) } "tool_result" -> { val p = Json.decodeFromString(ResultPayload.serializer(), payload) - MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt) + MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt, seq = seq) } "error" -> { val p = Json.decodeFromString(ErrorPayload.serializer(), payload) - MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt) + MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt, seq = seq) } else -> error("Unknown message kind in audit log: $kind") } diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt index 3eb7a7e..206594e 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt @@ -16,8 +16,14 @@ import pw.binom.db.ksqlite.SQLiteConnection */ object Schema { - /** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */ - const val CURRENT_VERSION: Int = 1 + /** + * Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. + * + * v2: `message.seq` — монотонный per-agent offset (курсор снапшота), + * синхронный с `OutboxStore`'ом. Старые БД до-мигрируются через + * `ALTER TABLE ... ADD COLUMN` (см. [migrate]). + */ + const val CURRENT_VERSION: Int = 2 // ───── Таблицы ───── const val TABLE_CONVERSATION = "conversation" @@ -34,10 +40,14 @@ object Schema { const val COL_CONVERSATION_ID = "conversation_id" const val COL_KIND = "kind" const val COL_PAYLOAD_JSON = "payload_json" + /** Монотонный per-agent offset записи (см. `OffsetSequencer`). */ + const val COL_SEQ = "seq" // ───── Индексы ───── const val IDX_CONV_UPDATED = "idx_conv_updated" const val IDX_MSG_CONV = "idx_msg_conv" + /** Keyset-индекс для `list(convId, afterSeq, upToSeq, limit)`. */ + const val IDX_MSG_CONV_SEQ = "idx_msg_conv_seq" private val v1ConversationDdl = """ CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION ( @@ -49,24 +59,28 @@ object Schema { ); """ - private val v1MessageDdl = """ + private val v2MessageDdl = """ CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE ( $COL_ID TEXT NOT NULL PRIMARY KEY, $COL_CONVERSATION_ID TEXT NOT NULL, $COL_KIND TEXT NOT NULL, $COL_PAYLOAD_JSON TEXT NOT NULL, - $COL_CREATED_AT INTEGER NOT NULL + $COL_CREATED_AT INTEGER NOT NULL, + $COL_SEQ INTEGER NOT NULL DEFAULT 0 ); """ - private val v1IndexesDdl = """ + private val v2IndexesDdl = """ CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC); - -- Главный hot-path индекс для list/сообщений: фильтр по conv + - -- сортировка по created_at (используется list(), cascade-clear, etc.) + -- Legacy hot-path (по времени): list() по createdAt. CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT); + + -- Cursor hot-path: keyset-пагинация по seq. + CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV_SEQ + ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_SEQ); """ /** @@ -74,7 +88,7 @@ object Schema { * * Гарантии: * - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на - * уже-мигрированной БД; + * уже-мигрированной БД; `ADD COLUMN` гейтится проверкой `PRAGMA table_info`; * - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди → * ROLLBACK оставит БД консистентной. * @@ -88,12 +102,31 @@ object Schema { conn.exec("BEGIN") try { conn.exec(v1ConversationDdl) - conn.exec(v1MessageDdl) - conn.exec(v1IndexesDdl) + conn.exec(v2MessageDdl) + // Старая БД (v1) не получит `seq` от CREATE IF NOT EXISTS — + // добавляем колонку, если её ещё нет. + if (!columnExists(conn, TABLE_MESSAGE, COL_SEQ)) { + conn.exec( + "ALTER TABLE $TABLE_MESSAGE ADD COLUMN $COL_SEQ INTEGER NOT NULL DEFAULT 0" + ) + } + conn.exec(v2IndexesDdl) conn.exec("COMMIT") } catch (t: Throwable) { runCatching { conn.exec("ROLLBACK") } throw t } } + + private fun columnExists(conn: SQLiteConnection, table: String, column: String): Boolean { + conn.prepare("PRAGMA table_info($table)").use { stmt -> + stmt.executeQuery().use { rs -> + // PRAGMA table_info: (cid, name, type, notnull, dflt_value, pk) + while (rs.next()) { + if (rs.getText(1) == column) return true + } + } + } + return false + } } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt index aee48d2..115a67a 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable * * Useful for admin dashboards, debug tools, parent agents: one subscription * instead of N+1. For regular UI use two separate SSE feeds - * ([AgentEvent] via `/events` и [Event] via `/conversations/{id}/events`); + * ([AgentEvent] via `/events` и [DurableEvent] via `/conversations/{id}/events`); * [CommonEvent] — for those who need everything in one place. * * Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`. @@ -23,12 +23,23 @@ import kotlinx.serialization.Serializable */ @Serializable sealed interface CommonEvent { + /** Момент эмиссии в UTC. Только для отображения/сортировки — **не** курсор. */ val date: Instant + /** + * Монотонный per-agent offset события — **курсор** (см. [Cursor]). + * + * Присваивается writer'ом через [OffsetSequencer.reserve] в тот же момент, + * что и `seq` соответствующей строки состояния (сначала строка, потом + * событие). Клиенты оперируют [Cursor], а не [date]. + */ + val offset: Long + @Serializable @SerialName("agent") data class Agent( override val date: Instant, + override val offset: Long, val event: AgentEvent, ) : CommonEvent @@ -36,7 +47,8 @@ sealed interface CommonEvent { @SerialName("conversation") data class Conversation( override val date: Instant, + override val offset: Long, val conversationId: String, - val event: Event, + val event: DurableEvent, ) : CommonEvent } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Cursor.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Cursor.kt new file mode 100644 index 0000000..435712f --- /dev/null +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Cursor.kt @@ -0,0 +1,51 @@ +package pw.binom.agentik.outbox + +import kotlin.random.Random +import kotlinx.serialization.Serializable + +/** + * Курсор события — **позиция в общем монотонном потоке событий агента**. + * + * Состоит из двух частей: + * - [offset] — per-agent монотонный номер события (0-based). Именно он, а не + * wall-clock [CommonEvent.date], является курсором: несколько событий могут + * иметь одинаковый [date] (например `UserMessage` и `ToolCall` в одну + * миллисекунду), но offset'ы всегда строго возрастают и уникальны. Фильтрация + * `offset > after.offset` не теряет события на «ничьих» по времени. + * - [epoch] — идентификатор «мира» счётчика. Меняется при сбросе/восстановлении + * БД, из-за которого offset'ы теряют монотонность. Клиент хранит epoch в своём + * курсоре; несовпадение epoch → сервер сигналит gap ([OutboxGapException]) → + * клиент делает полный resync. Обычный **рестарт** сервера epoch НЕ меняет + * (счётчик персистентный), поэтому клиент продолжает инкрементально. + * + * **Семантика подписки**: [Cursor.offset] — **эксклюзивная** граница. + * `events(after = cursor)` отдаёт события со строго большим offset. Практически + * клиент кладёт сюда offset последнего применённого события, либо [OutboxStore.currentCursor] + * из снапшота. + * + * **Почему не `Instant` и не «id ASC»**: `Instant` лоссов при совпадении millis, + * а `id` — случайный UUID, который не задаёт порядок записи. + * + * **Переполнение счётчика**: `Long` на агента неисчерпаем (≈4.6·10¹⁷ ходов при + * 20 событиях/ход — это ~1.5·10⁷ лет при 1000 ходов/с). Заворачивать его нельзя + * (сломает монотонность), поэтому при любом сбое, инвалидирующем счётчик + * (сброс/восстановление БД), **ротируется [epoch]** и все клиенты делают + * полный resync — это и есть «обработка переполнения», а не wrap. + */ +@Serializable +data class Cursor( + val epoch: String, + val offset: Long, +) { + override fun toString(): String = "$epoch:$offset" + + companion object { + /** + * Новый случайный [epoch] (opaque-строка). Новый epoch = «новый мир» + * счётчика: используется при первичной инициализации персистентного + * счётчика и при инвалидации offset-пространства — клиенты с прежним + * курсором получат [OutboxGapException] и сделают полный resync. + */ + fun newEpoch(): String = Random.nextLong().toString(16).padStart(16, '0') + } +} diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CursorStore.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CursorStore.kt new file mode 100644 index 0000000..5dbdc9c --- /dev/null +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CursorStore.kt @@ -0,0 +1,25 @@ +package pw.binom.agentik.outbox + +/** + * Персистентное хранилище позиции счётчика — [Cursor] (`epoch` + `offset`). + * + * Единственный мост между [OffsetSequencer] (чистая логика монотонного + * счётчика) и durable-носителем (`outbox-ksqlite`). Секвенсор читает позицию + * один раз при создании и держит `epoch`/`offset` в памяти (см. + * [OffsetSequencer] KDoc), поэтому [load] синхронный; [save] — durable + * запись, вызывается на каждом [OffsetSequencer.reserve]. + * + * Вызовы сериализованы самим [PersistentOffsetSequencer] (его `Mutex`), + * так что реализация может не иметь собственной синхронизации. + */ +interface CursorStore { + /** + * Текущая позиция счётчика, или `null` если он ещё не инициализирован + * (пустая БД / первый запуск). Для пустого хранилища [PersistentOffsetSequencer] + * сгенерирует новый `epoch` и стартовый offset. + */ + fun load(): Cursor? + + /** Записать позицию durable. */ + fun save(cursor: Cursor) +} diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/DurableEvent.kt similarity index 95% rename from outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt rename to outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/DurableEvent.kt index 4bd4b8d..21a814b 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/DurableEvent.kt @@ -30,7 +30,7 @@ import kotlin.time.Instant * `pw.binom.agentik.outbox.Event`. */ @Serializable -sealed interface Event { +sealed interface DurableEvent { /** Момент эмиссии события в UTC. */ val date: Instant @@ -46,7 +46,7 @@ sealed interface Event { val id: String, val content: List, val context: MessageContext? = null, - ) : Event + ) : DurableEvent /** * Целое сообщение ассистента — итог хода. Эмитится при завершении хода, @@ -63,7 +63,7 @@ sealed interface Event { val content: List, val reasoning: String? = null, val tokens: TurnTokens? = null, - ) : Event + ) : DurableEvent /** * Агент начал вызов тула. Аргументы приходят целиком — стриминга нет. @@ -78,7 +78,7 @@ sealed interface Event { val title: String?, val toolName: String, val toolArgs: String, - ) : Event + ) : DurableEvent /** * Результат вызова тула. Приходит целиком после завершения исполнения. @@ -101,7 +101,7 @@ sealed interface Event { val toolCallId: String, val toolName: String? = null, val result: String?, - ) : Event + ) : DurableEvent /** * Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется @@ -109,7 +109,7 @@ sealed interface Event { */ @Serializable @SerialName("interrupted") - data class Interrupted(override val date: Instant) : Event + data class Interrupted(override val date: Instant) : DurableEvent /** * Ошибка хода. После неё поток завершается; дальнейшие события могут @@ -117,7 +117,7 @@ sealed interface Event { */ @Serializable @SerialName("error") - data class Error(override val date: Instant, val message: String, val code: String? = null) : Event + data class Error(override val date: Instant, val message: String, val code: String? = null) : DurableEvent /** * Конвейер вызова тула упал (handler кинул Throwable, args не парсятся, @@ -136,7 +136,7 @@ sealed interface Event { val toolName: String?, val message: String, val durationMs: Long, - ) : Event + ) : DurableEvent /** * Диалог переходит в закрытое состояние ([Conversation.close] / @@ -148,7 +148,7 @@ sealed interface Event { * * Парный `Opening` намеренно отсутствует — симметрия не нужна, * так как открытие тривиально (id уже известен с момента - * `Agent.createConversation` → [Event.ConversationCreated] + * `Agent.createConversation` → [DurableEvent.ConversationCreated] * / [AgentEvent.Created] в outbox'е). */ @Serializable @@ -156,7 +156,7 @@ sealed interface Event { data class ConversationClosing( override val date: Instant, val conversationId: String, - ) : Event + ) : DurableEvent /** * Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли. @@ -173,5 +173,5 @@ sealed interface Event { override val date: Instant, val conversationId: String, val turnsCompacted: Int, - ) : Event + ) : DurableEvent } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOnlineOutbox.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOnlineOutbox.kt index f0426ab..dda9e7e 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOnlineOutbox.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOnlineOutbox.kt @@ -10,17 +10,21 @@ package pw.binom.agentik.outbox * персистятся, I/O нет — блокировать продюсера незачем. [tryAppendOnline] * не буферизует и не ждёт (см. [OnlineOutbox]): медленный подписчик может * потерять дельту, это допустимо. + * + * Диалог берётся из самого события ([OnlineEvent.conversationId]) — отдельного + * параметра нет, чтобы не было двух источников истины. */ interface MutableOnlineOutbox : OnlineOutbox { - suspend fun appendOnline(conversationId: String, event: OnlineEvent) + suspend fun appendOnline(event: OnlineEvent) /** - * Эмитит [event] в live-канал диалога [conversationId]. Не сохраняется. + * Эмитит [event] в live-канал его диалога ([OnlineEvent.conversationId]). + * Не сохраняется. * * Возвращает `true`, если событие принято live-каналом. Возврат `false` * (нет активных подписчиков / буфер переполнен с DROP-политикой) — * не ошибка: у онлайн-событий нет гарантии доставки. */ - fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean + fun tryAppendOnline(event: OnlineEvent): Boolean } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOutboxStore.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOutboxStore.kt index ed59ac4..22401e2 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOutboxStore.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/MutableOutboxStore.kt @@ -1,50 +1,40 @@ package pw.binom.agentik.outbox /** - * Mutable вариант [OutboxStore] — добавляет producer-операцию [append]. + * Mutable вариант [OutboxStore] — добавляет producer-операции [reserveOffset] + * и [append]. * - * Этот интерфейс предназначен **только для producer'ов** (ChatAgent, - * sub-agents, A2A-bridge). Consumer'ы (server SSE endpoints, admin - * dashboards, parent agents) должны принимать **read-only** [OutboxStore] - * — тогда невозможно случайно писать в store из observer'а. + * Предназначен **только для producer'ов** (ChatAgent, ConversationLoop, + * ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only + * [OutboxStore] — тогда невозможно случайно писать в store из observer'а. * - * Типичное использование: + * ## Контракт записи (порядок важен) * ``` - * // Producer - * class ChatAgent(private val events: MutableEventStore) { - * suspend fun doSomething() { - * events.append(CommonEvent.Agent(date = now, event = AgentEvent.Created(...))) - * } - * } - * - * // Consumer - * class EventStreamEndpoint(private val events: EventStore) { - * fun stream() = events.events(after = null) - * // Ошибка компиляции если раскомментировать: - * // events.append(...) // ← нельзя, MutableEventStore нет в типе - * } + * val n = outbox.reserveOffset() // 1. забронировать offset + * journal.append(record.copy(seq = n)) // 2. сначала состояние + * outbox.append(event.copy(offset = n)) // 3. потом событие * ``` + * «Сначала состояние, потом событие» — инвариант, на котором держится + * [OutboxStore.currentCursor]: к моменту, когда событие `n` появилось в + * outbox, строка состояния со `seq = n` уже записана. * - * **Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id, - * поэтому retry с тем же logical event (например, после network failure - * между producer и store) приведёт к дубликату в tail'е. Это OK для - * use case'a bounded-tail — клиент, делающий catchup через [events](after), - * получит свой диапазон ровно один раз при подключении, а последующие - * retry producer'а просто насытят tail повторами, не задевая уже - * обработанные. Для гарантированной exactly-once — dedup через - * [message-store] (там есть монотонный `id`). - * - * **Silently evicted**: implementation может выкинуть этот event сразу - * после append (TTL/cap) без уведомления producer'а. Producer **не - * должен** полагаться на то, что event дойдёт до клиента, если он - * вне retention window. + * Offset **обязан** быть выставлен в [CommonEvent.offset]; store проверяет + * строгую монотонность и бросает [IllegalArgumentException] на нарушение. */ interface MutableOutboxStore : OutboxStore { + /** - * Положить event в log. + * Забронировать следующий монотонный offset (делегирует в + * [OffsetSequencer.reserve]). Вызывается **до** записи состояния. + */ + suspend fun reserveOffset(): Long + + /** + * Положить событие в лог. [CommonEvent.offset] должен быть уже выставлен + * (обычно значением из [reserveOffset]). * - * - **Не идемпотентно** — см. KDoc интерфейса. - * - **Suspend** для KMP I/O impl'ов (SQLite через JNI). + * **Не идемпотентно** — повторный append с тем же offset'ом нарушает + * монотонность и бросит исключение (защита от двойной записи). */ suspend fun append(event: CommonEvent) } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OffsetSequencer.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OffsetSequencer.kt new file mode 100644 index 0000000..69d1cf7 --- /dev/null +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OffsetSequencer.kt @@ -0,0 +1,39 @@ +package pw.binom.agentik.outbox + +/** + * Источник монотонных offset'ов **для одного агента**. + * + * Один счётчик на агента, сквозной по всем сущностям (conversation + message + + * lifecycle): это даёт единый [Cursor] на всё — глобальный курсор (чат + * появился/умер/переименован) и per-chat курсор суть просто закладки в одном + * потоке offset'ов (как offset одного Kafka-topic'а с ключом `conversationId`). + * + * **Кто владеет счётчиком**: writer-сторона. В `standalone` это персистентный + * счётчик в той же SQLite-БД, что и журнал, — иначе рестарт сервера сбросил бы + * offset'ы, а у клиента в локальной БД остался бы старый курсор. Персистентность + * даёт дешёвый инкрементальный resume после рестарта; полная инвалидация + * (сброс/восстановление БД) закрывается ротацией [epoch]. + * + * **Порядок записи (инвариант)**: сначала пишется строка состояния с + * `seq = reserve()`, потом событие с `offset = <тот же>`. Тогда «состояние + * с offset ≤ C» гарантированно уже записано в момент чтения снапшота с + * курсором C, и всё, что `> C`, придёт потоком. + * + * **`epoch()`/`current()` — не-`suspend`**: persistent-реализация читает + * `(epoch, counter)` один раз при создании (в конструкторе, где и так идёт + * синхронный I/O открытия БД) и держит в памяти; на диск пишет только + * [reserve]. Это позволяет читать курсор из любого места без корутины. + */ +interface OffsetSequencer { + /** + * Идентификатор текущей эпохи счётчика (см. [Cursor.epoch]). Стабилен между + * рестартами, пока счётчик персистентный. + */ + fun epoch(): String + + /** Следующий offset, который будет выдан [reserve] (next offset to assign). */ + fun current(): Long + + /** Забронировать следующий offset; монотонно возрастает на 1 (durable). */ + suspend fun reserve(): Long +} diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineEvent.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineEvent.kt index 8358fec..94dc5b1 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineEvent.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineEvent.kt @@ -8,19 +8,19 @@ import kotlin.time.Instant * **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и * стриминг ответа агента (дельты текста/картинок). * - * Принципиальное отличие от [Event] (durable): + * Принципиальное отличие от [DurableEvent] (durable): * - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox], * ни в journal. Это чистый live-канал. * - **Только онлайн-подписка**: события, эмитнутые до подписки * (или в момент обрыва соединения), не реплеятся и не восстанавливаются. * Потерянный фрагмент не страшен — целый результат хода приходит - * durable-событием ([Event.AssistantMessage]) и/или лежит в журнале. + * durable-событием ([DurableEvent.AssistantMessage]) и/или лежит в журнале. * - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где * есть что реплеить. * * Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор, * который, попав в durable store, копится в RAM (standalone-outbox растёт - * unbounded) и засоряет историю. В [Event] остаются только «целые» события, + * unbounded) и засоряет историю. В [DurableEvent] остаются только «целые» события, * пригодные к перезапросу по курсору. */ @Serializable @@ -28,6 +28,14 @@ sealed interface OnlineEvent { /** Момент эмиссии события в UTC (для упорядочивания в рамках стрима). */ val date: Instant + /** + * Id диалога, которому принадлежит событие. Делает событие + * самодостаточным: общий live-поток всех диалогов (`GET /online`) + * разбирается на стороне клиента без внешнего конверта — какое + * событие к какому чату, видно прямо из payload'а. + */ + val conversationId: String + @Serializable enum class ResponseType { @SerialName("text") TEXT, @@ -36,35 +44,48 @@ sealed interface OnlineEvent { /** * Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до** - * [End]/[Event.Interrupted]/[Event.Error], синхронно из `Conversation.send()`, + * [End]/[DurableEvent.Interrupted]/[DurableEvent.Error], синхронно из `Conversation.send()`, * чтобы UI мог показать спиннер ещё до первого токена ответа. */ @Serializable @SerialName("working") - data class Working(override val date: Instant) : OnlineEvent + data class Working(override val date: Instant, override val conversationId: String) : OnlineEvent - /** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [Event]. */ + /** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [DurableEvent]. */ @Serializable @SerialName("end") - data class End(override val date: Instant) : OnlineEvent + data class End(override val date: Instant, override val conversationId: String) : OnlineEvent /** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */ @Serializable @SerialName("start_reasoning") - data class StartReasoning(override val date: Instant) : OnlineEvent + data class StartReasoning(override val date: Instant, override val conversationId: String) : OnlineEvent /** Начало ответа ассистента заданного типа. Далее идут соответствующие `Append*`. */ @Serializable @SerialName("start_response") - data class StartResponse(override val date: Instant, val responseType: ResponseType) : OnlineEvent + data class StartResponse( + override val date: Instant, + override val conversationId: String, + val responseType: ResponseType, + ) : OnlineEvent /** Очередная дельта текста ответа. */ @Serializable @SerialName("append_text") - data class AppendText(override val date: Instant, val body: String) : OnlineEvent + data class AppendText( + override val date: Instant, + override val conversationId: String, + val body: String, + ) : OnlineEvent /** Очередная дельта картинки ответа. */ @Serializable @SerialName("append_image") - data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : OnlineEvent + data class AppendImage( + override val date: Instant, + override val conversationId: String, + val body: ByteArray, + val mime: String, + ) : OnlineEvent } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineOutbox.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineOutbox.kt index 127227d..b41844c 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineOutbox.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OnlineOutbox.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow * * Это осознанный компромисс: дельты токенов — высокочастотный мусор, * который в durable-сторе копился бы в RAM и засорял историю. Потеря - * фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage] + * фрагмента при обрыве не критична — целый ответ приходит [DurableEvent.AssistantMessage] * и/или лежит в [pw.binom.agentik.journal.JournalStore]. * * Read-only view: запись — через [MutableOnlineOutbox]. diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxGapException.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxGapException.kt new file mode 100644 index 0000000..b8c2caf --- /dev/null +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxGapException.kt @@ -0,0 +1,34 @@ +package pw.binom.agentik.outbox + +/** + * Курсор клиента вышел за пределы retention'а outbox'а, **или** принадлежит + * другой [Cursor.epoch]. + * + * Это **не ошибка выполнения**, а сигнал протокола: «твой курсор мёртв — я не + * могу отдать непрерывный поток событий, начиная с него». Клиент обязан: + * 1. очистить/пометить свой локальный кэш как устаревший; + * 2. запросить у сервера свежий **snapshot состояния** (он вернёт и состояние, + * и актуальный [Cursor]); + * 3. подписаться на события `after = ` и накатить snapshot, + * затем буферизованные дельты. + * + * Бросается **изнутри** [OutboxStore.events] / [OutboxStore.conversationEvents] / + * [OutboxStore.agentEvents] (то есть из Flow, а не отдельной pre-check'ом) — так + * проверка делается под тем же lock'ом, что и регистрация подписчика, и не + * гоняется с конкурентной эвикцией. + * + * **Retry-политики НЕ должна этому исключению ретраить** (см. + * `ReconnectingOutbox`): повторный connect с тем же курсором даст тот же gap и + * превратится в бесконечный цикл. Обработка — resync, не backoff. + */ +class OutboxGapException( + /** Курсор, с которого клиент просил поток (может быть `null` для live-only). */ + val requested: Cursor?, + /** Актуальный курсор сервера (`currentCursor()`). */ + val current: Cursor, + /** Минимальный курсор, с которого ещё можно продолжить поток (`oldestCursor()`). */ + val oldest: Cursor, +) : RuntimeException( + "Outbox cursor is out of retention: requested=$requested, " + + "oldest=$oldest, current=$current. Re-snapshot the full state." +) diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxStore.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxStore.kt index df4a2ed..b770f1c 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxStore.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/OutboxStore.kt @@ -3,92 +3,84 @@ package pw.binom.agentik.outbox import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlin.time.Instant /** - * Bounded-tail event log с автоматическим управлением TTL. + * Bounded-tail лог **durable**-событий агента. * - * Хранит **только durable-события [Event]** — «целые» факты хода - * (Working/End/Interrupted/Error, ToolCall/ToolResult/ToolFailed). - * Высокочастотный **стриминг ответа** (дельты текста/картинок) сюда - * НЕ попадает — он живёт в [OnlineOutbox] (live-only, не сохраняется). + * Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle + * диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и + * картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only, не + * сохраняется и не реплеится). * - * **Архитектура двухуровневого хранилища событий**: - * 1. **Этот store** = короткий bounded tail (live SSE + недавний replay). - * События автоматически эвиктятся по TTL/cap (implementation-defined). - * 2. **Message store (`:message-store-api`)** = полный audit log, никогда не - * эвиктится. Source of truth для всего прошлого. + * ## Два уровня хранения + * 1. **Этот store** — короткий bounded tail (live SSE + недавний replay), + * эвиктится по TTL/cap (implementation-defined). + * 2. **Журнал (`:journal-api`)** — полный audit log, никогда не эвиктится. + * Source of truth для всего прошлого. Он и есть «полное состояние», + * которое запрашивает клиент при resync'е. * - * **Паттерн reconnect** (caller'ы): + * ## Курсор, а не дата + * Позиция в потоке — монотонный [Cursor] `(epoch, offset)`, а не wall-clock + * [CommonEvent.date]. Offset уникален и упорядочен даже когда два события + * делят одну миллисекунду. `Instant` для этого не годится (лоссов на ничьих), + * случайный `id` — тоже (не задаёт порядок записи). + * + * ## Протокол клиента (гарантия «в итоге корректное состояние») * ``` - * val earliest = store.earliestEventDate() - * if (client.lastSeen < earliest) { - * // gap обнаружен — идём в message store за прошлым - * val gap = messageStore.query(after = client.lastSeen, before = earliest) - * applyAll(gap) + * // 1. Пробуем продолжить с сохранённого курсора. + * try { + * outbox.conversationEvents(after = saved, conversationId = id).collect { apply(it) } + * } catch (e: OutboxGapException) { + * // 2. Курсор мёртв — берём свежий снапшот (состояние + его курсор). + * val snap = agent.chatSnapshot(id) // { state, cursor } + * clearLocal(); applySnapshot(snap.state) + * // 3. Подписка с курсора снапшота; дельты > cursor накатываются поверх. + * outbox.conversationEvents(after = snap.cursor, conversationId = id).collect { apply(it) } * } - * store.events(after = client.lastSeen).collect { apply(it) } * ``` + * Точный порядок на стороне сервера/snapshot'а (subscribe-before-snapshot, + * буферизация дельт, idempotent apply) описан в `:client/README.md`. * - * **Нет delete/cleanup методов** — TTL/cap eviction полностью на стороне - * implementation. Это: - * - Убирает single source of truth дублирование (caller не может забыть cleanup). - * - Позволяет impl выбирать retention strategy (TTL, size cap, sliding window). - * - Сохраняет контракт clean: интерфейс только о put/get. + * ## Gap detection + * `after != null && after.offset < oldestCursor().offset` (или другой + * [Cursor.epoch]) → [OutboxGapException] бросается **изнутри** Flow. Проверка + * идёт под тем же lock'ом, что и регистрация подписчика (одним критическим + * участком), поэтому не гоняется с конкурентной эвикцией и не теряет события + * в окне «snapshot → live». * - * **Read-only**: этот интерфейс предоставляет только read-операции. - * Для записи см. [MutableOutboxStore]. + * ## Read-only + * Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore]. * - * **Подписки нереентрантные**: каждый вызов [events] создаёт **новую - * подписку** (cold Flow). Один [events] НЕ видит события, добавленные до - * его вызова, если [after] == null. Если нужен catchup — передавайте - * `after = lastSeenDate` явно. - * - * **Multi-consumer**: разные [events] подписки видят одно и то же live - * tail. Каждая подписка — независимая projection. + * ## Подписки + * Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая + * независимая подписка** (cold Flow). `after == null` → только live (события + * с момента вызова). Иные consumer'ы видят тот же live-tail; каждая подписка — + * своя проекция. */ interface OutboxStore : AutoCloseable { /** - * Subscribe на events. + * Подписка на события. * - * **`after == null`** → только **live** (события с момента вызова - * `events()`). Каждое новое событие от любого producer'а немедленно - * появится в Flow. Буфер replay не отдаётся. + * - `after == null` → **только live** (события с момента вызова, replay + * буфера не отдаётся); + * - `after != null` → сначала **catchup** всех буферизованных событий с + * `offset > after.offset` (по возрастанию offset), затем live. * - * **`after != null`** → сначала **catchup**: эмитт все буферизованные - * события с `date > after`, порядок `date ASC` (ties по `id ASC`). - * Затем **live** (как null-case). - * - * Cold Flow: каждый вызов — новая подписка. Вызов **после** append'а - * не увидит этот конкретный event (если `after == null`); для catchup - * передавайте явный `after`. - * - * ВАЖНО: `Flow` НЕ бросает ошибку при потере сети между producer и - * store — такие события просто не дойдут до этого Flow. Для гарантии - * полноты клиент обязан cross-check с [earliestEventDate] и fallback - * в message store при gap'е (см. KDoc интерфейса). + * @throws OutboxGapException изнутри Flow, если [after] старше + * [oldestCursor] (retention gap) или принадлежит другой эпохе. */ - fun events(after: Instant?): Flow + fun events(after: Cursor?): Flow /** - * Subscribe на **только conversation events** (т.е. [CommonEvent.Conversation]). + * Подписка только на conversation-события ([CommonEvent.Conversation]). * - * - [conversationId] == null → события **всех** диалогов. - * - [conversationId] != null → события **только этого** диалога. + * - `conversationId == null` → все диалоги; + * - `conversationId != null` → только этот диалог. * - * Семантика `after` идентична [events] (catchup + live). - * Возвращаемый тип — конкретный subtype [CommonEvent.Conversation]. + * Семантика [after] и `gap` идентична [events]. */ - /** - * **Default implementation** (читает все events + фильтрует). - * - * Простая реализация через [events] + filterIsInstance. Реализации - * могут override'нуть для эффективности (например, добавить SQL - * `WHERE conversation_id = ?` чтобы не тянуть всё в память), но - * контракт корректен и без override. - */ - fun conversationEvents(after: Instant?, conversationId: String? = null): Flow = + fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow = events(after) .filterIsInstance() .let { filtered -> @@ -97,53 +89,34 @@ interface OutboxStore : AutoCloseable { } /** - * Subscribe на **только agent events** ([CommonEvent.Agent] — - * создание/удаление/переименование диалога). + * Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/ + * переименование диалога). * - * Семантика `after` идентична [events] (catchup + live). - * Возвращаемый тип — конкретный subtype [CommonEvent.Agent]. - * - * Полезно для admin-дашборда, который хочет видеть только lifecycle - * диалогов без деталей ходов. + * Семантика [after] и `gap` идентична [events]. */ - /** - * **Default implementation** (читает все events + фильтрует по типу). - * - * Простая реализация через [events] + filterIsInstance. Реализации - * могут override'нуть для эффективности (например, читать только agent - * row'ы из БД), но контракт корректен и без override. - */ - fun agentEvents(after: Instant?): Flow = + fun agentEvents(after: Cursor?): Flow = events(after).filterIsInstance() /** - * Date **стартовой точки** буфера. + * Актуальный курсор: offset последнего **записанного** события + * (`lastOffset`). Это «commit point» снапшота: состояние со `seq <= cursor.offset` + * уже в БД, всё, что `> cursor.offset`, придёт потоком. * - * - Если буфер не пуст → `date` самого старого буферизованного event'а. - * - Если буфер пуст → текущее время (`Clock.System.now()` на момент вызова). - * - * **Семантика "now если пусто"** важна: позволяет клиенту безопасно - * подписаться на [events](after = earliest) сразу — он получит только - * новые live event'ы, без ложного catchup. Если бы возвращалось - * `Instant.DISTANT_PAST` или `null` (с проверкой), клиент мог бы - * ошибочно подписаться на несуществующий catchup и зависнуть в ожидании. - * - * **Используется клиентом для gap detection**: - * - `lastSeen < earliest` → есть дыра в покрытии, нужен fallback - * в message store за диапазоном `[lastSeen, earliest)`. - * - `lastSeen >= earliest` → всё доступно через [events](after), - * fallback не нужен. - * - `lastSeen == earliest` → OK, первый live event будет > earliest. - * - * **Edge case**: клиент, подключившийся до того как store увидел хоть - * один event, получает `earliest ≈ now`. Его `lastSeen` будет < earliest - * — адаптируется в первом же poll'е и пойдёт через fallback если - * сообщения audit log существуют (для consistency с прошлым). - * - * Suspend потому что в persistent impl'ах требует SQL query (`MIN(date)` - * или `Clock.now()` для пустого буфера). + * Клиент берёт его из снапшота либо напрямую перед подпиской. */ - suspend fun earliestEventDate(): Instant + suspend fun currentCursor(): Cursor + + /** + * Минимальный курсор, с которого ещё можно продолжить поток без разрыва. + * + * - `after.offset >= oldestCursor().offset` → replay возможен; + * - `after.offset < oldestCursor().offset` → [OutboxGapException]. + * + * Для никогда не эвиктировавшего буфера равен offset'у последнего события + * (т.е. «истории нет, но резумиться с конца можно»), а не `-1`: клиент, + * догнавший состояние до рестарта, продолжает инкрементально. + */ + suspend fun oldestCursor(): Cursor override fun close() } diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/PersistentOffsetSequencer.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/PersistentOffsetSequencer.kt new file mode 100644 index 0000000..f0b835a --- /dev/null +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/PersistentOffsetSequencer.kt @@ -0,0 +1,62 @@ +package pw.binom.agentik.outbox + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * [OffsetSequencer] поверх персистентного [CursorStore] — production-счётчик + * событий агента. + * + * На создании читает сохранённый [Cursor] (`epoch` + next-offset) синхронно и + * держит его в памяти. Позиция переживает рестарт процесса, поэтому обычный + * рестарт сервера **не меняет** `epoch` и offset'ы остаются монотонными — + * клиент продолжает инкрементально (см. [Cursor.epoch] KDoc), а не получает + * gap на каждой перезагрузке. + * + * ### Стартовый offset + * + * Если хранилище пустое (первый запуск / апгрейд БД, где `message.seq` уже + * накоплен), новый `epoch` генерируется сразу, а `next` берётся из [initialNext] + * — по умолчанию `0`, но апгрейд должен передать `maxSeq + 1` журнала, иначе + * новые offset'ы столкнутся с уже записанными `seq`. Новый `epoch` при этом + * корректно заставляет клиентов сделать однократный resync. + * + * ### Ротация epoch + * + * Смена «мира» (сброс/восстановление БД) выполняется вызывающим: очисти + * [CursorStore] — следующий старт сгенерирует новый `epoch`. + */ +class PersistentOffsetSequencer( + private val store: CursorStore, + private val initialNext: () -> Long = { 0L }, + private val newEpoch: () -> String = { Cursor.newEpoch() }, +) : OffsetSequencer { + + private val mutex = Mutex() + private val epoch: String + private var next: Long + + init { + val saved = store.load() + if (saved == null) { + epoch = newEpoch() + next = initialNext() + // Фиксируем epoch сразу, чтобы он не «прыгал» до первого события. + store.save(Cursor(epoch = epoch, offset = next)) + } else { + epoch = saved.epoch + next = saved.offset + } + } + + override fun epoch(): String = epoch + + override fun current(): Long = next + + override suspend fun reserve(): Long = mutex.withLock { + val assigned = next + next = assigned + 1 + store.save(Cursor(epoch = epoch, offset = next)) + assigned + } +} diff --git a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOffsetSequencer.kt b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOffsetSequencer.kt new file mode 100644 index 0000000..bffd698 --- /dev/null +++ b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOffsetSequencer.kt @@ -0,0 +1,34 @@ +package pw.binom.agentik.outbox.inmemory + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.OffsetSequencer + +/** + * In-memory [OffsetSequencer] — для тестов, dev-режима и ephemeral runtime. + * + * Эпоха генерируется случайно при создании и **не переживает** пересоздание + * инстанса: новый store → новый epoch → клиент с прежним курсором получит + * [pw.binom.agentik.outbox.OutboxGapException] и сделает resync. Для + * production-агента нужен персистентный счётчик (см. `:journal-ksqlite`). + */ +class InMemoryOffsetSequencer( + private val epochId: String = newEpoch(), + initialOffset: Long = 0L, +) : OffsetSequencer { + + private val mutex = Mutex() + private var counter: Long = initialOffset + + override fun epoch(): String = epochId + + override fun current(): Long = counter + + override suspend fun reserve(): Long = mutex.withLock { counter++ } + + companion object { + /** Делегирует в [Cursor.newEpoch] — единый генератор epoch'а проекта. */ + fun newEpoch(): String = Cursor.newEpoch() + } +} diff --git a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOnlineOutbox.kt b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOnlineOutbox.kt index 50d5ecb..a683136 100644 --- a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOnlineOutbox.kt +++ b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOnlineOutbox.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map import pw.binom.agentik.outbox.MutableOnlineOutbox import pw.binom.agentik.outbox.OnlineEvent @@ -19,18 +18,16 @@ import pw.binom.agentik.outbox.OnlineEvent * **старые дропаются** ([BufferOverflow.DROP_OLDEST]), [appendOnline] не * блокируется. Потеря дельты допустима (см. [OnlineOutbox]). * - * **Маршрутизация**: один общий [MutableSharedFlow] c `conversationId` - * в envelope; [onlineEvents] фильтрует по диалогу. Отдельный flow-на-диалог - * не держим, чтобы не плодить per-conversation подписки, которые надо - * чистить вручную. + * **Маршрутизация**: один общий [MutableSharedFlow] всех диалогов; каждый + * [OnlineEvent] несёт свой `conversationId`, [onlineEvents] фильтрует по нему. + * Отдельный flow-на-диалог не держим, чтобы не плодить per-conversation + * подписки, которые надо чистить вручную. */ class InMemoryOnlineOutbox( liveBufferCapacity: Int = DEFAULT_LIVE_BUFFER_CAPACITY, ) : MutableOnlineOutbox { - private data class Envelope(val conversationId: String, val event: OnlineEvent) - - private val liveFlow = MutableSharedFlow( + private val liveFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = liveBufferCapacity, onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -42,17 +39,14 @@ class InMemoryOnlineOutbox( } } - override fun onlineEvents(): Flow = - liveFlow.map { it.event } + override fun onlineEvents(): Flow = liveFlow override fun onlineEvents(conversationId: String): Flow = - liveFlow.filter { it.conversationId == conversationId }.map { it.event } + liveFlow.filter { it.conversationId == conversationId } - override suspend fun appendOnline(conversationId: String, event: OnlineEvent)= - liveFlow.emit(Envelope(conversationId, event)) + override suspend fun appendOnline(event: OnlineEvent) = liveFlow.emit(event) - override fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean = - liveFlow.tryEmit(Envelope(conversationId, event)) + override fun tryAppendOnline(event: OnlineEvent): Boolean = liveFlow.tryEmit(event) override fun close() { // replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки. diff --git a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStore.kt b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStore.kt index b6dc1d1..6366117 100644 --- a/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStore.kt +++ b/outbox-inmemory/src/commonMain/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStore.kt @@ -2,135 +2,144 @@ package pw.binom.agentik.outbox.inmemory import kotlin.time.Clock import kotlin.time.Duration -import kotlin.time.Instant import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import pw.binom.agentik.outbox.MutableOutboxStore import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.MutableOutboxStore +import pw.binom.agentik.outbox.OffsetSequencer +import pw.binom.agentik.outbox.OutboxGapException /** * In-memory реализация [MutableOutboxStore] на `ArrayDeque` + [Mutex]. * - * **Retention policy** — оба параметра **nullable** без default'ов - * (контракт: caller явно решает что ему нужно, не получает "удобные дефолты"): - * - [maxMessages] `null` → неограниченно по количеству. - * - [ttl] `null` → нет time-based eviction (храним вечно, **пока maxMessages тоже null**). - * - **Оба `null` → вечное хранилище.** - * - Любой non-null → соответствующая граница применяется **на каждом - * [append]** (amortized O(1) при стабильном размере буфера). + * ## Retention + * - [maxMessages] `null` → неограниченно по количеству; + * - [ttl] `null` → нет time-based eviction; + * - **оба `null` → вечное хранилище в RAM**; + * - любой non-null → граница применяется на каждом [append] (amortized O(1)). * - * **Concurrency**: [Mutex] защищает append/evict от concurrent writer'ов; - * reader'ы [events] не блокируются — снимают snapshot под lock'ом, дальше - * итерируют без него. Snapshot под `mutex.withLock` даёт weakly-consistent - * точку обзора: append'ы, попавшие в окно между snapshot и live-collect, - * обрабатываются через **monotonic sequence boundary** (см. [events] KDoc). + * ## Курсор и gap + * Offset'ы берутся из [sequencer] ([reserveOffset] = [OffsetSequencer.reserve]). + * [currentCursor] = offset последнего **записанного** события; [oldestCursor] = + * `buffer.first().offset - 1` (или `lastOffset`, если буфер пуст). Подписка + * `after` старше `oldestCursor` (или с чужой эпохой) бросает [OutboxGapException]. * - * **Live tail**: [MutableSharedFlow] с DROP_OLDEST policy. Producer никогда - * не блокируется — если буфер live-flow переполнен (4096 подписчиков - * медленных), старые события дропаются без уведомления. Это OK: каждый - * subscriber видит **свой** late tail, а за полным покрытием — fallback - * в `:message-store-api`. + * На старте `lastOffset = sequencer.current() - 1`: если счётчик персистентный + * и равен N, то клиент с курсором N-1 (догнавший состояние до рестарта) + * продолжает инкрементально, а клиент с курсором < N-1 получает gap и делает + * resync. Так рестарт сервера не теряет события молча. * - * **Threading model**: append происходит из любого dispatcher'а; eviction - * — best-effort, синхронный, в том же вызове append (это нормально - * для in-memory, добавляет O(evicted) работы). + * ## Concurrency + * Один [Mutex] защищает append/evict/подписки. Регистрация подписчика и снятие + * snapshot'а идут **одним критическим участком** — это закрывает окно + * «snapshot → live», в котором append мог потеряться: всё, что попадёт в буфер + * после регистрации, доедет до подписчика через его [Channel]. Snapshot + * итерируется и эмитится вне lock'а. + * + * ## Live-tail + * Каждому подписчику — свой [Channel] с `DROP_OLDEST`: медленный подписчик + * теряет только хвост live-потока и обязан сам сделать resync при обнаружении + * gap'а по retention'у. */ class InMemoryOutboxStore( private val maxMessages: Int?, private val ttl: Duration?, private val clock: Clock = Clock.System, + private val sequencer: OffsetSequencer = InMemoryOffsetSequencer(), ) : MutableOutboxStore { private val mutex = Mutex() private val buffer = ArrayDeque() - private val liveFlow = MutableSharedFlow( - replay = 0, - extraBufferCapacity = LIVE_BUFFER_CAPACITY, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) + private val subscribers = mutableSetOf>() + + /** + * Offset последнего **записанного** события. Инициализируется из счётчика: + * `current() - 1` (для fresh-счётчика это `-1`). + */ + private var lastOffset: Long = sequencer.current() - 1 init { - // Аргументы — НЕ optional default'ы; explicit null = "не применяется". - // Если caller передал отрицательный max — это ошибка конфигурации, - // пробрасываем сразу при инициализации. require(maxMessages == null || maxMessages > 0) { "maxMessages must be > 0 or null, got $maxMessages" } } + override suspend fun reserveOffset(): Long = sequencer.reserve() + override suspend fun append(event: CommonEvent) { mutex.withLock { + require(event.offset > lastOffset) { + "Non-monotonic offset: got ${event.offset}, last=${lastOffset}" + } buffer.addLast(event) + lastOffset = event.offset + subscribers.forEach { it.trySend(event) } + evictLocked() } - liveFlow.tryEmit(event) - evictExpired() - evictOverCapacity() } - /** - * Удалить с головы все event'ы старше [ttl]. Amortized O(evicted). - * Если [ttl] null — no-op. - */ - private suspend fun evictExpired() { - val ttlValue = ttl ?: return - val cutoff = clock.now() - ttlValue - mutex.withLock { + private fun evictLocked() { + val ttlValue = ttl + if (ttlValue != null) { + val cutoff = clock.now() - ttlValue while (true) { - val head = buffer.firstOrNull() ?: return@withLock - if (head.date >= cutoff) return@withLock + val head = buffer.firstOrNull() ?: break + if (head.date >= cutoff) break buffer.removeFirst() } } - } - - /** - * Удалить с головы пока размер > [maxMessages]. Amortized O(evicted). - * Если [maxMessages] null — no-op. - */ - private suspend fun evictOverCapacity() { - val cap = maxMessages ?: return - mutex.withLock { + val cap = maxMessages + if (cap != null) { while (buffer.size > cap) { - if (buffer.isEmpty()) return@withLock buffer.removeFirst() } } } - override fun events(after: Instant?): Flow = flow { - // Replay buffer — snapshot под mutex'ом, дальше iterate без lock'а. - // Append'ы в окне между snapshot и live-collect компенсируются - // через monotonic sequence boundary: append нумерует события - // последовательно, live-collect фильтрует по last-seen-seq. + override fun events(after: Cursor?): Flow = channelFlow { + val channel = Channel( + capacity = LIVE_BUFFER_CAPACITY, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) val snapshot: List = mutex.withLock { - if (after == null) { - buffer.toList() - } else { - buffer.filter { it.date > after } + val epoch = sequencer.epoch() + if (after != null) { + val floor = buffer.firstOrNull()?.let { it.offset - 1 } ?: lastOffset + if (after.epoch != epoch || after.offset < floor || after.offset > lastOffset) { + throw OutboxGapException( + requested = after, + current = Cursor(epoch, lastOffset), + oldest = Cursor(epoch, floor), + ) + } } + subscribers += channel + // `after == null` → live-only (без replay буфера). Чтобы получить + // весь удержанный хвост, клиент передаёт `after = oldestCursor()`. + if (after == null) emptyList() else buffer.filter { it.offset > after.offset } } - snapshot.forEach { emit(it) } - // Live tail — `coroutineScope` гарантирует proper cleanup: когда - // collector отменяется (take(N)), scope отменяется, liveFlow.collect - // выходит чисто. Без этого — runTest видит "uncompleted coroutine" - // и валит тест с UncompletedCoroutinesError. - coroutineScope { - liveFlow.collect { emit(it) } + try { + snapshot.forEach { send(it) } + for (event in channel) send(event) + } finally { + mutex.withLock { subscribers -= channel } + channel.close() } } - override suspend fun earliestEventDate(): Instant { - val earliest = mutex.withLock { buffer.firstOrNull()?.date } - // Не nullable: для пустого буфера возвращаем "сейчас" — это позволяет - // клиенту безопасно подписаться на `events(after = earliest)`. - return earliest ?: clock.now() + override suspend fun currentCursor(): Cursor = mutex.withLock { + Cursor(sequencer.epoch(), lastOffset) + } + + override suspend fun oldestCursor(): Cursor = mutex.withLock { + val floor = buffer.firstOrNull()?.let { it.offset - 1 } ?: lastOffset + Cursor(sequencer.epoch(), floor) } /** @@ -138,23 +147,15 @@ class InMemoryOutboxStore( * * `internal` потому что production код не должен ходить напрямую в буфер * (для этого есть `events(after)`). Доступно только из `commonTest`. - * - * Returns: иммутабельный snapshot (копия). Под `mutex.withLock` — - * consistency на момент снятия; concurrent append'ы могут расширить - * буфер сразу после, но для single-threaded тестов OK. */ internal suspend fun snapshot(): List = mutex.withLock { buffer.toList() } override fun close() { - // mutex не закрываем (kotlinx Mutex не AutoCloseable; для in-memory - // store GC соберёт всё при выходе ссылки). buffer чистим. buffer.clear() + subscribers.clear() } private companion object { - // Live-flow capacity — generous default. Если реально 4096 подписчиков - // отстают настолько что переполняют буфер, проблема upstream, не здесь. private const val LIVE_BUFFER_CAPACITY = 4096 } } - diff --git a/outbox-inmemory/src/commonTest/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStoreTest.kt b/outbox-inmemory/src/commonTest/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStoreTest.kt index 9cb2f0d..86a1dd3 100644 --- a/outbox-inmemory/src/commonTest/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStoreTest.kt +++ b/outbox-inmemory/src/commonTest/kotlin/pw/binom/agentik/outbox/inmemory/InMemoryOutboxStoreTest.kt @@ -2,6 +2,7 @@ package pw.binom.agentik.outbox.inmemory import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration @@ -15,7 +16,9 @@ import kotlinx.coroutines.runBlocking // (`CommonEvent.Agent` через alias даёт "Unresolved qualified name"). import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.CommonEvent -import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.DurableEvent +import pw.binom.agentik.outbox.OutboxGapException class InMemoryOutboxStoreTest { @@ -24,32 +27,45 @@ class InMemoryOutboxStoreTest { override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs) } - private fun evtAt(clock: Clock, body: String): CommonEvent = - CommonEvent.Agent(date = clock.now(), event = AgentEvent.Created(date = clock.now(), conversationId = body)) + private fun agentEvent(offset: Long, conversationId: String, at: Instant = Instant.fromEpochSeconds(offset)) = + CommonEvent.Agent( + date = at, + offset = offset, + event = AgentEvent.Created(date = at, conversationId = conversationId), + ) + + private fun evtAt(clock: Clock, offset: Long, body: String): CommonEvent = + CommonEvent.Agent( + date = clock.now(), + offset = offset, + event = AgentEvent.Created(date = clock.now(), conversationId = body), + ) + + private suspend fun InMemoryOutboxStore.ids() = + snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId } @Test fun `append stores all events when both limits are null store-forever`() = runBlocking { val store = InMemoryOutboxStore(maxMessages = null, ttl = null) repeat(100) { i -> - store.append(CommonEvent.Agent( - date = Instant.fromEpochSeconds(i.toLong()), - event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"), - )) + store.append(agentEvent(i.toLong(), "c-$i")) } assertEquals(100, store.snapshot().size) } + @Test + fun `reserveOffset is monotonic`() = runBlocking { + val store = InMemoryOutboxStore(maxMessages = null, ttl = null) + assertEquals(0L, store.reserveOffset()) + assertEquals(1L, store.reserveOffset()) + assertEquals(2L, store.reserveOffset()) + } + @Test fun `maxMessages cap evicts oldest when exceeded`() = runBlocking { val store = InMemoryOutboxStore(maxMessages = 3, ttl = null) - for (i in 1..5) { - store.append(CommonEvent.Agent( - date = Instant.fromEpochSeconds(i.toLong()), - event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"), - )) - } - val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId } - assertEquals(listOf("c-3", "c-4", "c-5"), ids) + for (i in 1..5) store.append(agentEvent(i.toLong(), "c-$i")) + assertEquals(listOf("c-3", "c-4", "c-5"), store.ids()) } @Test @@ -57,78 +73,94 @@ class InMemoryOutboxStoreTest { val clock = FixedClock() val store = InMemoryOutboxStore(maxMessages = null, ttl = 100.milliseconds, clock = clock) - store.append(evtAt(clock, "old")) + store.append(evtAt(clock, 0, "old")) clock.advance(50.milliseconds) - store.append(evtAt(clock, "middle")) + store.append(evtAt(clock, 1, "middle")) clock.advance(70.milliseconds) - store.append(evtAt(clock, "fresh")) + store.append(evtAt(clock, 2, "fresh")) - val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId } - assertEquals(listOf("middle", "fresh"), ids) + assertEquals(listOf("middle", "fresh"), store.ids()) } @Test fun `both maxMessages and ttl apply together`() = runBlocking { val clock = FixedClock() - // ttl=100ms so b at t=20 (deadline=120) survives when c is appended at t=80. - // Cap=2 evicts oldest. Result: [b, c]. val store = InMemoryOutboxStore(maxMessages = 2, ttl = 100.milliseconds, clock = clock) - store.append(evtAt(clock, "a")) + store.append(evtAt(clock, 0, "a")) clock.advance(20.milliseconds) - store.append(evtAt(clock, "b")) + store.append(evtAt(clock, 1, "b")) clock.advance(60.milliseconds) - store.append(evtAt(clock, "c")) + store.append(evtAt(clock, 2, "c")) - val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId } - assertEquals(listOf("b", "c"), ids) + assertEquals(listOf("b", "c"), store.ids()) } @Test - fun `events with null after replays buffer then collects live`() = runBlocking { + fun `currentCursor is last appended offset`() = runBlocking { val store = InMemoryOutboxStore(maxMessages = null, ttl = null) - store.append(evtAt(Clock.System, "e1")) - store.append(evtAt(Clock.System, "e2")) + store.append(agentEvent(0, "e0")) + store.append(agentEvent(1, "e1")) + assertEquals(1L, store.currentCursor().offset) + } + + @Test + fun `oldestCursor equals last offset when buffer is empty`() = runBlocking { + val store = InMemoryOutboxStore(maxMessages = null, ttl = null) + // Свежий счётчик: next offset = 0 → oldest = -1. + assertEquals(-1L, store.oldestCursor().offset) + assertEquals(store.currentCursor().epoch, store.oldestCursor().epoch) + } + + @Test + fun `oldestCursor is first-minus-one when buffer is non-empty`() = runBlocking { + val store = InMemoryOutboxStore(maxMessages = 2, ttl = null) + store.append(agentEvent(0, "a")) + store.append(agentEvent(1, "b")) + store.append(agentEvent(2, "c")) + // buffer = [1, 2]; oldest = 1 - 1 = 0. + assertEquals(0L, store.oldestCursor().offset) + } + + @Test + fun `events with null after is live-only (no replay)`() = runBlocking { + val store = InMemoryOutboxStore(maxMessages = null, ttl = null) + store.append(agentEvent(0, "e1")) + store.append(agentEvent(1, "e2")) val collected = mutableListOf() val done = CompletableDeferred() val job = launch { store.events(after = null).collect { e -> collected.add(e) - if (collected.size >= 3) done.complete(Unit) + done.complete(Unit) } } delay(20) - store.append(evtAt(Clock.System, "e3")) + store.append(agentEvent(2, "e3")) done.await() job.cancel() - assertEquals(3, collected.size) + assertEquals(listOf("e3"), collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }) } @Test - fun `events with after catches up then continues with live`() = runBlocking { + fun `events with cursor catches up then continues with live`() = runBlocking { val store = InMemoryOutboxStore(maxMessages = null, ttl = null) - val t0 = Instant.fromEpochSeconds(0) - val t1 = Instant.fromEpochSeconds(10) - val t2 = Instant.fromEpochSeconds(20) - - store.append(CommonEvent.Agent(date = t0, event = AgentEvent.Created(date = t0, conversationId = "e1"))) - store.append(CommonEvent.Agent(date = t1, event = AgentEvent.Created(date = t1, conversationId = "e2"))) - store.append(CommonEvent.Agent(date = t2, event = AgentEvent.Created(date = t2, conversationId = "e3"))) + store.append(agentEvent(0, "e1")) + store.append(agentEvent(1, "e2")) + store.append(agentEvent(2, "e3")) + val from = Cursor(store.currentCursor().epoch, 0L) val collected = mutableListOf() val done = CompletableDeferred() val job = launch { - store.events(after = t0).collect { e -> + store.events(after = from).collect { e -> collected.add(e) if (collected.size >= 3) done.complete(Unit) } } delay(20) - store.append(CommonEvent.Agent( - date = Instant.fromEpochSeconds(30), - event = AgentEvent.Created(date = Instant.fromEpochSeconds(30), conversationId = "e4"), - )) + store.append(agentEvent(3, "e4")) done.await() job.cancel() val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId } @@ -136,40 +168,70 @@ class InMemoryOutboxStoreTest { } @Test - fun `earliestEventDate returns oldest buffered date`() = runBlocking { - val clock = FixedClock() - val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock) - store.append(evtAt(clock, "e1")) - clock.advance(100.milliseconds) - store.append(evtAt(clock, "e2")) + fun `subscribe from currentCursor receives only newer events - no handoff loss`() = runBlocking { + val store = InMemoryOutboxStore(maxMessages = null, ttl = null) + store.append(agentEvent(0, "old")) - assertEquals(Instant.fromEpochMilliseconds(1_000_000_000L), store.earliestEventDate()) + val collected = mutableListOf() + val from = store.currentCursor() + val job = launch { store.events(after = from).collect { collected.add(it) } } + delay(50) + store.append(agentEvent(1, "new")) + delay(50) + job.cancel() + + assertEquals(listOf(1L), collected.map { it.offset }) } @Test - fun `earliestEventDate returns current time when buffer is empty`() = runBlocking { - val clock = FixedClock(nowMs = 5_000_000_000L) - val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock) - assertEquals(Instant.fromEpochMilliseconds(5_000_000_000L), store.earliestEventDate()) + fun `gap exception when cursor older than oldest`() = runBlocking { + val store = InMemoryOutboxStore(maxMessages = 2, ttl = null) + store.append(agentEvent(0, "e0")) + store.append(agentEvent(1, "e1")) + store.append(agentEvent(2, "e2")) + + val tooOld = Cursor(store.currentCursor().epoch, -1L) + assertFailsWith { + store.events(after = tooOld).collect { } + } + // Ровно на границе — ещё можно. + val atFloor = Cursor(store.currentCursor().epoch, 0L) + val got = mutableListOf() + val job = launch { store.events(after = atFloor).collect { got.add(it.offset) } } + delay(30) + job.cancel() + assertEquals(listOf(1L, 2L), got) + } + + @Test + fun `gap exception on epoch mismatch`(): Unit = runBlocking { + val store = InMemoryOutboxStore(maxMessages = null, ttl = null) + store.append(agentEvent(0, "e0")) + val foreign = Cursor("some-other-epoch", 0L) + assertFailsWith { + store.events(after = foreign).collect { } + } + } + + @Test + fun `gap exception exposes requested current and oldest`() = runBlocking { + val store = InMemoryOutboxStore(maxMessages = 1, ttl = null) + store.append(agentEvent(0, "e0")) + store.append(agentEvent(1, "e1")) + val tooOld = Cursor(store.currentCursor().epoch, -1L) + val e = assertFailsWith { store.events(tooOld).collect { } } + assertEquals(tooOld, e.requested) + assertEquals(1L, e.current.offset) + assertEquals(0L, e.oldest.offset) } @Test fun `conversationEvents default impl filters to conversation variant`() = runBlocking { val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val now = Instant.fromEpochSeconds(0) - store.append(CommonEvent.Agent( - date = now, - event = AgentEvent.Created(date = now, conversationId = "agent-event"), - )) - store.append(CommonEvent.Conversation( - date = now, - conversationId = "c-1", - event = Event.Interrupted(date = now), - )) + store.append(CommonEvent.Agent(now, 0, AgentEvent.Created(now, "agent-event"))) + store.append(CommonEvent.Conversation(now, 1, "c-1", DurableEvent.Interrupted(now))) - // Snapshot-based test of the default impl (uses events() + filterIsInstance). - // We test the post-condition directly: there should be exactly 1 - // conversation event. val all = store.snapshot() assertEquals(2, all.size) assertEquals(1, all.count { it is CommonEvent.Conversation }) @@ -180,11 +242,10 @@ class InMemoryOutboxStoreTest { fun `conversationEvents with conversationId filters to that conversation`() = runBlocking { val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val now = Instant.fromEpochSeconds(0) - store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now))) - store.append(CommonEvent.Conversation(now, "c-2", Event.Interrupted(now))) - store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now))) + store.append(CommonEvent.Conversation(now, 0, "c-1", DurableEvent.Interrupted(now))) + store.append(CommonEvent.Conversation(now, 1, "c-2", DurableEvent.Interrupted(now))) + store.append(CommonEvent.Conversation(now, 2, "c-1", DurableEvent.Interrupted(now))) - // Test the filter logic by manually filtering snapshot. val c1 = store.snapshot() .filterIsInstance() .filter { it.conversationId == "c-1" } @@ -196,23 +257,26 @@ class InMemoryOutboxStoreTest { fun `agentEvents default impl filters to agent variant`() = runBlocking { val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val now = Instant.fromEpochSeconds(0) - store.append(CommonEvent.Agent( - date = now, - event = AgentEvent.Created(date = now, conversationId = "created"), - )) - store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now))) + store.append(CommonEvent.Agent(now, 0, AgentEvent.Created(now, "created"))) + store.append(CommonEvent.Conversation(now, 1, "c-1", DurableEvent.Interrupted(now))) - val all = store.snapshot() - val agents = all.filterIsInstance() + val agents = store.snapshot().filterIsInstance() assertEquals(1, agents.size) - val created = agents[0].event as AgentEvent.Created - assertEquals("created", created.conversationId) + assertEquals("created", (agents[0].event as AgentEvent.Created).conversationId) + } + + @Test + fun `append with non-monotonic offset throws`(): Unit = runBlocking { + val store = InMemoryOutboxStore(maxMessages = null, ttl = null) + store.append(agentEvent(5, "e5")) + assertFailsWith { store.append(agentEvent(5, "again")) } + assertFailsWith { store.append(agentEvent(4, "lower")) } } @Test fun `close clears buffer`() = runBlocking { val store = InMemoryOutboxStore(maxMessages = null, ttl = null) - store.append(evtAt(Clock.System, "e1")) + store.append(agentEvent(0, "e1")) store.close() assertEquals(emptyList(), store.snapshot()) } diff --git a/outbox-ksqlite/build.gradle.kts b/outbox-ksqlite/build.gradle.kts new file mode 100644 index 0000000..6b82ae6 --- /dev/null +++ b/outbox-ksqlite/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +// KMP-реализация :outbox-api `CursorStore` поверх ksqlite. +// Минимальная — только таблица `outbox_cursor` (одна строка: epoch + next offset). +// Даёт production-агенту персистентный `OffsetSequencer`: обычный рестарт сервера +// не ротирует epoch, клиент продолжает инкрементально. +// +// Цели сборки — jvm() + linuxX64() + mingwX64() (как у остальных ksqlite-модулей). + +kotlin { + jvmToolchain(21) + + jvm() + linuxX64() + mingwX64() + + sourceSets { + commonMain.dependencies { + implementation(libs.ksqlite) + + api(project(":outbox-api")) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } +} diff --git a/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStore.kt b/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStore.kt new file mode 100644 index 0000000..579c09d --- /dev/null +++ b/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStore.kt @@ -0,0 +1,78 @@ +package pw.binom.agentik.outbox.ksqlite + +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.CursorStore +import pw.binom.db.ksqlite.SQLiteConnection +import pw.binom.db.ksqlite.SQLitePreparedStatement + +/** + * ksqlite-реализация [CursorStore] — таблица `outbox_cursor` (одна строка, + * `id = 1`). + * + * ## Lifecycle соединения + * + * Две формы, как у остальных ksqlite-store'ов: + * - `KsqliteCursorStore(connection)` — внешнее соединение, store НЕ закрывает + * его в [close]. Для shared-connection bundle'а (`SqliteStores.assemble`). + * - `KsqliteCursorStore(path)` — открывает файловое соединение и закрывает + * его в [close]. + * + * ## Синхронизация + * + * `load()` вызывается один раз при создании `PersistentOffsetSequencer`, + * `save()` — сериализован его `Mutex`. Поэтому собственный mutex не нужен; + * prepared statements закрываются в [close] ДО owned-connection (иначе + * финалайзеры stmt'ов дёргают уже закрытый parent → SIGSEGV). + */ +class KsqliteCursorStore private constructor( + private val connection: SQLiteConnection, + private val ownsConnection: Boolean, +) : CursorStore, AutoCloseable { + + /** Внешнее соединение — store НЕ закрывает его в [close]. */ + constructor(connection: SQLiteConnection) : this(connection, ownsConnection = false) + + /** Файловое соединение — store закрывает его в [close]. */ + constructor(path: String) : this( + connection = SQLiteConnection.open(path = path), + ownsConnection = true, + ) + + init { + Schema.migrate(connection) + } + + private val getStmt: SQLitePreparedStatement = connection.prepare( + "SELECT ${Schema.COL_EPOCH}, ${Schema.COL_OFFSET} " + + "FROM ${Schema.TABLE} WHERE ${Schema.COL_ID} = 1" + ) + private val setStmt: SQLitePreparedStatement = connection.prepare( + "INSERT INTO ${Schema.TABLE}(${Schema.COL_ID}, ${Schema.COL_EPOCH}, ${Schema.COL_OFFSET}) " + + "VALUES(1, ?, ?) ON CONFLICT(${Schema.COL_ID}) DO UPDATE SET " + + "${Schema.COL_EPOCH}=excluded.${Schema.COL_EPOCH}, " + + "${Schema.COL_OFFSET}=excluded.${Schema.COL_OFFSET}" + ) + + override fun load(): Cursor? { + getStmt.reset() + getStmt.clearBindings() + getStmt.executeQuery().use { rs -> + if (!rs.next()) return null + return Cursor(epoch = rs.getText(0)!!, offset = rs.getLong(1)!!) + } + } + + override fun save(cursor: Cursor) { + setStmt.reset() + setStmt.clearBindings() + setStmt.bindText(1, cursor.epoch) + setStmt.bindLong(2, cursor.offset) + setStmt.executeUpdate() + } + + override fun close() { + getStmt.close() + setStmt.close() + if (ownsConnection) connection.close() + } +} diff --git a/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/Schema.kt b/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/Schema.kt new file mode 100644 index 0000000..b5b0a99 --- /dev/null +++ b/outbox-ksqlite/src/commonMain/kotlin/pw/binom/agentik/outbox/ksqlite/Schema.kt @@ -0,0 +1,44 @@ +package pw.binom.agentik.outbox.ksqlite + +import pw.binom.db.ksqlite.SQLiteConnection + +/** + * Имена таблиц/колонок для ksqlite-бэкенда `:outbox-api`. + * + * Владеет одной таблицей `outbox_cursor` — ровно одна строка (`id = 1`) с + * персистентной позицией счётчика событий агента (`epoch` + next offset). + * + * Как и остальные ksqlite-модули, `user_version` как gate не используется + * (split-world: несколько модулей ставят его независимо) — [migrate] просто + * идемпотентно прогоняет DDL. + */ +object Schema { + /** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */ + const val CURRENT_VERSION: Int = 1 + + const val TABLE = "outbox_cursor" + const val COL_ID = "id" + const val COL_EPOCH = "epoch" + + /** Хранит **next** offset (эксклюзивную границу), а не последний выданный. */ + const val COL_OFFSET = "next_offset" + + private val ddl = """ + CREATE TABLE IF NOT EXISTS $TABLE ( + $COL_ID INTEGER PRIMARY KEY CHECK ($COL_ID = 1), + $COL_EPOCH TEXT NOT NULL, + $COL_OFFSET INTEGER NOT NULL + ); + """ + + fun migrate(conn: SQLiteConnection) { + conn.exec("BEGIN") + try { + conn.exec(ddl) + conn.exec("COMMIT") + } catch (t: Throwable) { + runCatching { conn.exec("ROLLBACK") } + throw t + } + } +} diff --git a/outbox-ksqlite/src/commonTest/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStoreTest.kt b/outbox-ksqlite/src/commonTest/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStoreTest.kt new file mode 100644 index 0000000..b9be430 --- /dev/null +++ b/outbox-ksqlite/src/commonTest/kotlin/pw/binom/agentik/outbox/ksqlite/KsqliteCursorStoreTest.kt @@ -0,0 +1,80 @@ +package pw.binom.agentik.outbox.ksqlite + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.CursorStore +import pw.binom.agentik.outbox.PersistentOffsetSequencer +import pw.binom.db.ksqlite.SQLiteConnection + +/** In-memory [CursorStore] для проверки логики секвенсора без БД. */ +private class FakeCursorStore : CursorStore { + var value: Cursor? = null + override fun load(): Cursor? = value + override fun save(cursor: Cursor) { + value = cursor + } +} + +class PersistentOffsetSequencerTest { + + @Test + fun reservesMonotonically() = runTest { + val seq = PersistentOffsetSequencer(FakeCursorStore(), newEpoch = { "e1" }) + assertEquals("e1", seq.epoch()) + assertEquals(0L, seq.current()) + assertEquals(0L, seq.reserve()) + assertEquals(1L, seq.reserve()) + assertEquals(2L, seq.current()) + } + + @Test + fun seedsInitialNextOnFirstRun() = runTest { + val seq = PersistentOffsetSequencer(FakeCursorStore(), initialNext = { 42L }, newEpoch = { "e1" }) + assertEquals(42L, seq.current()) + assertEquals(42L, seq.reserve()) + assertEquals(43L, seq.current()) + } + + @Test + fun survivesRestartKeepingEpochAndOffset() = runTest { + val store = FakeCursorStore() + val before = PersistentOffsetSequencer(store, newEpoch = { "e1" }) + before.reserve() + before.reserve() + + // «Рестарт»: новый секвенсор над тем же persisted-хранилищем. + val after = PersistentOffsetSequencer(store, newEpoch = { "MUST-NOT-BE-USED" }) + assertEquals("e1", after.epoch()) + assertEquals(2L, after.current()) + assertEquals(2L, after.reserve()) + assertEquals(3L, after.current()) + } +} + +class KsqliteCursorStoreTest { + + @Test + fun roundTripsAndSurvivesRestart() { + val conn = SQLiteConnection.memory("outbox-cursor-test") + try { + val first = KsqliteCursorStore(conn) + assertNull(first.load()) + first.save(Cursor(epoch = "e1", offset = 7)) + assertEquals(Cursor(epoch = "e1", offset = 7), first.load()) + + // «Рестарт»: новый store над той же БД. + val second = KsqliteCursorStore(conn) + assertEquals(Cursor(epoch = "e1", offset = 7), second.load()) + second.save(Cursor(epoch = "e1", offset = 8)) + assertEquals(Cursor(epoch = "e1", offset = 8), first.load()) + + first.close() + second.close() + } finally { + conn.close() + } + } +} diff --git a/proto/README.md b/proto/README.md index 58611a1..f7a2277 100644 --- a/proto/README.md +++ b/proto/README.md @@ -8,7 +8,7 @@ сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан повторять `messages[]` каждый раз). - **declarative история vs. события** — `Message` это то, что уже легло - в БД, `Event` это live-стрим от агента во время `send()` или `events()`. + в БД, `DurableEvent` это live-стрим от агента во время `send()` или `events()`. - **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри `:proto`; это контракт. @@ -25,7 +25,7 @@ - `:agentik-cli` — работает поверх `:client`, а следовательно поверх `:proto`. *(`:agentik-tui` был исключён из сборки 2026-09-17.)* - `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает - `Message`/`Event` напрямую через storage. + `Message`/`DurableEvent` напрямую через storage. ## Как подключить @@ -62,7 +62,7 @@ target-specific артефакты + общий `kotlinMultiplatform`. `:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`, а общие типы содержимого и события живут в нижележащих модулях: `Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`, -`Event`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`. +`DurableEvent`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`. ```kotlin interface Agent : AutoCloseable { diff --git a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt index ec8839b..6ef2a1c 100644 --- a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt +++ b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt @@ -135,6 +135,27 @@ interface Agent : AutoCloseable { */ suspend fun renameConversation(id: String, title: String?): Instant? + /** + * Снапшот **всего состояния агента** (список диалогов) + курсор, на котором + * он валиден. Точка входа resync'а: клиент после [OutboxGapException] чистит + * локальный кэш, берёт этот снапшот, применяет его, затем подписывается + * `outbox.agentEvents(after = snapshot.cursor)` и накатывает дельты. + * + * Курсор читается **до** состояния (cursor-first) — это не гонка: любое + * изменение, случившееся во время чтения, имеет offset `> cursor` и придёт + * потоком. См. README `:client` (протокол синхронизации). + */ + suspend fun conversationsSnapshot(): ConversationsSnapshot + + /** + * Снапшот сообщений диалога [conversationId] + курсор, на котором он валиден. + * Сообщения отсечены `seq <= cursor.offset` (см. [ChatSnapshot]). + * + * Парный к [conversationsSnapshot] для per-chat resync'а: подписка — + * `outbox.conversationEvents(after = snapshot.cursor, conversationId)`. + */ + suspend fun chatSnapshot(conversationId: String): ChatSnapshot + companion object { const val PAGE_SIZE: Int = 100 diff --git a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Conversation.kt b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Conversation.kt index efa2126..58ea45a 100644 --- a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Conversation.kt +++ b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Conversation.kt @@ -13,7 +13,7 @@ import kotlin.time.Instant * * **Live-события** диалога НЕ часть этого интерфейса. Их два независимых * потока: - * - **durable** ([pw.binom.agentik.outbox.Event]: UserMessage / AssistantMessage / + * - **durable** ([pw.binom.agentik.outbox.DurableEvent]: UserMessage / AssistantMessage / * Interrupted / Error / ToolCall / ToolResult / ToolFailed) — из * [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору: * ``` diff --git a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Snapshot.kt b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Snapshot.kt new file mode 100644 index 0000000..ba9fab3 --- /dev/null +++ b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Snapshot.kt @@ -0,0 +1,40 @@ +package pw.binom.agentik.proto + +import kotlinx.serialization.Serializable +import pw.binom.agentik.journal.ConversationRecord +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.outbox.Cursor + +/** + * Снапшот списка диалогов + его [cursor]. + * + * Курсор — **commit point** на момент чтения (см. `OutboxStore.currentCursor`): + * состояние со `seq <= cursor.offset` отражено в [conversations], всё, что + * появится позже, придёт потоком outbox-событий. Клиент после применения + * снапшота подписывается `agentEvents(after = cursor)` (или + * `conversationEvents(after = cursor)`) и накатывает дельты поверх. + * + * Таблица `conversation` мала (реестр диалогов), поэтому отсечки по `seq` + * внутри снапшота нет — читается целиком; протокол от этого не страдает + * (absolute-события + идемпотентный apply). + */ +@Serializable +data class ConversationsSnapshot( + val conversations: List, + val cursor: Cursor, +) + +/** + * Снапшот сообщений одного диалога + его [cursor]. + * + * Сообщения прочитаны с отсечкой `seq <= cursor.offset` — конечное и + * стабильное множество, отражающее состояние диалога на момент [cursor]. + * Всё, что появится позже (`seq > cursor.offset`), придёт потоком + * `conversationEvents(after = cursor, conversationId)`. + */ +@Serializable +data class ChatSnapshot( + val conversationId: String, + val messages: List, + val cursor: Cursor, +) diff --git a/server/src/commonMain/kotlin/pw/binom/agentik/server/JournalRoutes.kt b/server/src/commonMain/kotlin/pw/binom/agentik/server/JournalRoutes.kt index 3d40793..1684aec 100644 --- a/server/src/commonMain/kotlin/pw/binom/agentik/server/JournalRoutes.kt +++ b/server/src/commonMain/kotlin/pw/binom/agentik/server/JournalRoutes.kt @@ -39,6 +39,20 @@ fun Route.journalRoutes( route(path) { get("/conversations/{id}/messages") { val id = call.parameters["id"]!! + // Cursor-режим (keyset по seq): `?afterSeq=&upToSeq=&limit=`. + val afterSeqRaw = call.request.queryParameters["afterSeq"] + if (afterSeqRaw != null) { + val afterSeq = afterSeqRaw.toLongOrNull() + if (afterSeq == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid 'afterSeq' (expected Long)") + return@get + } + val upToSeq = call.request.queryParameters["upToSeq"]?.toLongOrNull() ?: Long.MAX_VALUE + val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE + call.respond(journal.list(id, afterSeq, upToSeq, limit)) + return@get + } + // Legacy-режим (по `createdAt`): `?after=&offset=&limit=`. val after = call.parseAfter() ?: return@get val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0 val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE @@ -46,11 +60,18 @@ fun Route.journalRoutes( } get("/conversations/{id}/count") { val id = call.parameters["id"]!! - val after = call.parseAfter() - val count = if (after == null) { - journal.count(id) + // Cursor-режим: `?afterSeq=`. + val afterSeqRaw = call.request.queryParameters["afterSeq"] + val count = if (afterSeqRaw != null) { + val afterSeq = afterSeqRaw.toLongOrNull() + if (afterSeq == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid 'afterSeq' (expected Long)") + return@get + } + journal.count(id, afterSeq) } else { - journal.count(id, after) + val after = call.parseAfter() + if (after == null) journal.count(id) else journal.count(id, after) } call.respond(CountResponse(count = count)) } diff --git a/server/src/commonMain/kotlin/pw/binom/agentik/server/OutboxRoutes.kt b/server/src/commonMain/kotlin/pw/binom/agentik/server/OutboxRoutes.kt index 757a765..a6689a2 100644 --- a/server/src/commonMain/kotlin/pw/binom/agentik/server/OutboxRoutes.kt +++ b/server/src/commonMain/kotlin/pw/binom/agentik/server/OutboxRoutes.kt @@ -1,11 +1,29 @@ package pw.binom.agentik.server +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.routing.route +import kotlinx.serialization.Serializable import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.Cursor import pw.binom.agentik.outbox.OutboxStore +/** + * Тело ответа `410 Gone`: клиентский курсор вне retention'а (или чужая эпоха). + * + * Не ошибка протокола — сигнал сделать полный resync: взять снапшот + * (`/snapshot`, `/conversations/{id}/snapshot`) и подписаться с его курсора. + */ +@Serializable +data class OutboxGapResponse( + val requested: Cursor?, + val oldest: Cursor, + val current: Cursor, +) + /** * HTTP-фасад для [OutboxStore] (bounded-tail live event stream агента). * @@ -15,16 +33,20 @@ import pw.binom.agentik.outbox.OutboxStore * итоговый URL = `{path агента}/outbox/...`. * * **Endpoint'ы под `{path}/outbox`:** - * - `GET /events?after=` — SSE (catchup + live) в формате `data: \n\n`, - * где `` — сериализованный [CommonEvent]. - * Семантика `after` идентична [OutboxStore.events]: - * - `after` отсутствует → только live (события с момента подписки). - * - `after` задан → сначала catchup всех буферизованных событий с - * `date > after`, потом live. + * - `GET /events?epoch=&offset=` — SSE (catchup + live) в формате + * `data: \n\n`, где `` — сериализованный [CommonEvent]. + * - `epoch`/`offset` отсутствуют → только live (события с момента подписки). + * - оба заданы → catchup всех буферизованных событий с `offset > offset`, + * затем live. См. [OutboxStore.events]. * - * **Покрытие:** outbox — это короткий bounded tail с auto-TTL. Для событий - * старше буфера клиент должен идти в `/journal/conversations/{id}/messages` - * (полный audit log), см. KDoc [OutboxStore]. + * **Gap:** если курсор старше [OutboxStore.oldestCursor] (retention) или + * принадлежит другой эпохе → `410 Gone` c [OutboxGapResponse]. Проверка + * делается **до** старта SSE (иначе заголовки уже отправлены), тем же + * snapshot-чтением `oldestCursor()/currentCursor()`; гонка с конкурентной + * эвикцией закрыта внутренним lock'ом store'а на момент подписки. + * + * **Покрытие:** outbox — короткий bounded tail. Для событий старше буфера + * клиент идёт в снапшот (`/snapshot`, `/conversations/{id}/snapshot`). * * **Read-only:** [OutboxStore] не имеет `append` — запись только через * writer-референс, который ChatAgent держит внутри (тип `MutableOutboxStore`, @@ -36,9 +58,60 @@ fun Route.outboxRoutes( ) { route(path) { get("/events") { - val after = call.parseAfter() ?: return@get - // SSE-стрим: catchup (если `after` != DISTANT_PAST) + live tail. + val after = call.parseCursor() ?: return@get + if (!call.requireCursorAlive(outbox, after)) return@get call.streamJsonSse(outbox.events(after), CommonEvent.serializer()) } + get("/cursor") { + call.respond( + OutboxCursorResponse( + current = outbox.currentCursor(), + oldest = outbox.oldestCursor(), + ) + ) + } } } + +/** Тело `GET {path}/outbox/cursor`. */ +@Serializable +data class OutboxCursorResponse( + val current: Cursor, + val oldest: Cursor, +) + +/** + * Валидирует курсор перед подпиской. `null` (live-only) — всегда ок. + * Иначе: несовпадение эпохи, `offset < oldest` или `offset > current` + * → отвечает `410 Gone` и возвращает `false`. + */ +internal suspend fun ApplicationCall.requireCursorAlive(outbox: OutboxStore, after: Cursor?): Boolean { + if (after == null) return true + val oldest = outbox.oldestCursor() + val current = outbox.currentCursor() + if (after.epoch != oldest.epoch || after.offset < oldest.offset || after.offset > current.offset) { + respond( + HttpStatusCode.Gone, + OutboxGapResponse(requested = after, oldest = oldest, current = current), + ) + return false + } + return true +} + +/** + * Парсит курсор из query-параметров `epoch` + `offset`. + * Оба отсутствуют → `null` (live-only). Задан только один или невалидный + * `offset` → `400` и `null`. + */ +internal suspend fun ApplicationCall.parseCursor(): Cursor? { + val epoch = request.queryParameters["epoch"] + val offsetRaw = request.queryParameters["offset"] + if (epoch == null && offsetRaw == null) return null + val offset = offsetRaw?.toLongOrNull() + if (epoch == null || offset == null) { + respond(HttpStatusCode.BadRequest, "Invalid cursor (expected 'epoch' + 'offset' query params)") + return null + } + return Cursor(epoch = epoch, offset = offset) +} diff --git a/server/src/commonMain/kotlin/pw/binom/agentik/server/Routes.kt b/server/src/commonMain/kotlin/pw/binom/agentik/server/Routes.kt index 5f8ddb8..9486dcb 100644 --- a/server/src/commonMain/kotlin/pw/binom/agentik/server/Routes.kt +++ b/server/src/commonMain/kotlin/pw/binom/agentik/server/Routes.kt @@ -24,7 +24,7 @@ import pw.binom.agentik.proto.Conversation import pw.binom.agentik.content.Content import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.CommonEvent -import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.DurableEvent import pw.binom.agentik.outbox.OnlineEvent import kotlin.time.Instant @@ -161,11 +161,15 @@ internal fun Route.agentikRoutes(agent: Agent) { call.respond(HttpStatusCode.NotFound) return@get } - val after = call.parseAfter() ?: return@get + val after = call.parseCursor() ?: return@get + if (!call.requireCursorAlive(agent.outbox, after)) return@get // Live-источник событий — `OutboxStore` (единая точка истины); // разворачиваем `CommonEvent.Conversation` → `Event` для совместимости // wire-формата (клиент десериализует как `Event`, не как `CommonEvent.Conversation`). - call.streamJsonSse(agent.outbox.conversationEvents(after, id).map { it.event }, Event.serializer()) + call.streamJsonSse( + agent.outbox.conversationEvents(after = after, conversationId = id), + CommonEvent.Conversation.serializer(), + ) } /** @@ -199,13 +203,14 @@ internal fun Route.agentikRoutes(agent: Agent) { } get("/events") { - val after = call.parseAfter() ?: return@get + val after = call.parseCursor() ?: return@get + if (!call.requireCursorAlive(agent.outbox, after)) return@get // agent.outbox.agentEvents(after) возвращает Flow; // распаковываем .event для обратной совместимости с прежним // форматом (когда был Agent.events(): Flow). call.streamJsonSse( - agent.outbox.agentEvents(after).map { it.event }, - AgentEvent.serializer(), + agent.outbox.agentEvents(after), + CommonEvent.Agent.serializer(), ) } @@ -215,9 +220,30 @@ internal fun Route.agentikRoutes(agent: Agent) { * Для UI достаточно `/events` + `/conversations/{id}/events`. */ get("/events/all") { - val after = call.parseAfter() ?: return@get + val after = call.parseCursor() ?: return@get + if (!call.requireCursorAlive(agent.outbox, after)) return@get call.streamJsonSse(agent.outbox.events(after), CommonEvent.serializer()) } + + // ---- Snapshot (resync) ---- + + /** + * `GET /snapshot` — полное состояние агента (список диалогов) + его курсор. + * Клиент вызывает после `410 Gone`, чтобы сбросить локальный кэш и + * возобновить инкрементальную подписку с [pw.binom.agentik.proto.ConversationsSnapshot.cursor]. + */ + get("/snapshot") { + call.respond(agent.conversationsSnapshot()) + } + + /** + * `GET /conversations/{id}/snapshot` — сообщения диалога (отсечены + * `seq <= cursor.offset`) + курсор. Парный к `GET /snapshot` для per-chat resync'а. + */ + get("/conversations/{id}/snapshot") { + val id = call.parameters["id"]!! + call.respond(agent.chatSnapshot(id)) + } } // ---------- helpers ---------- diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt index 9f66c1a..0ca1635 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt @@ -24,6 +24,9 @@ import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.ChatSnapshot +import pw.binom.agentik.proto.ConversationsSnapshot +import pw.binom.agentik.outbox.Cursor import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.Conversation import kotlin.test.AfterTest @@ -63,16 +66,19 @@ class AgentInfoRouteTest { ) override val journal: JournalStore = object : JournalStore { override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList() + override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList() override suspend fun count(conversationId: String): Long = 0L + override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L override suspend fun count(conversationId: String, after: Instant): Long = 0L override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow() override fun close() {} } override val outbox: OutboxStore = object : OutboxStore { - override fun events(after: Instant?) = emptyFlow() - override fun agentEvents(after: Instant?) = emptyFlow() - override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow() - override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override fun events(after: Cursor?) = emptyFlow() + override fun agentEvents(after: Cursor?) = emptyFlow() + override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() + override suspend fun currentCursor(): Cursor = Cursor("test", 0L) + override suspend fun oldestCursor(): Cursor = Cursor("test", 0L) override fun close() {} } override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() @@ -81,6 +87,11 @@ class AgentInfoRouteTest { override suspend fun list(offset: Int, limit: Int) = emptyList() override fun close() {} } + override suspend fun conversationsSnapshot(): ConversationsSnapshot = + ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L)) + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot = + ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L)) + override fun createConversation(temp: Boolean): Conversation = TODO("not used") override suspend fun getConversation(id: String): Conversation? = null override suspend fun deleteConversation(id: String): Boolean = false @@ -133,16 +144,19 @@ class AgentInfoRouteTest { override val info: AgentInfo = AgentInfo(name = "agentik") override val journal: JournalStore = object : JournalStore { override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList() + override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList() override suspend fun count(conversationId: String): Long = 0L + override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L override suspend fun count(conversationId: String, after: Instant): Long = 0L override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow() override fun close() {} } override val outbox: OutboxStore = object : OutboxStore { - override fun events(after: Instant?) = emptyFlow() - override fun agentEvents(after: Instant?) = emptyFlow() - override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow() - override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override fun events(after: Cursor?) = emptyFlow() + override fun agentEvents(after: Cursor?) = emptyFlow() + override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() + override suspend fun currentCursor(): Cursor = Cursor("test", 0L) + override suspend fun oldestCursor(): Cursor = Cursor("test", 0L) override fun close() {} } override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() @@ -151,6 +165,11 @@ class AgentInfoRouteTest { override suspend fun list(offset: Int, limit: Int) = emptyList() override fun close() {} } + override suspend fun conversationsSnapshot(): ConversationsSnapshot = + ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L)) + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot = + ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L)) + override fun createConversation(temp: Boolean): Conversation = TODO("not used") override suspend fun getConversation(id: String): Conversation? = null override suspend fun deleteConversation(id: String): Boolean = false diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt index 5697862..d469cf4 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt @@ -20,6 +20,9 @@ import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.ChatSnapshot +import pw.binom.agentik.proto.ConversationsSnapshot +import pw.binom.agentik.outbox.Cursor import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.Conversation import kotlin.time.Instant @@ -47,16 +50,19 @@ class BearerTokenTest { ) : Agent { override val journal: JournalStore = object : JournalStore { override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList() + override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList() override suspend fun count(conversationId: String): Long = 0L + override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L override suspend fun count(conversationId: String, after: Instant): Long = 0L override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow() override fun close() {} } override val outbox: OutboxStore = object : OutboxStore { - override fun events(after: Instant?) = emptyFlow() - override fun agentEvents(after: Instant?) = emptyFlow() - override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow() - override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override fun events(after: Cursor?) = emptyFlow() + override fun agentEvents(after: Cursor?) = emptyFlow() + override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() + override suspend fun currentCursor(): Cursor = Cursor("test", 0L) + override suspend fun oldestCursor(): Cursor = Cursor("test", 0L) override fun close() {} } override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() @@ -65,6 +71,11 @@ class BearerTokenTest { override suspend fun list(offset: Int, limit: Int) = emptyList() override fun close() {} } + override suspend fun conversationsSnapshot(): ConversationsSnapshot = + ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L)) + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot = + ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L)) + override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests") override suspend fun getConversation(id: String): Conversation? = null override suspend fun deleteConversation(id: String): Boolean = false diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt index 1604bc0..9dff69c 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt @@ -23,6 +23,9 @@ import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.ChatSnapshot +import pw.binom.agentik.proto.ConversationsSnapshot +import pw.binom.agentik.outbox.Cursor import pw.binom.agentik.proto.AgentInfo import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -161,17 +164,27 @@ class ConversationRoutesTest { override val info: AgentInfo = AgentInfo(name = "test") override val journal: JournalStore = js override val outbox: OutboxStore = object : OutboxStore { - override fun events(after: Instant?) = emptyFlow() - override fun agentEvents(after: Instant?) = emptyFlow() - override fun conversationEvents(after: Instant?, conversationId: String?) = + override fun events(after: Cursor?) = emptyFlow() + override fun agentEvents(after: Cursor?) = emptyFlow() + override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() - override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override suspend fun currentCursor(): Cursor = Cursor("test", 0L) + override suspend fun oldestCursor(): Cursor = Cursor("test", 0L) override fun close() {} } override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() override val conversationStore: ConversationStore = cs + override suspend fun conversationsSnapshot(): ConversationsSnapshot = + + ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L)) + + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot = + + ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L)) + + override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation = TODO("not used") diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt index 0bada58..7229689 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt @@ -27,6 +27,9 @@ import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.ChatSnapshot +import pw.binom.agentik.proto.ConversationsSnapshot +import pw.binom.agentik.outbox.Cursor import pw.binom.agentik.proto.AgentInfo import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -182,10 +185,11 @@ class JournalRoutesCountTest { override val info: AgentInfo = AgentInfo(name = "test") override val journal: JournalStore = js override val outbox: OutboxStore = object : OutboxStore { - override fun events(after: Instant?) = emptyFlow() - override fun agentEvents(after: Instant?) = emptyFlow() - override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow() - override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override fun events(after: Cursor?) = emptyFlow() + override fun agentEvents(after: Cursor?) = emptyFlow() + override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow() + override suspend fun currentCursor(): Cursor = Cursor("test", 0L) + override suspend fun oldestCursor(): Cursor = Cursor("test", 0L) override fun close() {} } override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() @@ -195,6 +199,15 @@ class JournalRoutesCountTest { override fun close() {} } + override suspend fun conversationsSnapshot(): ConversationsSnapshot = + + ConversationsSnapshot(conversations = emptyList(), cursor = Cursor("test", 0L)) + + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot = + + ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor("test", 0L)) + + override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation = TODO("not used") override suspend fun getConversation(id: String): pw.binom.agentik.proto.Conversation? = null diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/SnapshotRouteTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/SnapshotRouteTest.kt new file mode 100644 index 0000000..3f479da --- /dev/null +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/SnapshotRouteTest.kt @@ -0,0 +1,162 @@ +package pw.binom.agentik.server + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.cio.CIO as ServerCIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.journal.ConversationRecord +import pw.binom.agentik.journal.ConversationStore +import pw.binom.agentik.journal.JournalStore +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.OnlineOutbox +import pw.binom.agentik.outbox.OutboxStore +import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo +import pw.binom.agentik.proto.ChatSnapshot +import pw.binom.agentik.proto.Conversation +import pw.binom.agentik.proto.ConversationsSnapshot +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * HTTP-контракт курсорного протокола: + * - `GET /snapshot` → состояние + курсор (JSON); + * - `GET /outbox/events?epoch=&offset=` → `410 Gone` (со `oldest`/`current`) + * при мёртвом курсоре, `400` при невалидном, до старта SSE; + * - `GET /outbox/cursor` → текущий/старейший курсор. + */ +class SnapshotRouteTest { + + private lateinit var server: EmbeddedServer<*, *> + private var port: Int = 0 + + private val epoch = "epoch-1" + + private class FakeOutbox( + private val epoch: String, + private val oldest: Long, + private val current: Long, + private val replay: List = emptyList(), + ) : OutboxStore { + override fun events(after: Cursor?): Flow = flowOf(*replay.toTypedArray()) + override fun agentEvents(after: Cursor?): Flow = emptyFlow() + override fun conversationEvents(after: Cursor?, conversationId: String?): Flow = + emptyFlow() + override suspend fun currentCursor(): Cursor = Cursor(epoch, current) + override suspend fun oldestCursor(): Cursor = Cursor(epoch, oldest) + override fun close() {} + } + + private fun fakeAgent(): Agent = object : Agent { + override val id: String = "test" + override val info: AgentInfo = AgentInfo(name = "test") + override val journal: JournalStore = object : JournalStore { + override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = + emptyList() + override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = + emptyList() + override suspend fun count(conversationId: String): Long = 0L + override suspend fun count(conversationId: String, after: Instant): Long = 0L + override suspend fun count(conversationId: String, afterSeq: Long): Long = 0L + override fun close() {} + } + override val outbox: OutboxStore = FakeOutbox(epoch = epoch, oldest = 5L, current = 11L) + override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() + override val conversationStore: ConversationStore = object : ConversationStore { + override suspend fun get(id: String) = null + override suspend fun list(offset: Int, limit: Int) = listOf( + ConversationRecord( + id = "conv-1", + title = "First", + isTemporal = false, + createdAt = Instant.fromEpochMilliseconds(0), + updatedAt = Instant.fromEpochMilliseconds(0), + ), + ) + override fun close() {} + } + + override suspend fun conversationsSnapshot(): ConversationsSnapshot = + ConversationsSnapshot(conversations = conversationStore.list(0, 100), cursor = Cursor(epoch, 11L)) + + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot = + ChatSnapshot(conversationId = conversationId, messages = emptyList(), cursor = Cursor(epoch, 11L)) + + override fun createConversation(temp: Boolean): Conversation = TODO("not used") + override suspend fun getConversation(id: String): Conversation? = null + override suspend fun deleteConversation(id: String): Boolean = false + override suspend fun renameConversation(id: String, title: String?): Instant? = null + override fun close() {} + } + + @BeforeTest + fun setup() { + server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") { + routing { agentikAgent(fakeAgent(), path = "/agentik", token = null) } + }.start(wait = false) + port = runBlocking { server.engine.resolvedConnectors()[0].port } + } + + @AfterTest + fun tearDown() { + server.stop(100, 200) + } + + private fun client() = HttpClient(CIO) + + @Test + fun `snapshot endpoint returns conversations plus cursor`() = runBlocking { + val response = client().get("http://127.0.0.1:$port/agentik/snapshot") + assertEquals(HttpStatusCode.OK, response.status) + val body = response.bodyAsText() + assertTrue("conv-1" in body, "body=$body") + assertTrue("\"cursor\"" in body, "body=$body") + assertTrue("\"$epoch\"" in body, "body=$body") + assertTrue("\"offset\":11" in body, "body=$body") + } + + @Test + fun `stale cursor returns 410 with oldest and current`() = runBlocking { + val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=$epoch&offset=0") + assertEquals(HttpStatusCode.Gone, response.status) + val body = response.bodyAsText() + assertTrue("\"oldest\"" in body, "body=$body") + assertTrue("\"offset\":5" in body, "body=$body") + assertTrue("\"offset\":11" in body, "body=$body") + } + + @Test + fun `foreign epoch returns 410`() = runBlocking { + val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=other&offset=6") + assertEquals(HttpStatusCode.Gone, response.status) + } + + @Test + fun `invalid cursor returns 400`() = runBlocking { + val response = client().get("http://127.0.0.1:$port/agentik/outbox/events?epoch=$epoch") + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `cursor endpoint returns current and oldest`() = runBlocking { + val response = client().get("http://127.0.0.1:$port/agentik/outbox/cursor") + assertEquals(HttpStatusCode.OK, response.status) + val body = response.bodyAsText() + assertTrue("\"current\"" in body && "\"oldest\"" in body, "body=$body") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8b4a90b..6f3876b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -114,6 +114,10 @@ include(":context-ksqlite") // ConversationStore / conversation table). Минимальный модуль: // таблицы `message` + `conversation` + индексы. include(":journal-ksqlite") +// ksqlite-реализация :outbox-api (CursorStore / outbox_cursor table). +// Персистентная позиция счётчика событий агента — переживает рестарт, чтобы +// клиент продолжал инкрементально (см. PersistentOffsetSequencer). +include(":outbox-ksqlite") // ksqlite-реализация :reflection-api (ReflectionStore / reflection table). // Минимальный модуль: только таблица `reflection` + 2 индекса. include(":reflection-ksqlite") diff --git a/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningComponent.kt b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningComponent.kt index 1685389..342ab20 100644 --- a/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningComponent.kt +++ b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningComponent.kt @@ -3,7 +3,6 @@ package pw.binom.agentik.skill.mining import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -14,7 +13,6 @@ import pw.binom.agentik.agent.ConversationHandle import pw.binom.agentik.agent.MutableAgent import pw.binom.agentik.agent.SystemPromptProvider import pw.binom.agentik.agent.ToolProvider -import pw.binom.agentik.skill.mining.SkillMiner import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.renderSystemPromptSection @@ -25,8 +23,8 @@ import pw.binom.litert.LiteTool * События, по которым SkillMiningComponent решает, что пора майнить новые скилы. * * Standalone-часть мэпит свой [pw.binom.agentik.outbox.OutboxStore] (через - * [pw.binom.agentik.outbox.Event.ConversationClosing] и - * [pw.binom.agentik.outbox.Event.CompactionTriggered]) на этот sealed + * [pw.binom.agentik.outbox.DurableEvent.ConversationClosing] и + * [pw.binom.agentik.outbox.DurableEvent.CompactionTriggered]) на этот sealed * interface и подаёт результат в [SkillMiningComponent.events]. Делаем так, * чтобы модуль :skill-mining не зависел от :standalone и его внутренних типов. */ diff --git a/standalone/build.gradle.kts b/standalone/build.gradle.kts index 786ac9e..e1e5d7d 100644 --- a/standalone/build.gradle.kts +++ b/standalone/build.gradle.kts @@ -74,6 +74,9 @@ kotlin { // Bounded-tail live event stream + per-event TTL. implementation(project(":outbox-inmemory")) + // Персистентный счётчик событий (CursorStore) поверх ksqlite — + // offset'ы переживают рестарт, клиент продолжает инкрементально. + implementation(project(":outbox-ksqlite")) // :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider. // ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.) diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt index 892a229..4e9699c 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt @@ -10,7 +10,7 @@ import pw.binom.a2a.model.Message import pw.binom.a2a.model.Role import pw.binom.a2a.model.TextPart import pw.binom.a2a.server.AgentHandler -import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.DurableEvent import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.proto.Agent import pw.binom.agentik.content.Content @@ -31,7 +31,7 @@ private val log = KotlinLogging.logger {} * Ответ A2A = склеенные [OnlineEvent.AppendText] нашего хода. Подписку на онлайн-поток * ([pw.binom.agentik.outbox.OnlineOutbox]) открываем ДО [Conversation.send] (live-only, * без catchup — события начала хода иначе можно упустить), завершение хода ждём - * по онлайн [OnlineEvent.End] и durable [Event.AssistantMessage] / [Event.Interrupted] / [Event.Error]. + * по онлайн [OnlineEvent.End] и durable [DurableEvent.AssistantMessage] / [DurableEvent.Interrupted] / [DurableEvent.Error]. * * Ограничение v1: tool-события и картинки в A2A-ответ не транслируются; * при нескольких ходов в очереди за контекстом текст предыдущего хода @@ -47,7 +47,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler { .joinToString("\n") { it.text } val conv = resolveConversation(contextId) - val since = conv.updatedAt + // Курсор старта: снапшот не нужен, достаточно текущей позиции лога. + val since = agent.outbox.currentCursor() val reply = StringBuilder() val turnDone = CompletableDeferred() // Онлайн-поток — дельты ответа (live-only, без catchup). @@ -66,8 +67,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler { val turnJob = async { agent.outbox.conversationEvents(since, conv.id).collect { ce -> when (val e = ce.event) { - is Event.AssistantMessage, is Event.Interrupted -> turnDone.complete(Unit) - is Event.Error -> + is DurableEvent.AssistantMessage, is DurableEvent.Interrupted -> turnDone.complete(Unit) + is DurableEvent.Error -> turnDone.completeExceptionally( IllegalStateException("agent turn failed: ${e.message}") ) diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt index ac80a82..26a48ab 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -392,6 +392,9 @@ private fun runServer() { contextCompactor = contextCompactor, recentReflections = recentReflections, reflector = reflector, + // Персистентный счётчик событий: offset/epoch переживают рестарт, + // клиенты продолжают инкрементально, а не делают полный resync. + outboxSequencer = sqliteStores.outboxSequencer, ).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry)) // Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а, diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index a80c958..ac3d279 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -5,9 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -25,11 +23,16 @@ import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemorySystemGuidance import pw.binom.agentik.proto.Agent as ProtoAgent import pw.binom.agentik.proto.AgentInfo +import pw.binom.agentik.proto.ChatSnapshot +import pw.binom.agentik.proto.ConversationsSnapshot +import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.outbox.AgentEvent -import pw.binom.agentik.outbox.Event as OutboxEvent +import pw.binom.agentik.outbox.DurableEvent as OutboxEvent import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.MutableOutboxStore import pw.binom.agentik.outbox.MutableOnlineOutbox +import pw.binom.agentik.outbox.OffsetSequencer +import pw.binom.agentik.outbox.inmemory.InMemoryOffsetSequencer import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.outbox.OutboxStore @@ -47,10 +50,7 @@ import pw.binom.agentik.reflection.Reflection import pw.binom.agentik.reflection.ReflectionStore import pw.binom.agentik.journal.MutableJournalStore import pw.binom.agentik.context.ContextStore -import pw.binom.agentik.toolsets.DisableToolsetTool -import pw.binom.agentik.toolsets.EnableToolsetTool import pw.binom.litert.LiteTool -import pw.binom.agentik.toolsets.SystemPromptToolsetSection import pw.binom.agentik.toolsets.ToolsetComponent import pw.binom.agentik.toolsets.ToolsetContribution import pw.binom.agentik.toolsets.ToolsetDispatchPolicy @@ -147,6 +147,14 @@ internal class ChatAgent( * секция в system prompt НЕ добавляется (полная невидимость per A1-α). */ private val toolsets: List = emptyList(), + /** + * Счётчик событий агента. По умолчанию in-memory: offset'ы начинаются с 0 + * при каждом старте процесса. Production передаёт + * [pw.binom.agentik.outbox.PersistentOffsetSequencer] поверх ksqlite + * (`SqliteStores.outboxSequencer`), чтобы `offset`/`epoch` переживали + * рестарт и клиент продолжал инкрементально (см. [Cursor]). + */ + private val outboxSequencer: OffsetSequencer = InMemoryOffsetSequencer(), ) : MutableAgent, AutoCloseable { /** @@ -213,8 +221,16 @@ private val testTools: MutableList = mutableListOf() private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore( maxMessages = null, ttl = null, + sequencer = outboxSequencer, ) +/** + * Сериализатор durable-записей: reserve offset → state → event (см. [DurableLog]). + * Делит [eventStore] со всеми беседами агента, поэтому offset'ы сквозные + * по агенту (и по всем беседам сразу). + */ +private val durableLog: DurableLog = DurableLog(eventStore) + /** * Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры). * Онлайн-события никогда не сохраняются и не реплеятся — см. [OnlineOutbox]. @@ -473,7 +489,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme messageStore = messageStore, workingMemoryStore = workingMemoryStore, reflectionStore = reflectionStore, - eventStore = eventStore, + durableLog = durableLog, onlineEventStore = onlineEventStore, llm = llm, systemPrompt = systemPrompt, @@ -497,12 +513,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme // фоновые задачи. attachConversation(conv.asHandle()) runBlocking { - eventStore.append( - CommonEvent.Agent( - date = now(), - event = AgentEvent.Created(date = now(), conversationId = conv.id), - ) - ) + durableLog.appendAgent(AgentEvent.Created(date = now(), conversationId = conv.id)) } return conv } @@ -527,26 +538,60 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme // отдельный store должен знать только про свою таблицу. messageStore.clear(id) workingMemoryStore.clear(id) - val event = AgentEvent.Deleted(date = now(), id = id) - eventStore.append(CommonEvent.Agent(date = now(), event = event)) + durableLog.appendAgent(AgentEvent.Deleted(date = now(), id = id)) } return ok } override suspend fun renameConversation(id: String, title: String?): Instant? { val newUpdatedAt = mutableConversationStore.rename(id, title) ?: return null - val event = AgentEvent.Renamed(date = newUpdatedAt, id = id, title = title) - eventStore.append(CommonEvent.Agent(date = newUpdatedAt, event = event)) + durableLog.appendAgent(AgentEvent.Renamed(date = newUpdatedAt, id = id, title = title)) return newUpdatedAt } + /** + * Cursor-first снапшот списка диалогов (см. [ProtoAgent.conversationsSnapshot]). + * Курсор читается до состояния: изменения во время чтения получат offset + * `> cursor` и приедут потоком. + */ + override suspend fun conversationsSnapshot(): ConversationsSnapshot { + val cursor = durableLog.currentCursor() + val out = ArrayList() + var offset = 0 + while (true) { + val page = mutableConversationStore.list(offset, ConversationStore.PAGE_SIZE) + out += page + if (page.size < ConversationStore.PAGE_SIZE) break + offset += page.size + } + return ConversationsSnapshot(conversations = out, cursor = cursor) + } + + /** + * Cursor-first снапшот сообщений диалога с отсечкой `seq <= cursor.offset` + * (keyset-пагинация, см. [ProtoAgent.chatSnapshot]). + */ + override suspend fun chatSnapshot(conversationId: String): ChatSnapshot { + val cursor = durableLog.currentCursor() + val out = ArrayList() + var afterSeq = -1L + while (true) { + val page = messageStore.list(conversationId, afterSeq, cursor.offset, JournalStore.PAGE_SIZE) + if (page.isEmpty()) break + out += page + afterSeq = page.last().seq + if (page.size < JournalStore.PAGE_SIZE) break + } + return ChatSnapshot(conversationId = conversationId, messages = out, cursor = cursor) + } + private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation( record = rec, conversationStore = mutableConversationStore, messageStore = messageStore, workingMemoryStore = workingMemoryStore, reflectionStore = reflectionStore, - eventStore = eventStore, + durableLog = durableLog, onlineEventStore = onlineEventStore, llm = llm, systemPrompt = systemPrompt, diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt index 8c2742d..1902c07 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt @@ -68,7 +68,7 @@ internal class CompactionCoordinator( } if (toCompact.isEmpty()) return false - events.tryEmit(pw.binom.agentik.outbox.Event.CompactionTriggered(date = now(), conversationId = state.id, turnsCompacted = toCompact.size)) + events.tryEmit(pw.binom.agentik.outbox.DurableEvent.CompactionTriggered(date = now(), conversationId = state.id, turnsCompacted = toCompact.size)) val turns = toCompact.mapNotNull { row -> when (val e = row.entry) { diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt index 6a1f3c9..1d34c3f 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt @@ -1,44 +1,55 @@ package pw.binom.agentik.standalone.agent -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import pw.binom.agentik.outbox.CommonEvent -import pw.binom.agentik.outbox.Event +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.DurableEvent import pw.binom.agentik.outbox.MutableOnlineOutbox -import pw.binom.agentik.outbox.MutableOutboxStore import pw.binom.agentik.outbox.OnlineEvent /** * Фасад эмиссии и чтения событий одного диалога. Разводит два канала: - * - durable ([Event]) → [globalEventStore] (`:outbox`), с catchup по `after`; + * - durable ([DurableEvent]) → [DurableLog] (`:outbox`), с catchup по [Cursor]; * - online ([OnlineEvent]) → [onlineStore], live-only (без catchup). + * + * Все durable-эмиссии идут через [DurableLog], чтобы state-row и парное + * событие получали один offset (см. KDoc [DurableLog]). */ internal class ConversationEvents( - private val globalEventStore: MutableOutboxStore, + private val durableLog: DurableLog, private val onlineStore: MutableOnlineOutbox, private val conversationId: String, ) { - fun tryEmit(event: Event): Boolean { - runBlocking { - globalEventStore.append( - CommonEvent.Conversation( - date = event.date, - conversationId = conversationId, - event = event, - ) - ) - } + /** + * Атомарная durable-запись: сначала [writeState] (journal/...) с + * забронированным `seq`, затем парное событие [event] с тем же offset. + */ + suspend fun commit(writeState: suspend (seq: Long) -> T, event: (seq: Long) -> DurableEvent): T = + durableLog.commit(conversationId = conversationId, writeState = writeState, event = event) + + /** Durable-событие без парной записи состояния. */ + suspend fun emit(event: DurableEvent) { + durableLog.appendConversation(conversationId = conversationId, event = event) + } + + /** + * Синхронный мост для мест без suspend-контекста (`close()`, + * [CompactionCoordinator]). Блокирует вызывающий поток до записи — + * используется только на редких путях. + */ + fun tryEmit(event: DurableEvent): Boolean { + runBlocking { emit(event) } return true } - fun events(after: kotlin.time.Instant?): Flow = - globalEventStore.conversationEvents(after = after, conversationId = conversationId) + fun events(after: Cursor?): Flow = + durableLog.outbox.conversationEvents(after = after, conversationId = conversationId) .map { it.event } /** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */ fun tryEmitOnline(event: OnlineEvent): Boolean = - onlineStore.tryAppendOnline(conversationId, event) + onlineStore.tryAppendOnline(event) /** Live-поток онлайн-событий диалога (без catchup — см. KDoc [pw.binom.agentik.outbox.OnlineOutbox]). */ fun onlineEvents(): Flow = onlineStore.onlineEvents(conversationId) diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt index f3333ca..87e90de 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt @@ -11,17 +11,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject import mu.KotlinLogging import pw.binom.agentik.memory.MemoryPrefetcher import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.content.Content as ProtoContent import pw.binom.agentik.proto.Conversation as ProtoConversation -import pw.binom.agentik.outbox.Event as ProtoEvent +import pw.binom.agentik.outbox.DurableEvent as ProtoEvent import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.MutableOnlineOutbox import pw.binom.agentik.reflection.ReflectionStore @@ -54,7 +50,7 @@ class ConversationLoop( private val messageStore: MutableJournalStore, private val workingMemoryStore: ContextStore, private val reflectionStore: ReflectionStore?, - private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore, + private val durableLog: DurableLog, /** * Live-канал онлайн-событий (дельты ответа). Не сохраняется; подписка * возможна только «онлайн». Durable-события по-прежнему в [eventStore]. @@ -108,7 +104,7 @@ class ConversationLoop( ) private val events = ConversationEvents( - globalEventStore = eventStore, + durableLog = durableLog, onlineStore = onlineEventStore, conversationId = state.id, ) @@ -148,7 +144,7 @@ class ConversationLoop( reflector = reflector, reflectionStore = reflectionStore, ), - eventStore = eventStore, + eventStore = durableLog.events, conversationIdProvider = { id }, ).also { it.start(agentScope) } @@ -183,7 +179,7 @@ class ConversationLoop( // увидел «агент работает» ещё до turnLock.withLock { launch } и до // первого токена от LLM. Working/End — онлайн-маркеры (live-only), // терминатор durable-части — AssistantMessage/Interrupted/Error. - emitOnline(OnlineEvent.Working(date = turnStarted)) + emitOnline(OnlineEvent.Working(date = turnStarted, conversationId = id)) val userMessageId = newId("msg") val storageContext = context?.toStorage() @@ -196,25 +192,29 @@ class ConversationLoop( ) if (!state.isTemporal) { - messageStore.append(userRecord) - workingMemoryStore.append( - conversationId = id, - entry = WorkingMemoryEntry.User( - sourceMessageId = userMessageId, - content = userRecord.content, - context = storageContext, - ), - now = turnStarted, - ) - // Durable-событие user-сообщения: позволяет восстановить историю - // по курсору outbox без отдельного запроса в journal. - emitEvent( - ProtoEvent.UserMessage( - date = turnStarted, - id = userMessageId, - content = userRecord.content, - context = storageContext, - ) + // State-first: journal.append + workingMemory под одним offset'ом, + // затем парное durable-событие (см. DurableLog). + events.commit( + writeState = { seq -> + messageStore.append(userRecord.copy(seq = seq)) + workingMemoryStore.append( + conversationId = id, + entry = WorkingMemoryEntry.User( + sourceMessageId = userMessageId, + content = userRecord.content, + context = storageContext, + ), + now = turnStarted, + ) + }, + event = { + ProtoEvent.UserMessage( + date = turnStarted, + id = userMessageId, + content = userRecord.content, + context = storageContext, + ) + }, ) } @@ -262,7 +262,7 @@ class ConversationLoop( // ловит это и делает final reflection (last chance вытащить insights). // После cancel() подписка умерла бы. runCatching { - events.tryEmit(pw.binom.agentik.outbox.Event.ConversationClosing(date = now(), conversationId = id)) + events.tryEmit(pw.binom.agentik.outbox.DurableEvent.ConversationClosing(date = now(), conversationId = id)) } state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } } runCatching { runBlocking { activeTurn?.cancelAndJoin() } } @@ -277,8 +277,14 @@ class ConversationLoop( compactor.compactPreTurnIfNeeded() } - emitOnline(OnlineEvent.StartReasoning(date = turnStarted)) - emitOnline(OnlineEvent.StartResponse(date = now(), responseType = OnlineEvent.ResponseType.TEXT)) + emitOnline(OnlineEvent.StartReasoning(date = turnStarted, conversationId = id)) + emitOnline( + OnlineEvent.StartResponse( + date = now(), + conversationId = id, + responseType = OnlineEvent.ResponseType.TEXT, + ) + ) val parts = userRecord.content.mapNotNull { c -> when (c) { @@ -345,7 +351,7 @@ class ConversationLoop( lc.sendStreamContents(pendingParts).collect { delta -> if (delta.text.isNotEmpty()) { reply.append(delta.text) - emitOnline(OnlineEvent.AppendText(date = now(), body = delta.text)) + emitOnline(OnlineEvent.AppendText(date = now(), conversationId = id, body = delta.text)) } if (delta.toolCalls.isNotEmpty()) { collectedCalls.addAll(delta.toolCalls) @@ -383,7 +389,7 @@ class ConversationLoop( } if (delta.text.isNotEmpty()) { reply.append(delta.text) - emitOnline(OnlineEvent.AppendText(date = now(), body = delta.text)) + emitOnline(OnlineEvent.AppendText(date = now(), conversationId = id, body = delta.text)) } if (delta.toolCalls.isNotEmpty()) { nextCalls.addAll(delta.toolCalls) @@ -400,7 +406,7 @@ class ConversationLoop( lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp -> if (followUp.text.isNotEmpty()) { reply.append(followUp.text) - emitOnline(OnlineEvent.AppendText(date = now(), body = followUp.text)) + emitOnline(OnlineEvent.AppendText(date = now(), conversationId = id, body = followUp.text)) } if (followUp.toolCalls.isNotEmpty()) { collectedPostTool.addAll(followUp.toolCalls) @@ -456,17 +462,19 @@ class ConversationLoop( createdAt = assistantAt, tokens = turnTokens, ) - messageStore.append(assistantRecord) - - // Durable-событие готового ответа агента. - emitEvent( - ProtoEvent.AssistantMessage( - date = assistantAt, - id = assistantId, - content = assistantContent, - reasoning = null, - tokens = turnTokens, - ) + // State-first: journal.append под забронированным offset'ом, + // затем парное durable-событие. + events.commit( + writeState = { seq -> messageStore.append(assistantRecord.copy(seq = seq)) }, + event = { + ProtoEvent.AssistantMessage( + date = assistantAt, + id = assistantId, + content = assistantContent, + reasoning = null, + tokens = turnTokens, + ) + }, ) workingMemoryStore.append( @@ -489,14 +497,11 @@ class ConversationLoop( state.record = state.record.copy(updatedAt = assistantAt) conversationStore.touch(id, assistantAt) if (!state.isTemporal) { - eventStore.append( - pw.binom.agentik.outbox.CommonEvent.Agent( + durableLog.appendAgent( + pw.binom.agentik.outbox.AgentEvent.Touched( date = assistantAt, - event = pw.binom.agentik.outbox.AgentEvent.Touched( - date = assistantAt, - id = id, - updatedAt = assistantAt, - ), + id = id, + updatedAt = assistantAt, ) ) } @@ -510,14 +515,14 @@ class ConversationLoop( if (wasInterrupted || interrupted.get()) { emitEvent(ProtoEvent.Interrupted(date = now())) } - emitOnline(OnlineEvent.End(date = now())) + emitOnline(OnlineEvent.End(date = now(), conversationId = id)) interrupted.set(false) } } - private fun emitEvent(event: ProtoEvent) { - events.tryEmit(event) + private suspend fun emitEvent(event: ProtoEvent) { + events.emit(event) } private fun emitOnline(event: OnlineEvent) { @@ -527,17 +532,24 @@ class ConversationLoop( private suspend fun failTurn(message: String, code: String? = null) { val ts = now() if (!state.isTemporal) { - messageStore.append( - MessageRecord.Error( - id = newId("err"), - conversationId = id, - message = message, - code = code, - createdAt = ts, - ), + events.commit( + writeState = { seq -> + messageStore.append( + MessageRecord.Error( + id = newId("err"), + conversationId = id, + message = message, + code = code, + createdAt = ts, + seq = seq, + ), + ) + }, + event = { ProtoEvent.Error(date = ts, message = message, code = code) }, ) + } else { + emitEvent(ProtoEvent.Error(date = ts, message = message, code = code)) } - emitEvent(ProtoEvent.Error(date = ts, message = message, code = code)) } private fun now(): Instant = diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/DurableLog.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/DurableLog.kt new file mode 100644 index 0000000..80e3379 --- /dev/null +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/DurableLog.kt @@ -0,0 +1,77 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.outbox.AgentEvent +import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.Cursor +import pw.binom.agentik.outbox.DurableEvent +import pw.binom.agentik.outbox.MutableOutboxStore +import pw.binom.agentik.outbox.OutboxStore + +/** + * Сериализатор durable-записей агента: единственное место, где state-row и + * парное ему outbox-событие получают **один и тот же** монотонный offset. + * + * ## Зачем нужен commit-lock + * + * Протокол снапшотов держится на трёх инвариантах (см. README `:outbox-api`): + * 1. **Producer**: сначала мутация состояния (journal/conversation), потом + * `append` события — оба с одинаковым `seq`/`offset`; + * 2. **Reader**: сначала `currentCursor()` (= последний **заапенденный** + * offset), потом чтение состояния с отсечкой `seq <= C`; всё, что `> C`, + * приедет потоком; + * 3. **Идемпотентность** применения (upsert/delete по id). + * + * Инвариант (1) ломается, если два продюсера параллельно забронируют + * offset'ы и запишут их в разном порядке (A резервирует 10, B — 11, B + * аппендит первым → outbox отвергнет 10 как немонотонный, либо состояние + * 10 «застрянет» ниже `currentCursor` и потеряется для подписчика). + * [commit]/[appendConversation]/[appendAgent] держат [Mutex] на всё время + * «reserve → запись состояния → append», поэтому durable-поток строго + * линеен, а `currentCursor` всегда указывает на корректную точку отсечки. + * + * Живёт в `:standalone` (а не в `:outbox-api`), потому что знает про + * journal/conversation-сторы; outbox остаётся тупым хранилищем событий. + */ +class DurableLog( + val outbox: MutableOutboxStore, +) { + private val mutex = Mutex() + + /** Read-only представление — для потребителей (ReflectionScheduler и т.п.). */ + val events: OutboxStore get() = outbox + + /** Точка отсечки для нового снапшота (см. протокол). */ + suspend fun currentCursor(): Cursor = outbox.currentCursor() + + /** + * Атомарно: забронировать offset → записать состояние с этим `seq` → + * аппендить парное событие. Возвращает результат [writeState]. + */ + suspend fun commit( + conversationId: String, + writeState: suspend (seq: Long) -> T, + event: (seq: Long) -> DurableEvent, + ): T = mutex.withLock { + val seq = outbox.reserveOffset() + val result = writeState(seq) + val e = event(seq) + outbox.append(CommonEvent.Conversation(date = e.date, offset = seq, conversationId = conversationId, event = e)) + result + } + + /** Durable-событие диалога без парной записи состояния (Closing, CompactionTriggered, ...). */ + suspend fun appendConversation(conversationId: String, event: DurableEvent): Long = mutex.withLock { + val seq = outbox.reserveOffset() + outbox.append(CommonEvent.Conversation(date = event.date, offset = seq, conversationId = conversationId, event = event)) + seq + } + + /** Агент-level событие (Created/Deleted/Renamed/Touched) — состояние мутируется вызывающим до вызова. */ + suspend fun appendAgent(event: AgentEvent): Long = mutex.withLock { + val seq = outbox.reserveOffset() + outbox.append(CommonEvent.Agent(date = event.date, offset = seq, event = event)) + seq + } +} diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionScheduler.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionScheduler.kt index 02b0161..9aa4cae 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionScheduler.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionScheduler.kt @@ -15,7 +15,7 @@ import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.ContextStore import pw.binom.agentik.llm.tools.LlmReflector import pw.binom.agentik.outbox.CommonEvent -import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.DurableEvent import pw.binom.agentik.outbox.OutboxStore import java.util.concurrent.atomic.AtomicLong @@ -36,7 +36,7 @@ import java.util.concurrent.atomic.AtomicLong * Подписка идёт через outbox (а не через per-conversation BackgroundEventBus * который был раньше): outbox — единый канал для всех событий (как клиентских, * так и внутренних), persistent tail с TTL работает из коробки, а клиенты - * по тому же потоку могут самостоятельно видеть/логировать [Event.ToolFailed] + * по тому же потоку могут самостоятельно видеть/логировать [DurableEvent.ToolFailed] * без скрытой телеметрии. */ internal data class ReflectionConfig( @@ -68,11 +68,11 @@ internal class ReflectionScheduler( merge( eventStore.events(after = null) .filterIsInstance() - .filter { it.event is Event.ConversationClosing && it.conversationId == conversationIdProvider() } + .filter { it.event is DurableEvent.ConversationClosing && it.conversationId == conversationIdProvider() } .onEach { onClosing() }, eventStore.events(after = null) .filterIsInstance() - .filter { it.event is Event.ToolFailed && it.conversationId == conversationIdProvider() } + .filter { it.event is DurableEvent.ToolFailed && it.conversationId == conversationIdProvider() } .onEach { onToolFailure() }, ).collect {} } diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt index cf7123a..2f571ca 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.async import mu.KotlinLogging -import pw.binom.agentik.outbox.Event as ProtoEvent +import pw.binom.agentik.outbox.DurableEvent as ProtoEvent import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore import pw.binom.agentik.context.WorkingMemoryEntry @@ -37,19 +37,28 @@ internal class ToolDispatcher( val nowTs = now() val startMs = System.currentTimeMillis() - events.tryEmit(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson)) - if (!state.isTemporal) { - messageStore.append( - MessageRecord.ToolCall( - id = callId, - conversationId = state.id, - toolName = call.name, - toolTitle = null, - toolArgsJson = argsJson, - createdAt = nowTs, - ), + // State-first: journal-запись и парное событие под одним offset'ом. + events.commit( + writeState = { seq -> + messageStore.append( + MessageRecord.ToolCall( + id = callId, + conversationId = state.id, + toolName = call.name, + toolTitle = null, + toolArgsJson = argsJson, + createdAt = nowTs, + seq = seq, + ), + ) + }, + event = { + ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson) + }, ) + } else { + events.tryEmit(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson)) } val toolDeferred = state.agentScope.async { @@ -87,7 +96,29 @@ internal class ToolDispatcher( } val resultAt = now() - events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText)) + if (!state.isTemporal) { + // State-first: journal-запись и парное событие под одним offset'ом. + events.commit( + writeState = { seq -> + messageStore.append( + MessageRecord.ToolResult( + id = resultId, + conversationId = state.id, + toolCallId = callId, + toolName = call.name, + result = resultText, + createdAt = resultAt, + seq = seq, + ), + ) + }, + event = { + ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText) + }, + ) + } else { + events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText)) + } // Только реальные падения тула попадают в outbox как background-event // (reflection-подобные потребители). Cancellation — not a failure, @@ -105,19 +136,6 @@ internal class ToolDispatcher( ) } - if (!state.isTemporal) { - messageStore.append( - MessageRecord.ToolResult( - id = resultId, - conversationId = state.id, - toolCallId = callId, - toolName = call.name, - result = resultText, - createdAt = resultAt, - ), - ) - } - return WorkingMemoryEntry.ToolExchange( sourceMessageId = callId, toolName = call.name, diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt index 2fa5178..32266b9 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt @@ -6,20 +6,24 @@ import pw.binom.agentik.journal.MutableJournalStore import pw.binom.agentik.journal.MutableConversationStore import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore +import pw.binom.agentik.journal.ksqlite.Schema as JournalSchema +import pw.binom.agentik.outbox.OffsetSequencer +import pw.binom.agentik.outbox.PersistentOffsetSequencer +import pw.binom.agentik.outbox.ksqlite.KsqliteCursorStore import pw.binom.agentik.reflection.ReflectionStore import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore import pw.binom.db.ksqlite.SQLiteConnection /** - * Bundle из 4 ksqlite-сторов для standalone-агента. + * Bundle из ksqlite-сторов для standalone-агента. * * Internal helper `:standalone` — bundle нужен только агенту, поэтому не * торчит наружу через публичный API модуля. Каждый store (conversation, - * message, working_memory, reflection) живёт в своём ksqlite-модуле; - * этот класс собирает их вокруг одной shared-connection и закрывает их - * в правильном порядке в [close]. + * message, working_memory, reflection, outbox cursor) живёт в своём + * ksqlite-модуле; этот класс собирает их вокруг одной shared-connection и + * закрывает их в правильном порядке в [close]. * - * Lifecycle: открывает [SQLiteConnection] и возвращает 4 store'а. Каждый + * Lifecycle: открывает [SQLiteConnection] и возвращает сторы. Каждый * store сам прогоняет свою схему в конструкторе (`Schema.migrate(connection)` * — idempotent `CREATE TABLE IF NOT EXISTS`), явных вызовов миграции в bundle * нет. Caller ДОЛЖЕН вызвать [close] при завершении. @@ -34,6 +38,12 @@ internal class SqliteStores internal constructor( val messages: MutableJournalStore, val workingMemory: ContextStore, val reflections: ReflectionStore, + private val outboxCursorStore: KsqliteCursorStore, + /** + * Персистентный счётчик событий агента (см. [PersistentOffsetSequencer]). + * Передаётся в `ChatAgent`, чтобы offset'ы переживали рестарт процесса. + */ + val outboxSequencer: OffsetSequencer, ) : AutoCloseable { override fun close() { @@ -41,6 +51,7 @@ internal class SqliteStores internal constructor( messages.close() workingMemory.close() reflections.close() + outboxCursorStore.close() connection.close() } @@ -48,12 +59,39 @@ internal class SqliteStores internal constructor( fun open(path: String): SqliteStores = assemble(SQLiteConnection.open(path)) fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name)) - private fun assemble(conn: SQLiteConnection): SqliteStores = SqliteStores( - connection = conn, - conversations = KsqliteMutableConversationStore(conn), - messages = KsqliteJournalStore(conn), - workingMemory = KsqliteContextStore(conn), - reflections = KsqliteReflectionStore(conn), - ) + private fun assemble(conn: SQLiteConnection): SqliteStores { + val cursorStore = KsqliteCursorStore(conn) + return SqliteStores( + connection = conn, + conversations = KsqliteMutableConversationStore(conn), + messages = KsqliteJournalStore(conn), + workingMemory = KsqliteContextStore(conn), + reflections = KsqliteReflectionStore(conn), + outboxCursorStore = cursorStore, + outboxSequencer = PersistentOffsetSequencer( + store = cursorStore, + initialNext = { seedNextFromJournal(conn) }, + ), + ) + } + + /** + * Стартовая позиция счётчика при первом создании [KsqliteCursorStore], + * когда в БД уже есть сообщения (апгрейд): `MAX(seq) + 1`. + * + * Без этого новые offset'ы начинались бы с 0 и столкнулись бы с уже + * записанными `seq` журнала (см. [PersistentOffsetSequencer]). + */ + private fun seedNextFromJournal(conn: SQLiteConnection): Long { + conn.prepare( + "SELECT COALESCE(MAX(${JournalSchema.COL_SEQ}), -1) " + + "FROM ${JournalSchema.TABLE_MESSAGE}" + ).use { stmt -> + stmt.executeQuery().use { rs -> + check(rs.next()) { "MAX(seq) must return a row" } + return (rs.getLong(0) ?: -1L) + 1L + } + } + } } } diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index 8ab3b9d..c253166 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -2,21 +2,20 @@ package pw.binom.agentik.standalone.agent import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest +import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.outbox.AgentEvent +import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.content.Content -import pw.binom.agentik.outbox.Event as ProtoEvent +import pw.binom.agentik.outbox.DurableEvent as ProtoEvent import pw.binom.agentik.skill.mining.SkillReadTool -import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmConfig -import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.standalone.persistence.SqliteStores import pw.binom.litert.LiteContentPart @@ -339,7 +338,7 @@ class ChatAgentTest { val events = mutableListOf() val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { - agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) } + agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) } } val online = mutableListOf() val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { @@ -381,7 +380,7 @@ class ChatAgentTest { val durable = mutableListOf() val online = mutableListOf() val durableJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { - agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { durable.add(it.event) } + agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { durable.add(it.event) } } val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) } @@ -421,7 +420,7 @@ class ChatAgentTest { // отправки событий подписка ничего не увидит. val events = mutableListOf() val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { - agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) } + agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) } } val online = mutableListOf() val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { @@ -485,7 +484,7 @@ class ChatAgentTest { // Подписываемся ДО send — SharedFlow без replay val events = mutableListOf() val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { - agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) } + agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) } } val sendJob = launch { @@ -649,7 +648,7 @@ class ChatAgentTest { // Agent.events() удалён из :proto — события живут в // agent.outbox.agentEvents(): Flow; // распаковываем .event для получения AgentEvent. - agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { events.add(it.event) } + agent.outbox.agentEvents(agent.outbox.oldestCursor()).collect { events.add(it.event) } } val conv = agent.createConversation(temp = false) agent.deleteConversation(conv.id) @@ -662,6 +661,71 @@ class ChatAgentTest { assertEquals(conv.id, created.conversationId) assertEquals(conv.id, deleted.id) } + + @Test + fun `chatSnapshot returns state plus cursor and resuming from it loses nothing`() = runTest { + // Инвариант протокола: snapshot.messages ∪ дельты(offset > cursor) == + // финальное состояние. Ни одна строка не теряется и не дублируется + // (дедуп делается по id у клиента, но здесь проверяем само покрытие). + val agent = newAgent() + fakeLlm.reply = "first reply" + val conv = agent.createConversation(temp = false) as ChatConversation + conv.send(listOf(Content.Text("first user"))) + + val snap = agent.chatSnapshot(conv.id) + assertEquals(agent.outbox.currentCursor(), snap.cursor, "snapshot cursor must equal current cursor") + assertTrue(snap.messages.all { it.seq <= snap.cursor.offset }, "message beyond cursor: ${snap.messages}") + assertTrue(snap.messages.any { it is MessageRecord.UserMessage }) + assertTrue(snap.messages.any { it is MessageRecord.AssistantMessage }) + + // Резюм строго «после курсора» — следующая дельта. + val deltas = mutableListOf() + val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id) + .collect { deltas.add(it) } + } + fakeLlm.reply = "second reply" + conv.send(listOf(Content.Text("second user"))) + delay(50) + job.cancel() + + assertTrue(deltas.isNotEmpty(), "no deltas after snapshot cursor") + assertTrue(deltas.all { it.offset > snap.cursor.offset }, "delta <= cursor: $deltas") + + val final = agent.chatSnapshot(conv.id) + assertTrue(final.messages.size > snap.messages.size, "resume must advance state") + val snapshotIds = snap.messages.map { it.id }.toSet() + val deltaIds = deltas.mapNotNull { ce -> + when (val e = ce.event) { + is ProtoEvent.UserMessage -> e.id + is ProtoEvent.AssistantMessage -> e.id + else -> null + } + }.toSet() + assertEquals(final.messages.map { it.id }.toSet(), snapshotIds + deltaIds) + } + + @Test + fun `conversationsSnapshot carries cursor and a later chat arrives as an agent delta`() = runTest { + val agent = newAgent() + val conv1 = agent.createConversation(temp = false) + val snap = agent.conversationsSnapshot() + assertEquals(agent.outbox.currentCursor(), snap.cursor) + assertTrue(snap.conversations.any { it.id == conv1.id }, "snapshot=$snap") + + val deltas = mutableListOf() + val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + agent.outbox.agentEvents(after = snap.cursor).collect { deltas.add(it.event) } + } + val conv2 = agent.createConversation(temp = false) + delay(50) + job.cancel() + + assertTrue( + deltas.any { it is AgentEvent.Created && it.conversationId == conv2.id }, + "deltas=$deltas", + ) + } } /** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */