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

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

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

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

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

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

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

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

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

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

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

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

sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
+459
View File
@@ -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<LoggedEvent>
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<PendingEvent>
fun markPendingAsSent(localId: UUID, seq: Long)
fun markPendingAsFailed(localId: UUID)
}
```
### 4.4. Ассистент — общая абстракция
Ключевая идея прозрачности: **ассистент — это просто генератор событий**. Клиент не знает, локальный он или удалённый.
```kotlin
interface Assistant {
// Запускает генерацию, возвращает поток событий
fun run(input: RunInput): Flow<DomainEvent>
}
class RemoteAssistant(...) : Assistant {
override fun run(input: RunInput): Flow<DomainEvent> {
// Стримит события от сервера через WebSocket
}
}
class LocalAssistant(...) : Assistant {
override fun run(input: RunInput): Flow<DomainEvent> {
// Генерирует события локально (например, вызывает локальную 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<DomainEvent>.
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<DomainEvent>`.
### 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<DomainEvent>`.
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 с нового курсора. Локальный и удалённый ассистенты — просто генераторы событий, неотличимые для клиента.
@@ -5,9 +5,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo 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.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
@@ -34,14 +37,21 @@ internal class FakeAgent(
// emptyFlow, journal — error-on-access (никто не должен его трогать). // emptyFlow, journal — error-on-access (никто не должен его трогать).
override val journal: JournalStore = error("journal not used in TuiBackend tests") override val journal: JournalStore = error("journal not used in TuiBackend tests")
override val outbox: OutboxStore = object : OutboxStore { override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>() override fun events(after: Cursor?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>() override fun agentEvents(after: Cursor?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>()
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 fun close() {}
} }
override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests") 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 { override fun createConversation(temp: Boolean): Conversation {
createCount++ createCount++
val c = conversationFactory() val c = conversationFactory()
+211 -249
View File
@@ -13,14 +13,19 @@
`deleteConversation` / `journal` / `outbox` / `close`. `deleteConversation` / `journal` / `outbox` / `close`.
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`) - `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`. / `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>` - `HttpJournalStore` — `list(convId, afterSeq, upToSeq, limit)` /
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens). `count(convId, afterSeq)` → `List<MessageRecord>` со всеми типами записей
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE). (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` с - `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
авто-reconnect при обрыве стрима (exponential backoff). Два независимых авто-reconnect при обрыве стрима (exponential backoff). Два независимых
потока: `events()` (те же `CommonEvent`) и `connectionStatus()` потока: `events(after: Cursor?)` (те же `CommonEvent`) и `connectionStatus()`
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается (`Connecting`/`Connected`/`Disconnected`/`Failed`/`Gap`) — статус НЕ мешается
с основным потоком событий. См. ниже. с основным потоком событий. Мёртвый курсор даёт `Gap` (не ретраится). См. ниже.
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно `Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины. вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
@@ -65,6 +70,95 @@ dependencies {
⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере. ⚠️ `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 минут ## Быстрый старт: свой клиент за 5 минут
Один self-contained пример: создаём агента, открываем диалог, Один self-contained пример: создаём агента, открываем диалог,
@@ -73,12 +167,11 @@ dependencies {
```kotlin ```kotlin
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.content.Content 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 pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.time.Clock
fun main() = runBlocking { fun main() = runBlocking {
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри. // 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
@@ -91,16 +184,19 @@ fun main() = runBlocking {
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
// Подписка «после текущего курсора» — событий строго после этой точки.
val cursor = agent.outbox.currentCursor()
// 2. Два независимых потока событий диалога: // 2. Два независимых потока событий диалога:
// durable (outbox) — целые события, с курсором после переподключения; // durable (outbox) — целые события, с курсором после переподключения;
// online (OnlineOutbox) — стриминг ответа, только live (без курсора). // online (OnlineOutbox) — стриминг ответа, только live (без курсора).
launch { launch {
agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id) agent.outbox.conversationEvents(after = cursor, conversationId = conv.id)
.collect { ce -> .collect { ce ->
when (val ev = ce.event) { when (val ev = ce.event) {
is Event.AssistantMessage -> println("[answer ready: ${ev.content}]") is DurableEvent.AssistantMessage -> println("[answer ready: ${ev.content}]")
is Event.Interrupted -> println("[interrupted]") is DurableEvent.Interrupted -> println("[interrupted]")
is Event.Error -> println("[error: ${ev.message}]") is DurableEvent.Error -> println("[error: ${ev.message}]")
else -> Unit else -> Unit
} }
} }
@@ -129,12 +225,12 @@ fun main() = runBlocking {
**Это весь клиент.** `:server` сам хранит историю, контекст, события. **Это весь клиент.** `:server` сам хранит историю, контекст, события.
Ты только получаешь два типизированных `Flow` и рендеришь как хочешь. Ты только получаешь два типизированных `Flow` и рендеришь как хочешь.
> **Durable vs online.** `Event` (в `agent.outbox`) — «целые» события, их > **Durable vs online.** `DurableEvent` (в `agent.outbox`) — «целые» события, их
> можно перезапросить по курсору `after`. `OnlineEvent` (в > можно перезапросить по курсору `after`. `OnlineEvent` (в
> `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/ > `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/
> `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при > `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при
> обрыве фрагмент невосстановим, но целый ответ всегда придёт durable- > обрыве фрагмент невосстановим, но целый ответ всегда придёт durable-
> `Event.AssistantMessage` и/или ляжет в journal. > `DurableEvent.AssistantMessage` и/или ляжет в journal.
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто `HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
внутри `AgentikAgent`. Один вызов — один готовый `Agent`. внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
@@ -143,19 +239,20 @@ fun main() = runBlocking {
```kotlin ```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import kotlin.time.Instant
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам. // Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
val cache = InMemoryJournalStore() val cache = InMemoryJournalStore()
// Backfill + live-refresh в одном фоне: // Снапшот на курсоре + подписка ПОСЛЕ него — без потерь (см. «Курсорный протокол»).
val snap = agent.chatSnapshot(conv.id)
cache.appendAll(snap.messages)
launch { launch {
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST) agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
.collect { cache.append(it) } .collect { ce -> applyDurable(ce.event, cache) } // upsert by id
} }
// История — теперь из кэша, без HTTP: // История — теперь из кэша, без 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 -> history.forEach { rec ->
when (rec) { when (rec) {
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}") is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
@@ -167,17 +264,18 @@ history.forEach { rec ->
} }
``` ```
Шаблон "remote.listFlow → local.append" работает с любым Шаблон «snapshot(курсор) → local.apply → live-дельты после курсора» работает с
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование любым `MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
"без геморроя". «без геморроя» с гарантией актуальности.
### Что вообще не нужно писать самому ### Что вообще не нужно писать самому
- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует - HTTP-сериализация `DurableEvent`/`Message` — `agentikHttpClient` регистрирует
`agentikJson` и `InstantSerializer`. `agentikJson` и `InstantSerializer`.
- SSE-парсер — `readSse()` внутри `:client`. - SSE-парсер — `readSse()` внутри `:client`.
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в - Cursor-менеджмент — сервер ведёт единый монотонный `offset`/`seq`, клиент
`JournalStore.listFlow` сама пагинирует. лишь хранит `Cursor(epoch, offset)`. Никаких `Instant`-сравнений и
pagination-циклов вручную.
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job. - Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)` - HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)`
с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает. с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает.
@@ -187,7 +285,7 @@ history.forEach { rec ->
### Что нужно написать самому ### Что нужно написать самому
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI). - UI-рендеринг `DurableEvent`'ов — это твоё (Compose/HTML/CLI).
- Диалог с пользователем — ввод текста, отображение кнопок и т.п. - Диалог с пользователем — ввод текста, отображение кнопок и т.п.
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore` - Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример). на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
@@ -198,7 +296,7 @@ history.forEach { rec ->
```kotlin ```kotlin
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.content.Content 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 pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -212,12 +310,13 @@ val agent = AgentikAgent(
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
// durable-поток (с курсором): terminal-события хода. // durable-поток (с курсором): terminal-события хода.
val cursor = agent.outbox.currentCursor()
launch { launch {
agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id) agent.outbox.conversationEvents(after = cursor, conversationId = conv.id)
.collect { ce -> .collect { ce ->
when (ce.event) { when (ce.event) {
is Event.AssistantMessage -> println("\n--- answer ready ---") is DurableEvent.AssistantMessage -> println("\n--- answer ready ---")
is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}") is DurableEvent.Error -> error("agent error: ${(ce.event as DurableEvent.Error).message}")
else -> Unit else -> Unit
} }
} }
@@ -235,268 +334,122 @@ launch {
conv.send(listOf(Content.Text("Привет, расскажи про себя"))) conv.send(listOf(Content.Text("Привет, расскажи про себя")))
``` ```
## Локальный кэш истории (правильный паттерн)
## История с локальным кэшем Клиент держит свой `MutableJournalStore` и наполняет его **снапшотом на
курсоре + дельтами после курсора** (см. «Курсорный протокол»). Чтение истории —
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически из локального кэша, без HTTP.
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
чтение истории — из локального кэша.
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
реализовывал интерфейс.
```kotlin ```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.content.Content import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.DurableEvent
import kotlin.time.Instant
// Кэш. Для диска — свой MutableJournalStore (KsqliteJournalStore в :journal-ksqlite).
val cache = InMemoryJournalStore()
class ChatSession( class ChatSession(
private val agent: pw.binom.agentik.proto.Agent, private val agent: pw.binom.agentik.proto.Agent,
val conversationId: String, val conversationId: String,
) : AutoCloseable { ) : AutoCloseable {
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
// (модуль `:journal-api`).
val cache = InMemoryJournalStore()
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
private val scope = kotlinx.coroutines.CoroutineScope( private val scope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Default,
kotlinx.coroutines.Dispatchers.Default,
) )
var lastSeen: pw.binom.agentik.outbox.Cursor? = null
init { init {
// 1. Backfill: забираем всю историю разговора с сервера.
scope.launch { scope.launch {
agent.journal.listFlow( // 1. Снапшот: состояние + курсор, на котором оно валидно.
conversationId = conversationId, val snap = agent.chatSnapshot(conversationId)
after = Instant.DISTANT_PAST, snap.messages.forEach { cache.append(it) }
).collect { cache.append(it) } lastSeen = snap.cursor
} // 2. Дельты строго после курсора снапшота.
// 2. Live: на каждом завершённом ходе (durable AssistantMessage) agent.outbox.conversationEvents(after = snap.cursor, conversationId = conversationId)
// просим у сервера новые записи. .collect { ce ->
scope.launch { applyToCache(ce.event)
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce -> lastSeen = ce.cursor
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) }
} }
} }
} }
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 { 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() { override fun close() { scope.cancel() }
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("Привет ещё раз")))
} }
``` ```
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом > `applyToCache` через `cache.append` даёт upsert по `id` (append-only store
(текст + tool-call/tool-result + tokens). UI сам решает что показать — > отбрасывает дубликаты `id`), поэтому перекрытие снапшота и дельт безвредно.
`rec is MessageRecord.UserMessage` для реплик пользователя, > Замените `InMemoryJournalStore` на `KsqliteJournalStore` — код не меняется.
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
### Когда курсор мёртв
Если `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 / на сервере (`ConversationRecord` = id / title / isTemporal / createdAt /
updatedAt, без `Conversation` handle и без флагов image-support). updatedAt). `AgentikAgent` оборачивает его в локальный кэш
(`wrapWithLocalConversationCache`) по тому же протоколу, что и историю:
**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы снапшот на курсоре + live-дельты.
при открытии UI уже знал названия, не дёргал сервер лишний раз, и
моментально реагировал на создание/удаление/переименование в другой
вкладке.
Подход — тот же **«remote → local snapshot + live-events»**:
```kotlin ```kotlin
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш: val agent = AgentikAgent(id = "agentik", baseUrl = "http://localhost:8080/agentik", engineFactory = CIO)
// remote.listFlow → local.upsert (snapshot)
// outbox.agentEvents → local.upsert / delete (live)
val agent = AgentikAgent(
id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
// Кэш уже наполняется в фоне, читать можно сразу: // Снапшот списка бесед + его курсор (глобальный для агента).
val all = agent.conversationStore.list(0, Int.MAX_VALUE) val snap = agent.conversationsSnapshot()
all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") } snap.conversations.forEach { println("${it.id} ${it.title ?: "(no title)"} ${it.updatedAt}") }
// И наблюдать live-изменения (Created/Deleted/Renamed/Touched) // Дельты после курсора снапшота: Created / Deleted / Renamed / Touched.
agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev -> agent.outbox.agentEvents(after = snap.cursor).collect { ce ->
when (ev) { when (val ev = ce.event) {
is AgentEvent.Created -> println("+ ${ev.conversationId}") is AgentEvent.Created -> println("+ ${ev.conversationId}")
is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}") is AgentEvent.Renamed -> println("~ ${ev.id} -> ${ev.title}")
is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})") is AgentEvent.Touched -> println("~ ${ev.id} (${ev.updatedAt})")
is AgentEvent.Deleted -> println("- ${ev.id}") is AgentEvent.Deleted -> println("- ${ev.id}")
} }
} }
``` ```
Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP **Команды** (создать / переименовать / удалить) идут через `agent`; сервер сам
для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как эмитит соответствующее `AgentEvent` в outbox, клиент применяет его к кэшу:
escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен —
подмени его на свою реализацию через `wrapWithLocalConversationCache`,
если нужен SQLite/JSON-store.
## Стриминг live-ответа
Для streaming-рендера текущего хода подписывайся на `events()` и
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
только для UI-feedback во время хода. После `End` хода запись уже
появится в кэше через refresh-блок выше.
```kotlin ```kotlin
import pw.binom.agentik.outbox.OnlineEvent val conv = agent.createConversation(temp = false) // POST /conversations -> Created
agent.renameConversation(conv.id, "Новый заголовок") // PATCH /conversations/{id} -> Renamed
agent.onlineOutbox.onlineEvents(convId).collect { ev -> agent.deleteConversation(conv.id) // DELETE /conversations/{id} -> Deleted
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
}
}
``` ```
Инструментальные вызовы и целый ответ — durable-поток **Никогда не пиши в `conversationStore` напрямую.** Для активной работы
(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и (send / interrupt) — handle через `agent.getConversation(id)`.
`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`.
## Прерывание хода
```kotlin
agent.getConversation(convId)!!.interrupt()
```
## Multi-conversation
Один `Agent`, много `ChatSession`:
```kotlin
val sessions = mutableMapOf<String, ChatSession>()
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<CommonEvent.Agent>`.
## Где `: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`.
### Если хочется своего cache-импла ### Если хочется своего cache-импла
`InMemoryMutableConversationStore` подходит для 99% случаев — Map + `InMemoryMutableConversationStore` подходит для большинства случаев. Для диска —
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление свой `MutableConversationStore` (см. `KsqliteMutableConversationStore` в
после перезапуска) — реализуй свой `MutableConversationStore` поверх `:journal-ksqlite`). Методы `rename`/`touch` принимают `seq` из общего счётчика:
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 */ }
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ } override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
override suspend fun delete(id: String): Boolean { /* DELETE */ } override suspend fun delete(id: String): Boolean { /* DELETE */ }
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ } override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
@@ -511,15 +464,17 @@ class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
./gradlew :client:jvmTest ./gradlew :client:jvmTest
``` ```
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва, Покрывают: JSON-парсинг `DurableEvent`-ов, SSE-стрим, recovery после разрыва,
401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв + 401/404, reconnect-cycle `ReconnectingOutbox` (5 кейсов: успех / обрыв +
reconnect / exhausted attempts → Failed / close → cancel). reconnect / exhausted attempts → Failed / мёртвый курсор → Gap (без ретрая) /
close → cancel).
## Auto-reconnect для живого outbox ## Auto-reconnect для живого outbox
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная Базовый `OutboxStore.events(after: Cursor?)` — cold SSE-стрим; при обрыве
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`. (мобильная сеть, рестарт сервера) клиент должен сам реконнектиться с курсором
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя: последнего увиденного события. Это повторяется в каждом клиенте, поэтому
`ReconnectingOutbox` берёт это на себя:
```kotlin ```kotlin
val recon = ReconnectingOutbox( val recon = ReconnectingOutbox(
@@ -528,7 +483,8 @@ val recon = ReconnectingOutbox(
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter 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 { scope.launch {
recon.connectionStatus().collect { status -> recon.connectionStatus().collect { status ->
when (status) { when (status) {
@@ -536,6 +492,7 @@ scope.launch {
is Connected -> ui.hideBanner() is Connected -> ui.hideBanner()
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…") is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
is Failed -> ui.showError(status.cause) is Failed -> ui.showError(status.cause)
is Gap -> resync(status.cause) // курсор мёртв — полный resync
} }
} }
} }
@@ -544,6 +501,11 @@ scope.launch {
recon.close() // отменяет background-loop, потоки терминируются recon.close() // отменяет background-loop, потоки терминируются
``` ```
`Gap` — единственный статус, который **не** ретраится: курсор старше
retention'а или чужая эпоха. Обработка — полный resync (см. «Курсорный
протокол»). Если не передать `after`, при старте берётся
`outbox.currentCursor()` (live-only семантика).
Два потока **независимы** — `events()` содержит только `CommonEvent`, Два потока **независимы** — `events()` содержит только `CommonEvent`,
`connectionStatus()` содержит только `ConnectionStatus`. Никакого `connectionStatus()` содержит только `ConnectionStatus`. Никакого
"мешающего" `Connecting`/`Disconnected` в потоке событий. "мешающего" `Connecting`/`Disconnected` в потоке событий.
@@ -18,7 +18,9 @@ import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.ChatSnapshot
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.ConversationsSnapshot
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -81,6 +83,22 @@ internal class AgentClient private constructor(
return rec.updatedAt 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() { override fun close() {
httpClient.close() httpClient.close()
} }
@@ -1,12 +1,14 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.HttpClientEngineFactory
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationRecord 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.MutableConversationStore
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.OutboxGapException
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import kotlin.time.Instant import kotlin.time.Duration.Companion.seconds
/** /**
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`). * Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
@@ -34,8 +37,9 @@ import kotlin.time.Instant
* ) * )
* val conv = agent.createConversation(temp = false) * val conv = agent.createConversation(temp = false)
* conv.send(listOf(Content.Text("hi"))) * conv.send(listOf(Content.Text("hi")))
* agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id) * // Курсор-протокол: сначала снапшот (state + cursor), потом подписка «после»:
* .map { it.event } * val snap = agent.chatSnapshot(conv.id)
* agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
* .collect { ... } * .collect { ... }
* agent.close() // закрывает HttpClient + локальный кэш * agent.close() // закрывает HttpClient + локальный кэш
* ``` * ```
@@ -76,10 +80,12 @@ import kotlin.time.Instant
* *
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP. * [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
* Внутри лежит [InMemoryMutableConversationStore], который: * Внутри лежит [InMemoryMutableConversationStore], который:
* 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`. * 1. На старте берёт `conversationsSnapshot()` (полный список + курсор) и
* 2. Подписывается на `outbox.agentEvents(after)` → для каждого * приводит к нему локальную копию.
* 2. Подписывается на `outbox.agentEvents(after = snapshot.cursor)` → для каждого
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет * [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
* соответствующий `upsert/delete/rename/touch` к локальной копии. * соответствующий `upsert/delete/rename/touch` к локальной копии.
* 3. При `OutboxGapException` повторяет с шага 1 (полный resync).
* *
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, * UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут * без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
@@ -103,17 +109,23 @@ fun AgentikAgent(
/** /**
* Оборачивает [Agent] так, что [Agent.conversationStore] становится * Оборачивает [Agent] так, что [Agent.conversationStore] становится
* локальным in-memory кэшем, синхронизированным с удалённым стором * локальным in-memory кэшем, синхронизированным с удалённым стором
* через outbox-события. * по курсор-протоколу.
* *
* - **Seed**: при создании делает один snapshot через * **Протокол синхронизации** (гарантирует актуальный список бесед):
* `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore]. * 1. `conversationsSnapshot()` — база (полный список) + курсор `C`.
* - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет * 2. `outbox.agentEvents(after = C)` — дельты, применяются поверх базы
* `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу. * (`Created`/`Deleted`/`Renamed`/`Touched`, все абсолютные и идемпотентные).
* 3. [OutboxGapException] (курсор мёртв — retention / смена epoch) → повтор
* с шага 1 (полный resync: `reconcile` удаляет локальные беседы, которых
* нет в снапшоте, и upsert'ит все из снапшота).
* 4. Прочие ошибки (сеть) → пауза и повтор.
* *
* Возвращает обёртку, у которой переопределён только [Agent.conversationStore] * Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
* (на read-only projection локального [InMemoryMutableConversationStore]). * (на read-only projection локального [InMemoryMutableConversationStore]).
* Остальные методы [Agent] — delegated в [delegate]. * Остальные методы [Agent] — delegated в [delegate].
*/ */
private val RESYNC_RETRY_DELAY = 2.seconds
private fun wrapWithLocalConversationCache( private fun wrapWithLocalConversationCache(
delegate: Agent, delegate: Agent,
scopeClient: Agent, scopeClient: Agent,
@@ -124,25 +136,48 @@ private fun wrapWithLocalConversationCache(
private val syncJob: Job private val syncJob: Job
init { init {
// Делаем cacheStore read-only view на localStore. syncJob = cacheScope.launch { syncLoop() }
// (Через вложенный класс — см. ниже.)
// Запускаем 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-источник всё равно догонит при первом событии.
} }
// 2. live — применяем outbox-события. private suspend fun syncLoop() {
// Используем `first()` для knownId после Created — потом отписываемся, while (cacheScope.isActive) {
// потому что Created нужно вытянуть полный record через `remote.get(id)`. try {
// Renamed/Touched меняют локальную копию без round-trip. val snap = delegate.conversationsSnapshot()
delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce -> reconcile(snap.conversations)
when (val ev = ce.event) { 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<ConversationRecord>) {
val fresh = records.mapTo(HashSet()) { it.id }
val stale = ArrayList<String>()
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 -> { is AgentEvent.Created -> {
// Created не несёт title/timestamps — нужно сходить в remote. // Created не несёт title/timestamps — нужно сходить в remote.
val rec = delegate.conversationStore.get(ev.conversationId) val rec = delegate.conversationStore.get(ev.conversationId)
@@ -153,8 +188,6 @@ private fun wrapWithLocalConversationCache(
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt) is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
} }
} }
}
}
/** /**
* Read-only projection локального кэша — клиент через него только * Read-only projection локального кэша — клиент через него только
@@ -1,45 +1,42 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.HttpClient 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.request.prepareGet
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import pw.binom.agentik.outbox.OutboxStore import kotlinx.serialization.KSerializer
import pw.binom.agentik.outbox.AgentEvent import kotlinx.serialization.Serializable
import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Cursor
import kotlin.time.Clock import pw.binom.agentik.outbox.OutboxGapException
import kotlin.time.Instant import pw.binom.agentik.outbox.OutboxStore
/** /**
* HTTP-реализация [OutboxStore] (= [pw.binom.agentik.outbox.OutboxStore]), * HTTP-реализация [OutboxStore], ходящая в `:server`-фасад.
* ходящая в `:server`-фасад.
* *
* **Endpoint-раскладка** (новый дизайн — storage handles на [Agent]): * **Endpoint-раскладка**:
* - [events] → `GET {baseUrl}/outbox/events?after=` (полный поток * - [events] → `GET {baseUrl}/outbox/events?epoch=&offset=` (полный поток
* [CommonEvent], bounded-tail + live SSE, см. [pw.binom.agentik.server.outboxRoutes]) * [CommonEvent], bounded-tail + live SSE). Без параметров — live-only.
* - [agentEvents] → `GET {baseUrl}/events?after=` (legacy proto-роут: * - [agentEvents] → `GET {baseUrl}/events?epoch=&offset=` (только
* сервер пробрасывает [pw.binom.agentik.outbox.agentEvents] и распаковывает * `CommonEvent.Agent`).
* `.event` для обратной совместимости с форматом AgentEvent) * - [conversationEvents] с `conversationId != null` →
* - [conversationEvents] с `conversationId != null` → `GET /conversations/{id}/events` * `GET /conversations/{id}/events?epoch=&offset=`; с `null` — fallback на
* default [OutboxStore.conversationEvents] (общий `/outbox/events` + filter).
* - [currentCursor] / [oldestCursor] → `GET {baseUrl}/outbox/cursor`.
* *
* Для [conversationEvents] с `conversationId == null` (события всех диалогов) * **Gap** (`410 Gone`): сервер отвечает `410` с [GapResponse] (oldest/current
* fallback на default [OutboxStore.conversationEvents] — общий поток * курсоры) — клиент конвертирует в [OutboxGapException]. Это сигнал сделать
* `/outbox/events` + filter. Это редкий кейс (admin-дашборды), и * resync: `agent.conversationsSnapshot()` / `agent.chatSnapshot(id)`.
* оптимизировать его отдельно нерационально.
* *
* [earliestEventDate] не имеет своего endpoint'а; возвращает `Clock.System.now()` * **Импорты [CommonEvent]/[Cursor] идут напрямую из `pw.binom.agentik.outbox`** —
* (см. KDoc [OutboxStore.earliestEventDate] — для пустого буфера это и есть * typealias'ы в `:proto` не поддерживают nested-class access.
* контрактное значение). Клиент, который полагался на 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.
*/ */
internal class HttpEventStore( internal class HttpEventStore(
private val httpClient: HttpClient, private val httpClient: HttpClient,
@@ -48,85 +45,80 @@ internal class HttpEventStore(
private val agentUrl: String = baseUrl.trimEnd('/') private val agentUrl: String = baseUrl.trimEnd('/')
override fun events(after: Instant?): Flow<CommonEvent> = flow { override fun events(after: Cursor?): Flow<CommonEvent> =
val url = buildString { sse("$agentUrl/outbox/events", after, CommonEvent.serializer())
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 agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
* Override: идём в `/events` напрямую — сервер фильтрует только lifecycle-события. sse("$agentUrl/events", after, CommonEvent.Agent.serializer())
* Default из [EventStore.agentEvents] читал бы `/events/all` + `filterIsInstance`.
*/
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = 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 с `conversationId != null` — идём в `/conversations/{id}/events`.
* С `null` (события всех диалогов) — fallback на default impl из [EventStore]:
* общий `/events/all` + filter.
*/
override fun conversationEvents( override fun conversationEvents(
after: Instant?, after: Cursor?,
conversationId: String?, conversationId: String?,
): Flow<CommonEvent.Conversation> { ): Flow<CommonEvent.Conversation> {
if (conversationId == null) { if (conversationId == null) return super.conversationEvents(after, null)
return super.conversationEvents(after, null) return sse(
"$agentUrl/conversations/$conversationId/events",
after,
CommonEvent.Conversation.serializer(),
)
} }
return flow {
val url = buildString { override suspend fun currentCursor(): Cursor = cursorResponse().current
append("$agentUrl/conversations/$conversationId/events")
if (after != null) append("?after=$after") override suspend fun oldestCursor(): Cursor = cursorResponse().oldest
}
httpClient.prepareGet(url) { noReadTimeout() } private suspend fun cursorResponse(): CursorResponse {
.execute { response -> val response = httpClient.get("$agentUrl/outbox/cursor")
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
"conversationEvents: server returned ${response.status}" "outbox.cursor: server returned ${response.status}"
}
return response.body()
}
private fun <T> sse(url: String, after: Cursor?, serializer: KSerializer<T>): Flow<T> = flow {
httpClient.prepareGet(url) {
noReadTimeout()
if (after != null) {
parameter("epoch", after.epoch)
parameter("offset", after.offset)
}
}.execute { response ->
if (response.status == HttpStatusCode.Gone) {
throw response.toGapException(after)
}
check(response.status == HttpStatusCode.OK) {
"$url: server returned ${response.status}"
} }
readSse(response.bodyAsChannel()) readSse(response.bodyAsChannel())
.collect { payload -> .collect { payload ->
val event = agentikJson.decodeFromString(Event.serializer(), payload) emit(agentikJson.decodeFromString(serializer, payload))
emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
} }
} }
} }
}
/**
* У HTTP-варианта нет своего endpoint'а для earliest-event-date.
* Контракт [EventStore.earliestEventDate] для пустого буфера говорит
* "сейчас" — для HTTP-клиента буфер на нашей стороне всегда "пуст"
* (мы не держим своё состояние), поэтому возвращаем `Clock.System.now()`.
*/
override suspend fun earliestEventDate(): Instant = Clock.System.now()
override fun close() { override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent). // 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,
)
}
@@ -58,6 +58,23 @@ internal class HttpJournalStore(
return response.body<List<MessageRecord>>() return response.body<List<MessageRecord>>()
} }
override suspend fun list(
conversationId: String,
afterSeq: Long,
upToSeq: Long,
limit: Int,
): List<MessageRecord> {
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<List<MessageRecord>>()
}
override suspend fun count(conversationId: String): Long { override suspend fun count(conversationId: String): Long {
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count") val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count")
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
@@ -76,6 +93,16 @@ internal class HttpJournalStore(
return response.body<CountResponse>().count return response.body<CountResponse>().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<CountResponse>().count
}
override fun close() { override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent). // HttpClient закрывает владелец (AgentClient / AgentikAgent).
} }
@@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.agentik.outbox.CommonEvent 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 pw.binom.agentik.outbox.OutboxStore
import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.AtomicReference
@@ -60,6 +62,17 @@ sealed interface ConnectionStatus {
* outbox и т.п. * outbox и т.п.
*/ */
data class Failed(val cause: Throwable) : ConnectionStatus 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) * val outbox = ReconnectingOutbox(httpEventStore, scope)
* *
* // Курсор берётся из снапшота: state + cursor, затем подписка «после него».
* val snap = agent.chatSnapshot(conversationId)
* scope.launch { * scope.launch {
* outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) } * outbox.events(after = snap.cursor).collect { e -> handle(e) }
* } * }
* scope.launch { * scope.launch {
* outbox.connectionStatus().collect { s -> ui.showStatus(s) } * outbox.connectionStatus().collect { s -> ui.showStatus(s) }
@@ -154,19 +169,27 @@ class ReconnectingOutbox(
private var job: Job? = null private var job: Job? = null
@OptIn(ExperimentalAtomicApi::class) @OptIn(ExperimentalAtomicApi::class)
private val lastSeen: AtomicReference<Instant?> = AtomicReference(null) private val lastSeen: AtomicReference<Cursor?> = AtomicReference(null)
/** /**
* Live-события из [outbox] с авто-reconnect. [after] — начальный курсор; * Live-события из [outbox] с авто-reconnect. [after] — начальный курсор;
* учитывается только при первом вызове (любом из [events] / * учитывается только при первом вызове (любом из [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). * Коллекторы независимы — каждый получает свою копию потока (shared).
* Медленный коллектор может пропускать события при переполнении буфера * Медленный коллектор может пропускать события при переполнении буфера
* (`DROP_OLDEST`). * (`DROP_OLDEST`).
*/ */
fun events(after: Instant? = null): Flow<CommonEvent> { fun events(after: Cursor? = null): Flow<CommonEvent> {
ensureStarted(after) ensureStarted(after)
return _events return _events
} }
@@ -183,7 +206,7 @@ class ReconnectingOutbox(
} }
@OptIn(ExperimentalAtomicApi::class) @OptIn(ExperimentalAtomicApi::class)
private fun ensureStarted(initialCursor: Instant?) { private fun ensureStarted(initialCursor: Cursor?) {
if (!started.compareAndSet(false, true)) return if (!started.compareAndSet(false, true)) return
lastSeen.store(initialCursor) lastSeen.store(initialCursor)
job = scope.launch { runLoop() } job = scope.launch { runLoop() }
@@ -196,9 +219,13 @@ class ReconnectingOutbox(
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
attempt++ attempt++
_status.emit(ConnectionStatus.Connecting(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 { val error: Throwable? = try {
outbox.events(after = lastSeen.load()).collect { event -> outbox.events(after = cursor).collect { event ->
lastSeen.store(event.date) lastSeen.store(Cursor(epoch = cursor?.epoch ?: "", offset = event.offset))
_events.emit(event) _events.emit(event)
if (!connected) { if (!connected) {
connected = true connected = true
@@ -208,10 +235,18 @@ class ReconnectingOutbox(
null null
} catch (t: CancellationException) { } catch (t: CancellationException) {
throw t throw t
} catch (t: OutboxGapException) {
gap = t
null
} catch (t: Throwable) { } catch (t: Throwable) {
t t
} }
connected = false connected = false
if (gap != null) {
// Ретраить бессмысленно: курсор мёртв. Отдаём сигнал наружу.
_status.emit(ConnectionStatus.Gap(gap))
return
}
if (attempt >= policy.maxAttempts) { if (attempt >= policy.maxAttempts) {
_status.emit( _status.emit(
ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally")) ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally"))
@@ -11,10 +11,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.outbox.OutboxStore 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
@@ -41,7 +42,7 @@ internal class FakeOutbox : OutboxStore {
private val channel = Channel<Msg>(Channel.UNLIMITED) private val channel = Channel<Msg>(Channel.UNLIMITED)
override fun events(after: Instant?): Flow<CommonEvent> = flow { override fun events(after: Cursor?): Flow<CommonEvent> = flow {
for (msg in channel) { for (msg in channel) {
when (msg) { when (msg) {
is Msg.Err -> throw msg.throwable is Msg.Err -> throw msg.throwable
@@ -53,20 +54,24 @@ internal class FakeOutbox : OutboxStore {
fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) } fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) }
fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) } fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) }
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = emptyFlow() override fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> = emptyFlow()
override fun conversationEvents( override fun conversationEvents(
after: Instant?, after: Cursor?,
conversationId: String?, conversationId: String?,
): Flow<CommonEvent.Conversation> = emptyFlow() ): Flow<CommonEvent.Conversation> = 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() } override fun close() { channel.close() }
} }
private const val TEST_EPOCH = "test"
private fun testEvent(dateMs: Long): CommonEvent = private fun testEvent(dateMs: Long): CommonEvent =
CommonEvent.Conversation( CommonEvent.Conversation(
date = Instant.fromEpochMilliseconds(dateMs), date = Instant.fromEpochMilliseconds(dateMs),
offset = dateMs,
conversationId = "test", conversationId = "test",
event = Event.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)), event = DurableEvent.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -141,6 +146,32 @@ class ReconnectingOutboxTest {
assertEquals(0, ctx.eventsLog.size) 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<ConnectionStatus.Gap>()
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 @Test
fun `close cancels background loop`() = runConnectionTest( fun `close cancels background loop`() = runConnectionTest(
attempts = 5, attempts = 5,
+1 -1
View File
@@ -68,7 +68,7 @@ agentik
`Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`. `Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`.
События разделены на два потока (оба в `:outbox-api`): События разделены на два потока (оба в `:outbox-api`):
- **durable** `Event` (`outbox.conversationEvents(after, id)`): `UserMessage | - **durable** `DurableEvent` (`outbox.conversationEvents(after, id)`): `UserMessage |
AssistantMessage | ToolCall | ToolResult | ToolFailed | Interrupted | Error | AssistantMessage | ToolCall | ToolResult | ToolFailed | Interrupted | Error |
ConversationClosing | CompactionTriggered` — перезапрашиваются по курсору; ConversationClosing | CompactionTriggered` — перезапрашиваются по курсору;
- **online** `OnlineEvent` (`onlineOutbox.onlineEvents(id)`): `Working | End | - **online** `OnlineEvent` (`onlineOutbox.onlineEvents(id)`): `Working | End |
+1 -1
View File
@@ -2,7 +2,7 @@
kotlin = "2.4.20" kotlin = "2.4.20"
kotlinx-serialization = "1.11.0" kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.8.0" kotlinx-io = "0.9.1"
ktor = "3.1.3" ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT" a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0" kaml = "0.104.0"
@@ -13,38 +13,49 @@ import kotlin.time.Instant
* *
* Никаких обновлений, никакого удаления (кроме каскадного вместе * Никаких обновлений, никакого удаления (кроме каскадного вместе
* с ConversationStore.delete). * с 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 { interface JournalStore : AutoCloseable {
/** Legacy-страница по `createdAt > [after]`, `ORDER BY createdAt ASC` + `OFFSET`. */
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord> suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/** /**
* Сколько сообщений в диалоге [conversationId] всего. * Keyset-страница записей `[afterSeq] < seq <= [upToSeq]`, `ORDER BY seq ASC`.
* *
* O(1) на SQL-бэкендах (`SELECT COUNT(*) ... WHERE conversation_id = ?`), * @param afterSeq нижняя эксклюзивная граница (для «с начала» — `-1`).
* O(N) на in-memory (size простого list'а с фильтром по conversationId). * @param upToSeq верхняя **инклюзивная** граница (для «без отсечки» —
* Не зависит от cursor'а [after] — для total-размера диалога. * `Long.MAX_VALUE`).
*/ */
suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int): List<MessageRecord>
/** Сколько сообщений в диалоге [conversationId] всего. */
suspend fun count(conversationId: String): Long suspend fun count(conversationId: String): Long
/** /**
* Сколько сообщений в диалоге [conversationId] создано **позже** [after] * Сколько сообщений создано **позже** [after] (строго `createdAt > after`).
* (строго `createdAt > after`, как и в [list]). * Legacy unread-бейдж по времени.
*
* O(1) на SQL-бэкендах, O(N) на in-memory. Полезно для:
* - UI badge "N новых сообщений" — клиент знает последний `lastSeen`,
* сервер говорит `count(convId, after=lastSeen)`;
* - пагинации без получения самих записей: знаем лимит последней страницы,
* надо понять "есть ли ещё";
* - compaction-метрик: «сколько turn'ов осталось после cutoff».
*/ */
suspend fun count(conversationId: String, after: Instant): Long suspend fun count(conversationId: String, after: Instant): Long
/** /**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip * Сколько сообщений имеют `seq > [afterSeq]`. Cursor-версия unread-бейджа:
* (по странице через `list()` пока не получит короткую страницу). Для * `count(convId, afterSeq = lastSeenOffset)`.
* in-memory backend'ов это OK; remote/SQLite impl'ы могут override'нуть */
* на `Channel` / cursor-батчинг, чтобы избежать per-page round-trip. 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<MessageRecord> = flow { fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
var offset = 0 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<MessageRecord> = 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 { companion object {
const val PAGE_SIZE = 100 const val PAGE_SIZE = 100
} }
@@ -9,12 +9,29 @@ import kotlin.time.Instant
/** /**
* Запись в таблице `message` (append-only audit). * Запись в таблице `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 @Serializable
sealed interface MessageRecord { sealed interface MessageRecord {
val id: String val id: String
val conversationId: String val conversationId: String
val createdAt: Instant val createdAt: Instant
val seq: Long
@Serializable @Serializable
sealed interface Body : MessageRecord { sealed interface Body : MessageRecord {
@@ -29,6 +46,7 @@ sealed interface MessageRecord {
override val content: List<Content>, override val content: List<Content>,
override val createdAt: Instant, override val createdAt: Instant,
val context: MessageContext? = null, val context: MessageContext? = null,
override val seq: Long = 0L,
) : Body ) : Body
@Serializable @Serializable
@@ -45,6 +63,7 @@ sealed interface MessageRecord {
* их не раскрывает. * их не раскрывает.
*/ */
val reasoning: String? = null, val reasoning: String? = null,
override val seq: Long = 0L,
) : Body ) : Body
@Serializable @Serializable
@@ -56,6 +75,7 @@ sealed interface MessageRecord {
val toolTitle: String?, val toolTitle: String?,
val toolArgsJson: String, val toolArgsJson: String,
override val createdAt: Instant, override val createdAt: Instant,
override val seq: Long = 0L,
) : MessageRecord ) : MessageRecord
@Serializable @Serializable
@@ -74,6 +94,7 @@ sealed interface MessageRecord {
val toolName: String? = null, val toolName: String? = null,
val result: String?, val result: String?,
override val createdAt: Instant, override val createdAt: Instant,
override val seq: Long = 0L,
) : MessageRecord ) : MessageRecord
@Serializable @Serializable
@@ -84,5 +105,6 @@ sealed interface MessageRecord {
val message: String, val message: String,
val code: String?, val code: String?,
override val createdAt: Instant, override val createdAt: Instant,
override val seq: Long = 0L,
) : MessageRecord ) : MessageRecord
} }
@@ -15,21 +15,12 @@ import kotlin.time.Instant
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте * embedded/CLI сценариев достаточно; для hot-path на сервере используйте
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore]. * [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
* *
* **Контракт `list`**: возвращает подмножество с * **Контракт `list`**: legacy — `createdAt > after`, `ORDER BY createdAt ASC`
* `conversationId == conversationId && createdAt > after`, отсортированное * (+`offset/limit`); seq-версия — `afterSeq < seq <= upToSeq`,
* по `createdAt ASC`. `offset/limit` — paging поверх отфильтрованного списка. * `ORDER BY seq ASC` (keyset).
* *
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён * **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
* на сервере). [close] — no-op. * на сервере). [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 { class InMemoryJournalStore : MutableJournalStore {
@@ -54,6 +45,19 @@ class InMemoryJournalStore : MutableJournalStore {
.toList() .toList()
} }
override suspend fun list(
conversationId: String,
afterSeq: Long,
upToSeq: Long,
limit: Int,
): List<MessageRecord> = mutex.withLock {
records.asSequence()
.filter { it.conversationId == conversationId && it.seq > afterSeq && it.seq <= upToSeq }
.sortedWith(compareBy({ it.seq }, { it.createdAt }, { it.id }))
.take(limit)
.toList()
}
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */ /** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
override suspend fun clear(conversationId: String): Unit = mutex.withLock { override suspend fun clear(conversationId: String): Unit = mutex.withLock {
records.removeAll { it.conversationId == conversationId } records.removeAll { it.conversationId == conversationId }
@@ -67,6 +71,10 @@ class InMemoryJournalStore : MutableJournalStore {
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong() 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 } suspend fun size(): Int = mutex.withLock { records.size }
@@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext
* ## Миграция * ## Миграция
* *
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent * [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в * (CREATE TABLE / INDEX IF NOT EXISTS + гейтированный ADD COLUMN), так что
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов * лишних эффектов нет ни в standalone-форме, ни в shared-connection bundle'е,
* прогоняют миграцию одной и той же схемы по очереди. * где несколько store'ов прогоняют миграцию одной и той же схемы по очереди.
* *
* Prepared statements (insert / list / clear) препарируются один раз в * Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого * конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
@@ -45,6 +45,12 @@ import kotlinx.coroutines.withContext
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding * `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат * helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом. * в [MessageCodecs.kt] рядом.
*
* ## Курсор ([MessageRecord.seq])
*
* [list] с диапазоном `[afterSeq] < seq <= [upToSeq]` — keyset-пагинация,
* а не `OFFSET`: конкурентная вставка/удаление сдвигает OFFSET-окно и молча
* теряет строки. Индекс `idx_msg_conv_seq` покрывает hot-path.
*/ */
class KsqliteJournalStore private constructor( class KsqliteJournalStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
@@ -82,14 +88,14 @@ class KsqliteJournalStore private constructor(
""" """
INSERT INTO ${Schema.TABLE_MESSAGE} INSERT INTO ${Schema.TABLE_MESSAGE}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND}, (${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})
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent() """.trimIndent()
) )
private val listStmt: SQLitePreparedStatement = connection.prepare( private val listStmt: SQLitePreparedStatement = connection.prepare(
""" """
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND}, 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} FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ? WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ? AND ${Schema.COL_CREATED_AT} > ?
@@ -97,6 +103,18 @@ class KsqliteJournalStore private constructor(
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""".trimIndent() """.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( private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?" "DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
) )
@@ -110,6 +128,13 @@ class KsqliteJournalStore private constructor(
AND ${Schema.COL_CREATED_AT} > ? AND ${Schema.COL_CREATED_AT} > ?
""".trimIndent() """.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) { override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record) val (kind, payload) = encodeRecord(record)
@@ -121,6 +146,7 @@ class KsqliteJournalStore private constructor(
insertStmt.bindText(3, kind) insertStmt.bindText(3, kind)
insertStmt.bindText(4, payload) insertStmt.bindText(4, payload)
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds()) insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
insertStmt.bindLong(6, record.seq)
insertStmt.executeUpdate() insertStmt.executeUpdate()
} }
} }
@@ -148,6 +174,29 @@ class KsqliteJournalStore private constructor(
} }
} }
override suspend fun list(
conversationId: String,
afterSeq: Long,
upToSeq: Long,
limit: Int,
): List<MessageRecord> = 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<MessageRecord>()
listSeqStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(rs.toMessageRecord(json))
}
}
out
}
}
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) { override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock { mutex.withLock {
clearStmt.reset() 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() { override fun close() {
insertStmt.close() insertStmt.close()
listStmt.close() listStmt.close()
listSeqStmt.close()
clearStmt.close() clearStmt.close()
countAllStmt.close() countAllStmt.close()
countAfterStmt.close() countAfterStmt.close()
countAfterSeqStmt.close()
if (ownsConnection) { if (ownsConnection) {
connection.close() connection.close()
} }
@@ -43,26 +43,28 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
val kind = getText(2)!! val kind = getText(2)!!
val payload = getText(3)!! val payload = getText(3)!!
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!) val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
// Колонка `seq` — 6-я (индекс 5) в SELECT'ах store'а.
val seq = getLong(5) ?: 0L
return when (kind) { return when (kind) {
"user" -> { "user" -> {
val d = decodeBodyPayload(payload) 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" -> { "assistant" -> {
val d = decodeBodyPayload(payload) 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" -> { "tool_call" -> {
val p = Json.decodeFromString(CallPayload.serializer(), payload) 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" -> { "tool_result" -> {
val p = Json.decodeFromString(ResultPayload.serializer(), payload) 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" -> { "error" -> {
val p = Json.decodeFromString(ErrorPayload.serializer(), payload) 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") else -> error("Unknown message kind in audit log: $kind")
} }
@@ -16,8 +16,14 @@ import pw.binom.db.ksqlite.SQLiteConnection
*/ */
object Schema { 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" const val TABLE_CONVERSATION = "conversation"
@@ -34,10 +40,14 @@ object Schema {
const val COL_CONVERSATION_ID = "conversation_id" const val COL_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind" const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json" 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_CONV_UPDATED = "idx_conv_updated"
const val IDX_MSG_CONV = "idx_msg_conv" 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 = """ private val v1ConversationDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION ( 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 ( CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY, $COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL, $COL_CONVERSATION_ID TEXT NOT NULL,
$COL_KIND TEXT NOT NULL, $COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON 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 CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC); ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
-- Главный hot-path индекс для list/сообщений: фильтр по conv + -- Legacy hot-path (по времени): list() по createdAt.
-- сортировка по created_at (используется list(), cascade-clear, etc.)
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT); 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` — безопасно на * - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* уже-мигрированной БД; * уже-мигрированной БД; `ADD COLUMN` гейтится проверкой `PRAGMA table_info`;
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди → * - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* ROLLBACK оставит БД консистентной. * ROLLBACK оставит БД консистентной.
* *
@@ -88,12 +102,31 @@ object Schema {
conn.exec("BEGIN") conn.exec("BEGIN")
try { try {
conn.exec(v1ConversationDdl) conn.exec(v1ConversationDdl)
conn.exec(v1MessageDdl) conn.exec(v2MessageDdl)
conn.exec(v1IndexesDdl) // Старая БД (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") conn.exec("COMMIT")
} catch (t: Throwable) { } catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") } runCatching { conn.exec("ROLLBACK") }
throw t 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
}
} }
@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
* *
* Useful for admin dashboards, debug tools, parent agents: one subscription * Useful for admin dashboards, debug tools, parent agents: one subscription
* instead of N+1. For regular UI use two separate SSE feeds * 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. * [CommonEvent] — for those who need everything in one place.
* *
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`. * Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
@@ -23,12 +23,23 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
sealed interface CommonEvent { sealed interface CommonEvent {
/** Момент эмиссии в UTC. Только для отображения/сортировки — **не** курсор. */
val date: Instant val date: Instant
/**
* Монотонный per-agent offset события — **курсор** (см. [Cursor]).
*
* Присваивается writer'ом через [OffsetSequencer.reserve] в тот же момент,
* что и `seq` соответствующей строки состояния (сначала строка, потом
* событие). Клиенты оперируют [Cursor], а не [date].
*/
val offset: Long
@Serializable @Serializable
@SerialName("agent") @SerialName("agent")
data class Agent( data class Agent(
override val date: Instant, override val date: Instant,
override val offset: Long,
val event: AgentEvent, val event: AgentEvent,
) : CommonEvent ) : CommonEvent
@@ -36,7 +47,8 @@ sealed interface CommonEvent {
@SerialName("conversation") @SerialName("conversation")
data class Conversation( data class Conversation(
override val date: Instant, override val date: Instant,
override val offset: Long,
val conversationId: String, val conversationId: String,
val event: Event, val event: DurableEvent,
) : CommonEvent ) : CommonEvent
} }
@@ -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')
}
}
@@ -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)
}
@@ -30,7 +30,7 @@ import kotlin.time.Instant
* `pw.binom.agentik.outbox.Event`. * `pw.binom.agentik.outbox.Event`.
*/ */
@Serializable @Serializable
sealed interface Event { sealed interface DurableEvent {
/** Момент эмиссии события в UTC. */ /** Момент эмиссии события в UTC. */
val date: Instant val date: Instant
@@ -46,7 +46,7 @@ sealed interface Event {
val id: String, val id: String,
val content: List<Content>, val content: List<Content>,
val context: MessageContext? = null, val context: MessageContext? = null,
) : Event ) : DurableEvent
/** /**
* Целое сообщение ассистента — итог хода. Эмитится при завершении хода, * Целое сообщение ассистента — итог хода. Эмитится при завершении хода,
@@ -63,7 +63,7 @@ sealed interface Event {
val content: List<Content>, val content: List<Content>,
val reasoning: String? = null, val reasoning: String? = null,
val tokens: TurnTokens? = null, val tokens: TurnTokens? = null,
) : Event ) : DurableEvent
/** /**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет. * Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
@@ -78,7 +78,7 @@ sealed interface Event {
val title: String?, val title: String?,
val toolName: String, val toolName: String,
val toolArgs: String, val toolArgs: String,
) : Event ) : DurableEvent
/** /**
* Результат вызова тула. Приходит целиком после завершения исполнения. * Результат вызова тула. Приходит целиком после завершения исполнения.
@@ -101,7 +101,7 @@ sealed interface Event {
val toolCallId: String, val toolCallId: String,
val toolName: String? = null, val toolName: String? = null,
val result: String?, val result: String?,
) : Event ) : DurableEvent
/** /**
* Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется * Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется
@@ -109,7 +109,7 @@ sealed interface Event {
*/ */
@Serializable @Serializable
@SerialName("interrupted") @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 @Serializable
@SerialName("error") @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 не парсятся, * Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
@@ -136,7 +136,7 @@ sealed interface Event {
val toolName: String?, val toolName: String?,
val message: String, val message: String,
val durationMs: Long, val durationMs: Long,
) : Event ) : DurableEvent
/** /**
* Диалог переходит в закрытое состояние ([Conversation.close] / * Диалог переходит в закрытое состояние ([Conversation.close] /
@@ -148,7 +148,7 @@ sealed interface Event {
* *
* Парный `Opening` намеренно отсутствует — симметрия не нужна, * Парный `Opening` намеренно отсутствует — симметрия не нужна,
* так как открытие тривиально (id уже известен с момента * так как открытие тривиально (id уже известен с момента
* `Agent.createConversation` → [Event.ConversationCreated] * `Agent.createConversation` → [DurableEvent.ConversationCreated]
* / [AgentEvent.Created] в outbox'е). * / [AgentEvent.Created] в outbox'е).
*/ */
@Serializable @Serializable
@@ -156,7 +156,7 @@ sealed interface Event {
data class ConversationClosing( data class ConversationClosing(
override val date: Instant, override val date: Instant,
val conversationId: String, val conversationId: String,
) : Event ) : DurableEvent
/** /**
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли. * Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
@@ -173,5 +173,5 @@ sealed interface Event {
override val date: Instant, override val date: Instant,
val conversationId: String, val conversationId: String,
val turnsCompacted: Int, val turnsCompacted: Int,
) : Event ) : DurableEvent
} }
@@ -10,17 +10,21 @@ package pw.binom.agentik.outbox
* персистятся, I/O нет — блокировать продюсера незачем. [tryAppendOnline] * персистятся, I/O нет — блокировать продюсера незачем. [tryAppendOnline]
* не буферизует и не ждёт (см. [OnlineOutbox]): медленный подписчик может * не буферизует и не ждёт (см. [OnlineOutbox]): медленный подписчик может
* потерять дельту, это допустимо. * потерять дельту, это допустимо.
*
* Диалог берётся из самого события ([OnlineEvent.conversationId]) — отдельного
* параметра нет, чтобы не было двух источников истины.
*/ */
interface MutableOnlineOutbox : OnlineOutbox { 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` * Возвращает `true`, если событие принято live-каналом. Возврат `false`
* (нет активных подписчиков / буфер переполнен с DROP-политикой) — * (нет активных подписчиков / буфер переполнен с DROP-политикой) —
* не ошибка: у онлайн-событий нет гарантии доставки. * не ошибка: у онлайн-событий нет гарантии доставки.
*/ */
fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean fun tryAppendOnline(event: OnlineEvent): Boolean
} }
@@ -1,50 +1,40 @@
package pw.binom.agentik.outbox package pw.binom.agentik.outbox
/** /**
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append]. * Mutable вариант [OutboxStore] — добавляет producer-операции [reserveOffset]
* и [append].
* *
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent, * Предназначен **только для producer'ов** (ChatAgent, ConversationLoop,
* sub-agents, A2A-bridge). Consumer'ы (server SSE endpoints, admin * ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only
* dashboards, parent agents) должны принимать **read-only** [OutboxStore] * [OutboxStore] — тогда невозможно случайно писать в store из observer'а.
* — тогда невозможно случайно писать в store из observer'а.
* *
* Типичное использование: * ## Контракт записи (порядок важен)
* ``` * ```
* // Producer * val n = outbox.reserveOffset() // 1. забронировать offset
* class ChatAgent(private val events: MutableEventStore) { * journal.append(record.copy(seq = n)) // 2. сначала состояние
* suspend fun doSomething() { * outbox.append(event.copy(offset = n)) // 3. потом событие
* 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 нет в типе
* }
* ``` * ```
* «Сначала состояние, потом событие» — инвариант, на котором держится
* [OutboxStore.currentCursor]: к моменту, когда событие `n` появилось в
* outbox, строка состояния со `seq = n` уже записана.
* *
* **Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id, * Offset **обязан** быть выставлен в [CommonEvent.offset]; store проверяет
* поэтому retry с тем же logical event (например, после network failure * строгую монотонность и бросает [IllegalArgumentException] на нарушение.
* между 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.
*/ */
interface MutableOutboxStore : OutboxStore { interface MutableOutboxStore : OutboxStore {
/** /**
* Положить event в log. * Забронировать следующий монотонный offset (делегирует в
* [OffsetSequencer.reserve]). Вызывается **до** записи состояния.
*/
suspend fun reserveOffset(): Long
/**
* Положить событие в лог. [CommonEvent.offset] должен быть уже выставлен
* (обычно значением из [reserveOffset]).
* *
* - **Не идемпотентно** — см. KDoc интерфейса. * **Не идемпотентно** — повторный append с тем же offset'ом нарушает
* - **Suspend** для KMP I/O impl'ов (SQLite через JNI). * монотонность и бросит исключение (защита от двойной записи).
*/ */
suspend fun append(event: CommonEvent) suspend fun append(event: CommonEvent)
} }
@@ -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
}
@@ -8,19 +8,19 @@ import kotlin.time.Instant
* **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и * **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и
* стриминг ответа агента (дельты текста/картинок). * стриминг ответа агента (дельты текста/картинок).
* *
* Принципиальное отличие от [Event] (durable): * Принципиальное отличие от [DurableEvent] (durable):
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox], * - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
* ни в journal. Это чистый live-канал. * ни в journal. Это чистый live-канал.
* - **Только онлайн-подписка**: события, эмитнутые до подписки * - **Только онлайн-подписка**: события, эмитнутые до подписки
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются. * (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
* Потерянный фрагмент не страшен — целый результат хода приходит * Потерянный фрагмент не страшен — целый результат хода приходит
* durable-событием ([Event.AssistantMessage]) и/или лежит в журнале. * durable-событием ([DurableEvent.AssistantMessage]) и/или лежит в журнале.
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где * - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
* есть что реплеить. * есть что реплеить.
* *
* Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор, * Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор,
* который, попав в durable store, копится в RAM (standalone-outbox растёт * который, попав в durable store, копится в RAM (standalone-outbox растёт
* unbounded) и засоряет историю. В [Event] остаются только «целые» события, * unbounded) и засоряет историю. В [DurableEvent] остаются только «целые» события,
* пригодные к перезапросу по курсору. * пригодные к перезапросу по курсору.
*/ */
@Serializable @Serializable
@@ -28,6 +28,14 @@ sealed interface OnlineEvent {
/** Момент эмиссии события в UTC (для упорядочивания в рамках стрима). */ /** Момент эмиссии события в UTC (для упорядочивания в рамках стрима). */
val date: Instant val date: Instant
/**
* Id диалога, которому принадлежит событие. Делает событие
* самодостаточным: общий live-поток всех диалогов (`GET /online`)
* разбирается на стороне клиента без внешнего конверта — какое
* событие к какому чату, видно прямо из payload'а.
*/
val conversationId: String
@Serializable @Serializable
enum class ResponseType { enum class ResponseType {
@SerialName("text") TEXT, @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 мог показать спиннер ещё до первого токена ответа. * чтобы UI мог показать спиннер ещё до первого токена ответа.
*/ */
@Serializable @Serializable
@SerialName("working") @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 @Serializable
@SerialName("end") @SerialName("end")
data class End(override val date: Instant) : OnlineEvent data class End(override val date: Instant, override val conversationId: String) : OnlineEvent
/** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */ /** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */
@Serializable @Serializable
@SerialName("start_reasoning") @SerialName("start_reasoning")
data class StartReasoning(override val date: Instant) : OnlineEvent data class StartReasoning(override val date: Instant, override val conversationId: String) : OnlineEvent
/** Начало ответа ассистента заданного типа. Далее идут соответствующие `Append*`. */ /** Начало ответа ассистента заданного типа. Далее идут соответствующие `Append*`. */
@Serializable @Serializable
@SerialName("start_response") @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 @Serializable
@SerialName("append_text") @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 @Serializable
@SerialName("append_image") @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
} }
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
* *
* Это осознанный компромисс: дельты токенов — высокочастотный мусор, * Это осознанный компромисс: дельты токенов — высокочастотный мусор,
* который в durable-сторе копился бы в RAM и засорял историю. Потеря * который в durable-сторе копился бы в RAM и засорял историю. Потеря
* фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage] * фрагмента при обрыве не критична — целый ответ приходит [DurableEvent.AssistantMessage]
* и/или лежит в [pw.binom.agentik.journal.JournalStore]. * и/или лежит в [pw.binom.agentik.journal.JournalStore].
* *
* Read-only view: запись — через [MutableOnlineOutbox]. * Read-only view: запись — через [MutableOnlineOutbox].
@@ -0,0 +1,34 @@
package pw.binom.agentik.outbox
/**
* Курсор клиента вышел за пределы retention'а outbox'а, **или** принадлежит
* другой [Cursor.epoch].
*
* Это **не ошибка выполнения**, а сигнал протокола: «твой курсор мёртв — я не
* могу отдать непрерывный поток событий, начиная с него». Клиент обязан:
* 1. очистить/пометить свой локальный кэш как устаревший;
* 2. запросить у сервера свежий **snapshot состояния** (он вернёт и состояние,
* и актуальный [Cursor]);
* 3. подписаться на события `after = <cursor из снапшота>` и накатить 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."
)
@@ -3,92 +3,84 @@ package pw.binom.agentik.outbox
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlin.time.Instant
/** /**
* Bounded-tail event log с автоматическим управлением TTL. * Bounded-tail лог **durable**-событий агента.
* *
* Хранит **только durable-события [Event]** — «целые» факты хода * Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle
* (Working/End/Interrupted/Error, ToolCall/ToolResult/ToolFailed). * диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и
* Высокочастотный **стриминг ответа** (дельты текста/картинок) сюда * картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only, не
* НЕ попадает — он живёт в [OnlineOutbox] (live-only, не сохраняется). * сохраняется и не реплеится).
* *
* **Архитектура двухуровневого хранилища событий**: * ## Два уровня хранения
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay). * 1. **Этот store** — короткий bounded tail (live SSE + недавний replay),
* События автоматически эвиктятся по TTL/cap (implementation-defined). * эвиктится по TTL/cap (implementation-defined).
* 2. **Message store (`:message-store-api`)** = полный audit log, никогда не * 2. **Журнал (`:journal-api`)** — полный audit log, никогда не эвиктится.
* эвиктится. Source of truth для всего прошлого. * Source of truth для всего прошлого. Он и есть «полное состояние»,
* которое запрашивает клиент при resync'е.
* *
* **Паттерн reconnect** (caller'ы): * ## Курсор, а не дата
* Позиция в потоке — монотонный [Cursor] `(epoch, offset)`, а не wall-clock
* [CommonEvent.date]. Offset уникален и упорядочен даже когда два события
* делят одну миллисекунду. `Instant` для этого не годится (лоссов на ничьих),
* случайный `id` — тоже (не задаёт порядок записи).
*
* ## Протокол клиента (гарантия «в итоге корректное состояние»)
* ``` * ```
* val earliest = store.earliestEventDate() * // 1. Пробуем продолжить с сохранённого курсора.
* if (client.lastSeen < earliest) { * try {
* // gap обнаружен — идём в message store за прошлым * outbox.conversationEvents(after = saved, conversationId = id).collect { apply(it) }
* val gap = messageStore.query(after = client.lastSeen, before = earliest) * } catch (e: OutboxGapException) {
* applyAll(gap) * // 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 полностью на стороне * ## Gap detection
* implementation. Это: * `after != null && after.offset < oldestCursor().offset` (или другой
* - Убирает single source of truth дублирование (caller не может забыть cleanup). * [Cursor.epoch]) → [OutboxGapException] бросается **изнутри** Flow. Проверка
* - Позволяет impl выбирать retention strategy (TTL, size cap, sliding window). * идёт под тем же lock'ом, что и регистрация подписчика (одним критическим
* - Сохраняет контракт clean: интерфейс только о put/get. * участком), поэтому не гоняется с конкурентной эвикцией и не теряет события
* в окне «snapshot → live».
* *
* **Read-only**: этот интерфейс предоставляет только read-операции. * ## Read-only
* Для записи см. [MutableOutboxStore]. * Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore].
* *
* **Подписки нереентрантные**: каждый вызов [events] создаёт **новую * ## Подписки
* подписку** (cold Flow). Один [events] НЕ видит события, добавленные до * Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая
* его вызова, если [after] == null. Если нужен catchup — передавайте * независимая подписка** (cold Flow). `after == null` → только live (события
* `after = lastSeenDate` явно. * с момента вызова). Иные consumer'ы видят тот же live-tail; каждая подписка —
* * своя проекция.
* **Multi-consumer**: разные [events] подписки видят одно и то же live
* tail. Каждая подписка — независимая projection.
*/ */
interface OutboxStore : AutoCloseable { interface OutboxStore : AutoCloseable {
/** /**
* Subscribe на events. * Подписка на события.
* *
* **`after == null`** → только **live** (события с момента вызова * - `after == null` → **только live** (события с момента вызова, replay
* `events()`). Каждое новое событие от любого producer'а немедленно * буфера не отдаётся);
* появится в Flow. Буфер replay не отдаётся. * - `after != null` → сначала **catchup** всех буферизованных событий с
* `offset > after.offset` (по возрастанию offset), затем live.
* *
* **`after != null`** → сначала **catchup**: эмитт все буферизованные * @throws OutboxGapException изнутри Flow, если [after] старше
* события с `date > after`, порядок `date ASC` (ties по `id ASC`). * [oldestCursor] (retention gap) или принадлежит другой эпохе.
* Затем **live** (как null-case).
*
* Cold Flow: каждый вызов — новая подписка. Вызов **после** append'а
* не увидит этот конкретный event (если `after == null`); для catchup
* передавайте явный `after`.
*
* ВАЖНО: `Flow` НЕ бросает ошибку при потере сети между producer и
* store — такие события просто не дойдут до этого Flow. Для гарантии
* полноты клиент обязан cross-check с [earliestEventDate] и fallback
* в message store при gap'е (см. KDoc интерфейса).
*/ */
fun events(after: Instant?): Flow<CommonEvent> fun events(after: Cursor?): Flow<CommonEvent>
/** /**
* Subscribe на **только conversation events** (т.е. [CommonEvent.Conversation]). * Подписка только на conversation-события ([CommonEvent.Conversation]).
* *
* - [conversationId] == null → события **всех** диалогов. * - `conversationId == null` → все диалоги;
* - [conversationId] != null → события **только этого** диалога. * - `conversationId != null` → только этот диалог.
* *
* Семантика `after` идентична [events] (catchup + live). * Семантика [after] и `gap` идентична [events].
* Возвращаемый тип — конкретный subtype [CommonEvent.Conversation].
*/ */
/** fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
* **Default implementation** (читает все events + фильтрует).
*
* Простая реализация через [events] + filterIsInstance. Реализации
* могут override'нуть для эффективности (например, добавить SQL
* `WHERE conversation_id = ?` чтобы не тянуть всё в память), но
* контракт корректен и без override.
*/
fun conversationEvents(after: Instant?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
events(after) events(after)
.filterIsInstance<CommonEvent.Conversation>() .filterIsInstance<CommonEvent.Conversation>()
.let { filtered -> .let { filtered ->
@@ -97,53 +89,34 @@ interface OutboxStore : AutoCloseable {
} }
/** /**
* Subscribe на **только agent events** ([CommonEvent.Agent] — * Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/
* создание/удаление/переименование диалога). * переименование диалога).
* *
* Семантика `after` идентична [events] (catchup + live). * Семантика [after] и `gap` идентична [events].
* Возвращаемый тип — конкретный subtype [CommonEvent.Agent].
*
* Полезно для admin-дашборда, который хочет видеть только lifecycle
* диалогов без деталей ходов.
*/ */
/** fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
* **Default implementation** (читает все events + фильтрует по типу).
*
* Простая реализация через [events] + filterIsInstance. Реализации
* могут override'нуть для эффективности (например, читать только agent
* row'ы из БД), но контракт корректен и без override.
*/
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> =
events(after).filterIsInstance<CommonEvent.Agent>() events(after).filterIsInstance<CommonEvent.Agent>()
/** /**
* 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() override fun close()
} }
@@ -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
}
}
@@ -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()
}
}
@@ -4,7 +4,6 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import pw.binom.agentik.outbox.MutableOnlineOutbox import pw.binom.agentik.outbox.MutableOnlineOutbox
import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineEvent
@@ -19,18 +18,16 @@ import pw.binom.agentik.outbox.OnlineEvent
* **старые дропаются** ([BufferOverflow.DROP_OLDEST]), [appendOnline] не * **старые дропаются** ([BufferOverflow.DROP_OLDEST]), [appendOnline] не
* блокируется. Потеря дельты допустима (см. [OnlineOutbox]). * блокируется. Потеря дельты допустима (см. [OnlineOutbox]).
* *
* **Маршрутизация**: один общий [MutableSharedFlow] c `conversationId` * **Маршрутизация**: один общий [MutableSharedFlow] всех диалогов; каждый
* в envelope; [onlineEvents] фильтрует по диалогу. Отдельный flow-на-диалог * [OnlineEvent] несёт свой `conversationId`, [onlineEvents] фильтрует по нему.
* не держим, чтобы не плодить per-conversation подписки, которые надо * Отдельный flow-на-диалог не держим, чтобы не плодить per-conversation
* чистить вручную. * подписки, которые надо чистить вручную.
*/ */
class InMemoryOnlineOutbox( class InMemoryOnlineOutbox(
liveBufferCapacity: Int = DEFAULT_LIVE_BUFFER_CAPACITY, liveBufferCapacity: Int = DEFAULT_LIVE_BUFFER_CAPACITY,
) : MutableOnlineOutbox { ) : MutableOnlineOutbox {
private data class Envelope(val conversationId: String, val event: OnlineEvent) private val liveFlow = MutableSharedFlow<OnlineEvent>(
private val liveFlow = MutableSharedFlow<Envelope>(
replay = 0, replay = 0,
extraBufferCapacity = liveBufferCapacity, extraBufferCapacity = liveBufferCapacity,
onBufferOverflow = BufferOverflow.DROP_OLDEST, onBufferOverflow = BufferOverflow.DROP_OLDEST,
@@ -42,17 +39,14 @@ class InMemoryOnlineOutbox(
} }
} }
override fun onlineEvents(): Flow<OnlineEvent> = override fun onlineEvents(): Flow<OnlineEvent> = liveFlow
liveFlow.map { it.event }
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> = override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
liveFlow.filter { it.conversationId == conversationId }.map { it.event } liveFlow.filter { it.conversationId == conversationId }
override suspend fun appendOnline(conversationId: String, event: OnlineEvent)= override suspend fun appendOnline(event: OnlineEvent) = liveFlow.emit(event)
liveFlow.emit(Envelope(conversationId, event))
override fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean = override fun tryAppendOnline(event: OnlineEvent): Boolean = liveFlow.tryEmit(event)
liveFlow.tryEmit(Envelope(conversationId, event))
override fun close() { override fun close() {
// replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки. // replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки.
@@ -2,135 +2,144 @@ package pw.binom.agentik.outbox.inmemory
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Instant
import kotlinx.coroutines.channels.BufferOverflow 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.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.outbox.MutableOutboxStore
import pw.binom.agentik.outbox.CommonEvent 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]. * In-memory реализация [MutableOutboxStore] на `ArrayDeque` + [Mutex].
* *
* **Retention policy** — оба параметра **nullable** без default'ов * ## Retention
* (контракт: caller явно решает что ему нужно, не получает "удобные дефолты"): * - [maxMessages] `null` → неограниченно по количеству;
* - [maxMessages] `null` → неограниченно по количеству. * - [ttl] `null` → нет time-based eviction;
* - [ttl] `null` → нет time-based eviction (храним вечно, **пока maxMessages тоже null**). * - **оба `null` → вечное хранилище в RAM**;
* - **Оба `null` → вечное хранилище.** * - любой non-null → граница применяется на каждом [append] (amortized O(1)).
* - Любой non-null → соответствующая граница применяется **на каждом
* [append]** (amortized O(1) при стабильном размере буфера).
* *
* **Concurrency**: [Mutex] защищает append/evict от concurrent writer'ов; * ## Курсор и gap
* reader'ы [events] не блокируются — снимают snapshot под lock'ом, дальше * Offset'ы берутся из [sequencer] ([reserveOffset] = [OffsetSequencer.reserve]).
* итерируют без него. Snapshot под `mutex.withLock` даёт weakly-consistent * [currentCursor] = offset последнего **записанного** события; [oldestCursor] =
* точку обзора: append'ы, попавшие в окно между snapshot и live-collect, * `buffer.first().offset - 1` (или `lastOffset`, если буфер пуст). Подписка
* обрабатываются через **monotonic sequence boundary** (см. [events] KDoc). * `after` старше `oldestCursor` (или с чужой эпохой) бросает [OutboxGapException].
* *
* **Live tail**: [MutableSharedFlow] с DROP_OLDEST policy. Producer никогда * На старте `lastOffset = sequencer.current() - 1`: если счётчик персистентный
* не блокируется — если буфер live-flow переполнен (4096 подписчиков * и равен N, то клиент с курсором N-1 (догнавший состояние до рестарта)
* медленных), старые события дропаются без уведомления. Это OK: каждый * продолжает инкрементально, а клиент с курсором < N-1 получает gap и делает
* subscriber видит **свой** late tail, а за полным покрытием — fallback * resync. Так рестарт сервера не теряет события молча.
* в `:message-store-api`.
* *
* **Threading model**: append происходит из любого dispatcher'а; eviction * ## Concurrency
* — best-effort, синхронный, в том же вызове append (это нормально * Один [Mutex] защищает append/evict/подписки. Регистрация подписчика и снятие
* для in-memory, добавляет O(evicted) работы). * snapshot'а идут **одним критическим участком** — это закрывает окно
* «snapshot → live», в котором append мог потеряться: всё, что попадёт в буфер
* после регистрации, доедет до подписчика через его [Channel]. Snapshot
* итерируется и эмитится вне lock'а.
*
* ## Live-tail
* Каждому подписчику — свой [Channel] с `DROP_OLDEST`: медленный подписчик
* теряет только хвост live-потока и обязан сам сделать resync при обнаружении
* gap'а по retention'у.
*/ */
class InMemoryOutboxStore( class InMemoryOutboxStore(
private val maxMessages: Int?, private val maxMessages: Int?,
private val ttl: Duration?, private val ttl: Duration?,
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
private val sequencer: OffsetSequencer = InMemoryOffsetSequencer(),
) : MutableOutboxStore { ) : MutableOutboxStore {
private val mutex = Mutex() private val mutex = Mutex()
private val buffer = ArrayDeque<CommonEvent>() private val buffer = ArrayDeque<CommonEvent>()
private val liveFlow = MutableSharedFlow<CommonEvent>( private val subscribers = mutableSetOf<SendChannel<CommonEvent>>()
replay = 0,
extraBufferCapacity = LIVE_BUFFER_CAPACITY, /**
onBufferOverflow = BufferOverflow.DROP_OLDEST, * Offset последнего **записанного** события. Инициализируется из счётчика:
) * `current() - 1` (для fresh-счётчика это `-1`).
*/
private var lastOffset: Long = sequencer.current() - 1
init { init {
// Аргументы — НЕ optional default'ы; explicit null = "не применяется".
// Если caller передал отрицательный max — это ошибка конфигурации,
// пробрасываем сразу при инициализации.
require(maxMessages == null || maxMessages > 0) { require(maxMessages == null || maxMessages > 0) {
"maxMessages must be > 0 or null, got $maxMessages" "maxMessages must be > 0 or null, got $maxMessages"
} }
} }
override suspend fun reserveOffset(): Long = sequencer.reserve()
override suspend fun append(event: CommonEvent) { override suspend fun append(event: CommonEvent) {
mutex.withLock { mutex.withLock {
require(event.offset > lastOffset) {
"Non-monotonic offset: got ${event.offset}, last=${lastOffset}"
}
buffer.addLast(event) buffer.addLast(event)
lastOffset = event.offset
subscribers.forEach { it.trySend(event) }
evictLocked()
} }
liveFlow.tryEmit(event)
evictExpired()
evictOverCapacity()
} }
/** private fun evictLocked() {
* Удалить с головы все event'ы старше [ttl]. Amortized O(evicted). val ttlValue = ttl
* Если [ttl] null — no-op. if (ttlValue != null) {
*/
private suspend fun evictExpired() {
val ttlValue = ttl ?: return
val cutoff = clock.now() - ttlValue val cutoff = clock.now() - ttlValue
mutex.withLock {
while (true) { while (true) {
val head = buffer.firstOrNull() ?: return@withLock val head = buffer.firstOrNull() ?: break
if (head.date >= cutoff) return@withLock if (head.date >= cutoff) break
buffer.removeFirst() buffer.removeFirst()
} }
} }
} val cap = maxMessages
if (cap != null) {
/**
* Удалить с головы пока размер > [maxMessages]. Amortized O(evicted).
* Если [maxMessages] null — no-op.
*/
private suspend fun evictOverCapacity() {
val cap = maxMessages ?: return
mutex.withLock {
while (buffer.size > cap) { while (buffer.size > cap) {
if (buffer.isEmpty()) return@withLock
buffer.removeFirst() buffer.removeFirst()
} }
} }
} }
override fun events(after: Instant?): Flow<CommonEvent> = flow { override fun events(after: Cursor?): Flow<CommonEvent> = channelFlow {
// Replay buffer — snapshot под mutex'ом, дальше iterate без lock'а. val channel = Channel<CommonEvent>(
// Append'ы в окне между snapshot и live-collect компенсируются capacity = LIVE_BUFFER_CAPACITY,
// через monotonic sequence boundary: append нумерует события onBufferOverflow = BufferOverflow.DROP_OLDEST,
// последовательно, live-collect фильтрует по last-seen-seq. )
val snapshot: List<CommonEvent> = mutex.withLock { val snapshot: List<CommonEvent> = mutex.withLock {
if (after == null) { val epoch = sequencer.epoch()
buffer.toList() if (after != null) {
} else { val floor = buffer.firstOrNull()?.let { it.offset - 1 } ?: lastOffset
buffer.filter { it.date > after } if (after.epoch != epoch || after.offset < floor || after.offset > lastOffset) {
throw OutboxGapException(
requested = after,
current = Cursor(epoch, lastOffset),
oldest = Cursor(epoch, floor),
)
} }
} }
snapshot.forEach { emit(it) } subscribers += channel
// Live tail — `coroutineScope` гарантирует proper cleanup: когда // `after == null` → live-only (без replay буфера). Чтобы получить
// collector отменяется (take(N)), scope отменяется, liveFlow.collect // весь удержанный хвост, клиент передаёт `after = oldestCursor()`.
// выходит чисто. Без этого — runTest видит "uncompleted coroutine" if (after == null) emptyList() else buffer.filter { it.offset > after.offset }
// и валит тест с UncompletedCoroutinesError. }
coroutineScope { try {
liveFlow.collect { emit(it) } snapshot.forEach { send(it) }
for (event in channel) send(event)
} finally {
mutex.withLock { subscribers -= channel }
channel.close()
} }
} }
override suspend fun earliestEventDate(): Instant { override suspend fun currentCursor(): Cursor = mutex.withLock {
val earliest = mutex.withLock { buffer.firstOrNull()?.date } Cursor(sequencer.epoch(), lastOffset)
// Не nullable: для пустого буфера возвращаем "сейчас" — это позволяет }
// клиенту безопасно подписаться на `events(after = earliest)`.
return earliest ?: clock.now() 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 код не должен ходить напрямую в буфер * `internal` потому что production код не должен ходить напрямую в буфер
* (для этого есть `events(after)`). Доступно только из `commonTest`. * (для этого есть `events(after)`). Доступно только из `commonTest`.
*
* Returns: иммутабельный snapshot (копия). Под `mutex.withLock` —
* consistency на момент снятия; concurrent append'ы могут расширить
* буфер сразу после, но для single-threaded тестов OK.
*/ */
internal suspend fun snapshot(): List<CommonEvent> = mutex.withLock { buffer.toList() } internal suspend fun snapshot(): List<CommonEvent> = mutex.withLock { buffer.toList() }
override fun close() { override fun close() {
// mutex не закрываем (kotlinx Mutex не AutoCloseable; для in-memory
// store GC соберёт всё при выходе ссылки). buffer чистим.
buffer.clear() buffer.clear()
subscribers.clear()
} }
private companion object { private companion object {
// Live-flow capacity — generous default. Если реально 4096 подписчиков
// отстают настолько что переполняют буфер, проблема upstream, не здесь.
private const val LIVE_BUFFER_CAPACITY = 4096 private const val LIVE_BUFFER_CAPACITY = 4096
} }
} }
@@ -2,6 +2,7 @@ package pw.binom.agentik.outbox.inmemory
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration import kotlin.time.Duration
@@ -15,7 +16,9 @@ import kotlinx.coroutines.runBlocking
// (`CommonEvent.Agent` через alias даёт "Unresolved qualified name"). // (`CommonEvent.Agent` через alias даёт "Unresolved qualified name").
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent 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 { class InMemoryOutboxStoreTest {
@@ -24,32 +27,45 @@ class InMemoryOutboxStoreTest {
override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs) override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs)
} }
private fun evtAt(clock: Clock, body: String): CommonEvent = private fun agentEvent(offset: Long, conversationId: String, at: Instant = Instant.fromEpochSeconds(offset)) =
CommonEvent.Agent(date = clock.now(), event = AgentEvent.Created(date = clock.now(), conversationId = body)) 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 @Test
fun `append stores all events when both limits are null store-forever`() = runBlocking { fun `append stores all events when both limits are null store-forever`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
repeat(100) { i -> repeat(100) { i ->
store.append(CommonEvent.Agent( store.append(agentEvent(i.toLong(), "c-$i"))
date = Instant.fromEpochSeconds(i.toLong()),
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
))
} }
assertEquals(100, store.snapshot().size) 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 @Test
fun `maxMessages cap evicts oldest when exceeded`() = runBlocking { fun `maxMessages cap evicts oldest when exceeded`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = 3, ttl = null) val store = InMemoryOutboxStore(maxMessages = 3, ttl = null)
for (i in 1..5) { for (i in 1..5) store.append(agentEvent(i.toLong(), "c-$i"))
store.append(CommonEvent.Agent( assertEquals(listOf("c-3", "c-4", "c-5"), store.ids())
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)
} }
@Test @Test
@@ -57,78 +73,94 @@ class InMemoryOutboxStoreTest {
val clock = FixedClock() val clock = FixedClock()
val store = InMemoryOutboxStore(maxMessages = null, ttl = 100.milliseconds, clock = clock) 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) clock.advance(50.milliseconds)
store.append(evtAt(clock, "middle")) store.append(evtAt(clock, 1, "middle"))
clock.advance(70.milliseconds) 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"), store.ids())
assertEquals(listOf("middle", "fresh"), ids)
} }
@Test @Test
fun `both maxMessages and ttl apply together`() = runBlocking { fun `both maxMessages and ttl apply together`() = runBlocking {
val clock = FixedClock() 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) 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) clock.advance(20.milliseconds)
store.append(evtAt(clock, "b")) store.append(evtAt(clock, 1, "b"))
clock.advance(60.milliseconds) 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"), store.ids())
assertEquals(listOf("b", "c"), ids)
} }
@Test @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) val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
store.append(evtAt(Clock.System, "e1")) store.append(agentEvent(0, "e0"))
store.append(evtAt(Clock.System, "e2")) 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<CommonEvent>() val collected = mutableListOf<CommonEvent>()
val done = CompletableDeferred<Unit>() val done = CompletableDeferred<Unit>()
val job = launch { val job = launch {
store.events(after = null).collect { e -> store.events(after = null).collect { e ->
collected.add(e) collected.add(e)
if (collected.size >= 3) done.complete(Unit) done.complete(Unit)
} }
} }
delay(20) delay(20)
store.append(evtAt(Clock.System, "e3")) store.append(agentEvent(2, "e3"))
done.await() done.await()
job.cancel() job.cancel()
assertEquals(3, collected.size) assertEquals(listOf("e3"), collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId })
} }
@Test @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 store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val t0 = Instant.fromEpochSeconds(0) store.append(agentEvent(0, "e1"))
val t1 = Instant.fromEpochSeconds(10) store.append(agentEvent(1, "e2"))
val t2 = Instant.fromEpochSeconds(20) store.append(agentEvent(2, "e3"))
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")))
val from = Cursor(store.currentCursor().epoch, 0L)
val collected = mutableListOf<CommonEvent>() val collected = mutableListOf<CommonEvent>()
val done = CompletableDeferred<Unit>() val done = CompletableDeferred<Unit>()
val job = launch { val job = launch {
store.events(after = t0).collect { e -> store.events(after = from).collect { e ->
collected.add(e) collected.add(e)
if (collected.size >= 3) done.complete(Unit) if (collected.size >= 3) done.complete(Unit)
} }
} }
delay(20) delay(20)
store.append(CommonEvent.Agent( store.append(agentEvent(3, "e4"))
date = Instant.fromEpochSeconds(30),
event = AgentEvent.Created(date = Instant.fromEpochSeconds(30), conversationId = "e4"),
))
done.await() done.await()
job.cancel() job.cancel()
val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId } val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
@@ -136,40 +168,70 @@ class InMemoryOutboxStoreTest {
} }
@Test @Test
fun `earliestEventDate returns oldest buffered date`() = runBlocking { fun `subscribe from currentCursor receives only newer events - no handoff loss`() = runBlocking {
val clock = FixedClock() val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock) store.append(agentEvent(0, "old"))
store.append(evtAt(clock, "e1"))
clock.advance(100.milliseconds)
store.append(evtAt(clock, "e2"))
assertEquals(Instant.fromEpochMilliseconds(1_000_000_000L), store.earliestEventDate()) val collected = mutableListOf<CommonEvent>()
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 @Test
fun `earliestEventDate returns current time when buffer is empty`() = runBlocking { fun `gap exception when cursor older than oldest`() = runBlocking {
val clock = FixedClock(nowMs = 5_000_000_000L) val store = InMemoryOutboxStore(maxMessages = 2, ttl = null)
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock) store.append(agentEvent(0, "e0"))
assertEquals(Instant.fromEpochMilliseconds(5_000_000_000L), store.earliestEventDate()) store.append(agentEvent(1, "e1"))
store.append(agentEvent(2, "e2"))
val tooOld = Cursor(store.currentCursor().epoch, -1L)
assertFailsWith<OutboxGapException> {
store.events(after = tooOld).collect { }
}
// Ровно на границе — ещё можно.
val atFloor = Cursor(store.currentCursor().epoch, 0L)
val got = mutableListOf<Long>()
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<OutboxGapException> {
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<OutboxGapException> { store.events(tooOld).collect { } }
assertEquals(tooOld, e.requested)
assertEquals(1L, e.current.offset)
assertEquals(0L, e.oldest.offset)
} }
@Test @Test
fun `conversationEvents default impl filters to conversation variant`() = runBlocking { fun `conversationEvents default impl filters to conversation variant`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0) val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Agent( store.append(CommonEvent.Agent(now, 0, AgentEvent.Created(now, "agent-event")))
date = now, store.append(CommonEvent.Conversation(now, 1, "c-1", DurableEvent.Interrupted(now)))
event = AgentEvent.Created(date = now, conversationId = "agent-event"),
))
store.append(CommonEvent.Conversation(
date = now,
conversationId = "c-1",
event = Event.Interrupted(date = 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() val all = store.snapshot()
assertEquals(2, all.size) assertEquals(2, all.size)
assertEquals(1, all.count { it is CommonEvent.Conversation }) assertEquals(1, all.count { it is CommonEvent.Conversation })
@@ -180,11 +242,10 @@ class InMemoryOutboxStoreTest {
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking { fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0) val now = Instant.fromEpochSeconds(0)
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, "c-2", Event.Interrupted(now))) store.append(CommonEvent.Conversation(now, 1, "c-2", DurableEvent.Interrupted(now)))
store.append(CommonEvent.Conversation(now, "c-1", Event.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() val c1 = store.snapshot()
.filterIsInstance<CommonEvent.Conversation>() .filterIsInstance<CommonEvent.Conversation>()
.filter { it.conversationId == "c-1" } .filter { it.conversationId == "c-1" }
@@ -196,23 +257,26 @@ class InMemoryOutboxStoreTest {
fun `agentEvents default impl filters to agent variant`() = runBlocking { fun `agentEvents default impl filters to agent variant`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0) val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Agent( store.append(CommonEvent.Agent(now, 0, AgentEvent.Created(now, "created")))
date = now, store.append(CommonEvent.Conversation(now, 1, "c-1", DurableEvent.Interrupted(now)))
event = AgentEvent.Created(date = now, conversationId = "created"),
))
store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
val all = store.snapshot() val agents = store.snapshot().filterIsInstance<CommonEvent.Agent>()
val agents = all.filterIsInstance<CommonEvent.Agent>()
assertEquals(1, agents.size) assertEquals(1, agents.size)
val created = agents[0].event as AgentEvent.Created assertEquals("created", (agents[0].event as AgentEvent.Created).conversationId)
assertEquals("created", 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<IllegalArgumentException> { store.append(agentEvent(5, "again")) }
assertFailsWith<IllegalArgumentException> { store.append(agentEvent(4, "lower")) }
} }
@Test @Test
fun `close clears buffer`() = runBlocking { fun `close clears buffer`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
store.append(evtAt(Clock.System, "e1")) store.append(agentEvent(0, "e1"))
store.close() store.close()
assertEquals(emptyList(), store.snapshot()) assertEquals(emptyList(), store.snapshot())
} }
+30
View File
@@ -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)
}
}
}
@@ -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()
}
}
@@ -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
}
}
}
@@ -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()
}
}
}
+3 -3
View File
@@ -8,7 +8,7 @@
сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан
повторять `messages[]` каждый раз). повторять `messages[]` каждый раз).
- **declarative история vs. события** — `Message` это то, что уже легло - **declarative история vs. события** — `Message` это то, что уже легло
в БД, `Event` это live-стрим от агента во время `send()` или `events()`. в БД, `DurableEvent` это live-стрим от агента во время `send()` или `events()`.
- **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри - **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри
`:proto`; это контракт. `:proto`; это контракт.
@@ -25,7 +25,7 @@
- `:agentik-cli` — работает поверх `:client`, а следовательно поверх `:proto`. - `:agentik-cli` — работает поверх `:client`, а следовательно поверх `:proto`.
*(`:agentik-tui` был исключён из сборки 2026-09-17.)* *(`:agentik-tui` был исключён из сборки 2026-09-17.)*
- `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает - `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает
`Message`/`Event` напрямую через storage. `Message`/`DurableEvent` напрямую через storage.
## Как подключить ## Как подключить
@@ -62,7 +62,7 @@ target-specific артефакты + общий `kotlinMultiplatform`.
`:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`, `:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`,
а общие типы содержимого и события живут в нижележащих модулях: а общие типы содержимого и события живут в нижележащих модулях:
`Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`, `Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`,
`Event`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`. `DurableEvent`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`.
```kotlin ```kotlin
interface Agent : AutoCloseable { interface Agent : AutoCloseable {
@@ -135,6 +135,27 @@ interface Agent : AutoCloseable {
*/ */
suspend fun renameConversation(id: String, title: String?): Instant? 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 { companion object {
const val PAGE_SIZE: Int = 100 const val PAGE_SIZE: Int = 100
@@ -13,7 +13,7 @@ import kotlin.time.Instant
* *
* **Live-события** диалога НЕ часть этого интерфейса. Их два независимых * **Live-события** диалога НЕ часть этого интерфейса. Их два независимых
* потока: * потока:
* - **durable** ([pw.binom.agentik.outbox.Event]: UserMessage / AssistantMessage / * - **durable** ([pw.binom.agentik.outbox.DurableEvent]: UserMessage / AssistantMessage /
* Interrupted / Error / ToolCall / ToolResult / ToolFailed) — из * Interrupted / Error / ToolCall / ToolResult / ToolFailed) — из
* [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору: * [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору:
* ``` * ```
@@ -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<ConversationRecord>,
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<MessageRecord>,
val cursor: Cursor,
)
@@ -39,6 +39,20 @@ fun Route.journalRoutes(
route(path) { route(path) {
get("/conversations/{id}/messages") { get("/conversations/{id}/messages") {
val id = call.parameters["id"]!! 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 after = call.parseAfter() ?: return@get
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0 val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
@@ -46,11 +60,18 @@ fun Route.journalRoutes(
} }
get("/conversations/{id}/count") { get("/conversations/{id}/count") {
val id = call.parameters["id"]!! val id = call.parameters["id"]!!
val after = call.parseAfter() // Cursor-режим: `?afterSeq=`.
val count = if (after == null) { val afterSeqRaw = call.request.queryParameters["afterSeq"]
journal.count(id) 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 { } 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)) call.respond(CountResponse(count = count))
} }
@@ -1,11 +1,29 @@
package pw.binom.agentik.server 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.Route
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.route import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.outbox.OutboxStore 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 агента). * HTTP-фасад для [OutboxStore] (bounded-tail live event stream агента).
* *
@@ -15,16 +33,20 @@ import pw.binom.agentik.outbox.OutboxStore
* итоговый URL = `{path агента}/outbox/...`. * итоговый URL = `{path агента}/outbox/...`.
* *
* **Endpoint'ы под `{path}/outbox`:** * **Endpoint'ы под `{path}/outbox`:**
* - `GET /events?after=` — SSE (catchup + live) в формате `data: <json>\n\n`, * - `GET /events?epoch=&offset=` — SSE (catchup + live) в формате
* где `<json>` — сериализованный [CommonEvent]. * `data: <json>\n\n`, где `<json>` — сериализованный [CommonEvent].
* Семантика `after` идентична [OutboxStore.events]: * - `epoch`/`offset` отсутствуют → только live (события с момента подписки).
* - `after` отсутствует → только live (события с момента подписки). * - оба заданы → catchup всех буферизованных событий с `offset > offset`,
* - `after` задан → сначала catchup всех буферизованных событий с * затем live. См. [OutboxStore.events].
* `date > after`, потом live.
* *
* **Покрытие:** outbox — это короткий bounded tail с auto-TTL. Для событий * **Gap:** если курсор старше [OutboxStore.oldestCursor] (retention) или
* старше буфера клиент должен идти в `/journal/conversations/{id}/messages` * принадлежит другой эпохе → `410 Gone` c [OutboxGapResponse]. Проверка
* (полный audit log), см. KDoc [OutboxStore]. * делается **до** старта SSE (иначе заголовки уже отправлены), тем же
* snapshot-чтением `oldestCursor()/currentCursor()`; гонка с конкурентной
* эвикцией закрыта внутренним lock'ом store'а на момент подписки.
*
* **Покрытие:** outbox — короткий bounded tail. Для событий старше буфера
* клиент идёт в снапшот (`/snapshot`, `/conversations/{id}/snapshot`).
* *
* **Read-only:** [OutboxStore] не имеет `append` — запись только через * **Read-only:** [OutboxStore] не имеет `append` — запись только через
* writer-референс, который ChatAgent держит внутри (тип `MutableOutboxStore`, * writer-референс, который ChatAgent держит внутри (тип `MutableOutboxStore`,
@@ -36,9 +58,60 @@ fun Route.outboxRoutes(
) { ) {
route(path) { route(path) {
get("/events") { get("/events") {
val after = call.parseAfter() ?: return@get val after = call.parseCursor() ?: return@get
// SSE-стрим: catchup (если `after` != DISTANT_PAST) + live tail. if (!call.requireCursorAlive(outbox, after)) return@get
call.streamJsonSse(outbox.events(after), CommonEvent.serializer()) 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)
}
@@ -24,7 +24,7 @@ import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.content.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent 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 pw.binom.agentik.outbox.OnlineEvent
import kotlin.time.Instant import kotlin.time.Instant
@@ -161,11 +161,15 @@ internal fun Route.agentikRoutes(agent: Agent) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return@get return@get
} }
val after = call.parseAfter() ?: return@get val after = call.parseCursor() ?: return@get
if (!call.requireCursorAlive(agent.outbox, after)) return@get
// Live-источник событий — `OutboxStore` (единая точка истины); // Live-источник событий — `OutboxStore` (единая точка истины);
// разворачиваем `CommonEvent.Conversation` → `Event` для совместимости // разворачиваем `CommonEvent.Conversation` → `Event` для совместимости
// wire-формата (клиент десериализует как `Event`, не как `CommonEvent.Conversation`). // 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") { 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<CommonEvent.Agent>; // agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
// распаковываем .event для обратной совместимости с прежним // распаковываем .event для обратной совместимости с прежним
// форматом (когда был Agent.events(): Flow<AgentEvent>). // форматом (когда был Agent.events(): Flow<AgentEvent>).
call.streamJsonSse( call.streamJsonSse(
agent.outbox.agentEvents(after).map { it.event }, agent.outbox.agentEvents(after),
AgentEvent.serializer(), CommonEvent.Agent.serializer(),
) )
} }
@@ -215,9 +220,30 @@ internal fun Route.agentikRoutes(agent: Agent) {
* Для UI достаточно `/events` + `/conversations/{id}/events`. * Для UI достаточно `/events` + `/conversations/{id}/events`.
*/ */
get("/events/all") { 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()) 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 ---------- // ---------- helpers ----------
@@ -24,6 +24,9 @@ import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent 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.AgentInfo
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import kotlin.test.AfterTest import kotlin.test.AfterTest
@@ -63,16 +66,19 @@ class AgentInfoRouteTest {
) )
override val journal: JournalStore = object : JournalStore { override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>() override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L 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 suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>() override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {} override fun close() {}
} }
override val outbox: OutboxStore = object : OutboxStore { override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>() override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>() override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
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 fun close() {}
} }
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -81,6 +87,11 @@ class AgentInfoRouteTest {
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>() override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {} 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 fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false override suspend fun deleteConversation(id: String): Boolean = false
@@ -133,16 +144,19 @@ class AgentInfoRouteTest {
override val info: AgentInfo = AgentInfo(name = "agentik") override val info: AgentInfo = AgentInfo(name = "agentik")
override val journal: JournalStore = object : JournalStore { override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>() override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L 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 suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>() override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {} override fun close() {}
} }
override val outbox: OutboxStore = object : OutboxStore { override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>() override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>() override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
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 fun close() {}
} }
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -151,6 +165,11 @@ class AgentInfoRouteTest {
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>() override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {} 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 fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false override suspend fun deleteConversation(id: String): Boolean = false
@@ -20,6 +20,9 @@ import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent 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.AgentInfo
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant import kotlin.time.Instant
@@ -47,16 +50,19 @@ class BearerTokenTest {
) : Agent { ) : Agent {
override val journal: JournalStore = object : JournalStore { override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>() override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L 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 suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>() override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {} override fun close() {}
} }
override val outbox: OutboxStore = object : OutboxStore { override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>() override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>() override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
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 fun close() {}
} }
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -65,6 +71,11 @@ class BearerTokenTest {
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>() override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {} 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 fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
override suspend fun getConversation(id: String): Conversation? = null override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false override suspend fun deleteConversation(id: String): Boolean = false
@@ -23,6 +23,9 @@ import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent 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.AgentInfo
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
@@ -161,17 +164,27 @@ class ConversationRoutesTest {
override val info: AgentInfo = AgentInfo(name = "test") override val info: AgentInfo = AgentInfo(name = "test")
override val journal: JournalStore = js override val journal: JournalStore = js
override val outbox: OutboxStore = object : OutboxStore { override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>() override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>() override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = override fun conversationEvents(after: Cursor?, conversationId: String?) =
emptyFlow<CommonEvent.Conversation>() emptyFlow<CommonEvent.Conversation>()
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 fun close() {}
} }
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
override val conversationStore: ConversationStore = cs 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 = override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
TODO("not used") TODO("not used")
@@ -27,6 +27,9 @@ import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent 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.AgentInfo
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
@@ -182,10 +185,11 @@ class JournalRoutesCountTest {
override val info: AgentInfo = AgentInfo(name = "test") override val info: AgentInfo = AgentInfo(name = "test")
override val journal: JournalStore = js override val journal: JournalStore = js
override val outbox: OutboxStore = object : OutboxStore { override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>() override fun events(after: Cursor?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>() override fun agentEvents(after: Cursor?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>() override fun conversationEvents(after: Cursor?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
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 fun close() {}
} }
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox() override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
@@ -195,6 +199,15 @@ class JournalRoutesCountTest {
override fun close() {} 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 = override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
TODO("not used") TODO("not used")
override suspend fun getConversation(id: String): pw.binom.agentik.proto.Conversation? = null override suspend fun getConversation(id: String): pw.binom.agentik.proto.Conversation? = null
@@ -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<CommonEvent> = emptyList(),
) : OutboxStore {
override fun events(after: Cursor?): Flow<CommonEvent> = flowOf(*replay.toTypedArray())
override fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> = emptyFlow()
override fun conversationEvents(after: Cursor?, conversationId: String?): Flow<CommonEvent.Conversation> =
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<MessageRecord>()
override suspend fun list(conversationId: String, afterSeq: Long, upToSeq: Long, limit: Int) =
emptyList<MessageRecord>()
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")
}
}
+4
View File
@@ -114,6 +114,10 @@ include(":context-ksqlite")
// ConversationStore / conversation table). Минимальный модуль: // ConversationStore / conversation table). Минимальный модуль:
// таблицы `message` + `conversation` + индексы. // таблицы `message` + `conversation` + индексы.
include(":journal-ksqlite") include(":journal-ksqlite")
// ksqlite-реализация :outbox-api (CursorStore / outbox_cursor table).
// Персистентная позиция счётчика событий агента — переживает рестарт, чтобы
// клиент продолжал инкрементально (см. PersistentOffsetSequencer).
include(":outbox-ksqlite")
// ksqlite-реализация :reflection-api (ReflectionStore / reflection table). // ksqlite-реализация :reflection-api (ReflectionStore / reflection table).
// Минимальный модуль: только таблица `reflection` + 2 индекса. // Минимальный модуль: только таблица `reflection` + 2 индекса.
include(":reflection-ksqlite") include(":reflection-ksqlite")
@@ -3,7 +3,6 @@ package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex 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.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.skills.renderSystemPromptSection import pw.binom.agentik.skills.renderSystemPromptSection
@@ -25,8 +23,8 @@ import pw.binom.litert.LiteTool
* События, по которым SkillMiningComponent решает, что пора майнить новые скилы. * События, по которым SkillMiningComponent решает, что пора майнить новые скилы.
* *
* Standalone-часть мэпит свой [pw.binom.agentik.outbox.OutboxStore] (через * Standalone-часть мэпит свой [pw.binom.agentik.outbox.OutboxStore] (через
* [pw.binom.agentik.outbox.Event.ConversationClosing] и * [pw.binom.agentik.outbox.DurableEvent.ConversationClosing] и
* [pw.binom.agentik.outbox.Event.CompactionTriggered]) на этот sealed * [pw.binom.agentik.outbox.DurableEvent.CompactionTriggered]) на этот sealed
* interface и подаёт результат в [SkillMiningComponent.events]. Делаем так, * interface и подаёт результат в [SkillMiningComponent.events]. Делаем так,
* чтобы модуль :skill-mining не зависел от :standalone и его внутренних типов. * чтобы модуль :skill-mining не зависел от :standalone и его внутренних типов.
*/ */
+3
View File
@@ -74,6 +74,9 @@ kotlin {
// Bounded-tail live event stream + per-event TTL. // Bounded-tail live event stream + per-event TTL.
implementation(project(":outbox-inmemory")) implementation(project(":outbox-inmemory"))
// Персистентный счётчик событий (CursorStore) поверх ksqlite —
// offset'ы переживают рестарт, клиент продолжает инкрементально.
implementation(project(":outbox-ksqlite"))
// :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider. // :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider.
// ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.) // ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.)
@@ -10,7 +10,7 @@ import pw.binom.a2a.model.Message
import pw.binom.a2a.model.Role import pw.binom.a2a.model.Role
import pw.binom.a2a.model.TextPart import pw.binom.a2a.model.TextPart
import pw.binom.a2a.server.AgentHandler 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.outbox.OnlineEvent
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.content.Content import pw.binom.agentik.content.Content
@@ -31,7 +31,7 @@ private val log = KotlinLogging.logger {}
* Ответ A2A = склеенные [OnlineEvent.AppendText] нашего хода. Подписку на онлайн-поток * Ответ A2A = склеенные [OnlineEvent.AppendText] нашего хода. Подписку на онлайн-поток
* ([pw.binom.agentik.outbox.OnlineOutbox]) открываем ДО [Conversation.send] (live-only, * ([pw.binom.agentik.outbox.OnlineOutbox]) открываем ДО [Conversation.send] (live-only,
* без catchup — события начала хода иначе можно упустить), завершение хода ждём * без catchup — события начала хода иначе можно упустить), завершение хода ждём
* по онлайн [OnlineEvent.End] и durable [Event.AssistantMessage] / [Event.Interrupted] / [Event.Error]. * по онлайн [OnlineEvent.End] и durable [DurableEvent.AssistantMessage] / [DurableEvent.Interrupted] / [DurableEvent.Error].
* *
* Ограничение v1: tool-события и картинки в A2A-ответ не транслируются; * Ограничение v1: tool-события и картинки в A2A-ответ не транслируются;
* при нескольких ходов в очереди за контекстом текст предыдущего хода * при нескольких ходов в очереди за контекстом текст предыдущего хода
@@ -47,7 +47,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
.joinToString("\n") { it.text } .joinToString("\n") { it.text }
val conv = resolveConversation(contextId) val conv = resolveConversation(contextId)
val since = conv.updatedAt // Курсор старта: снапшот не нужен, достаточно текущей позиции лога.
val since = agent.outbox.currentCursor()
val reply = StringBuilder() val reply = StringBuilder()
val turnDone = CompletableDeferred<Unit>() val turnDone = CompletableDeferred<Unit>()
// Онлайн-поток — дельты ответа (live-only, без catchup). // Онлайн-поток — дельты ответа (live-only, без catchup).
@@ -66,8 +67,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
val turnJob = async { val turnJob = async {
agent.outbox.conversationEvents(since, conv.id).collect { ce -> agent.outbox.conversationEvents(since, conv.id).collect { ce ->
when (val e = ce.event) { when (val e = ce.event) {
is Event.AssistantMessage, is Event.Interrupted -> turnDone.complete(Unit) is DurableEvent.AssistantMessage, is DurableEvent.Interrupted -> turnDone.complete(Unit)
is Event.Error -> is DurableEvent.Error ->
turnDone.completeExceptionally( turnDone.completeExceptionally(
IllegalStateException("agent turn failed: ${e.message}") IllegalStateException("agent turn failed: ${e.message}")
) )
@@ -392,6 +392,9 @@ private fun runServer() {
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
recentReflections = recentReflections, recentReflections = recentReflections,
reflector = reflector, reflector = reflector,
// Персистентный счётчик событий: offset/epoch переживают рестарт,
// клиенты продолжают инкрементально, а не делают полный resync.
outboxSequencer = sqliteStores.outboxSequencer,
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry)) ).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а, // Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
@@ -5,9 +5,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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.memory.MemorySystemGuidance
import pw.binom.agentik.proto.Agent as ProtoAgent import pw.binom.agentik.proto.Agent as ProtoAgent
import pw.binom.agentik.proto.AgentInfo 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.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.CommonEvent
import pw.binom.agentik.outbox.MutableOutboxStore import pw.binom.agentik.outbox.MutableOutboxStore
import pw.binom.agentik.outbox.MutableOnlineOutbox 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.outbox.OnlineOutbox
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore 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.reflection.ReflectionStore
import pw.binom.agentik.journal.MutableJournalStore import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.context.ContextStore 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.litert.LiteTool
import pw.binom.agentik.toolsets.SystemPromptToolsetSection
import pw.binom.agentik.toolsets.ToolsetComponent import pw.binom.agentik.toolsets.ToolsetComponent
import pw.binom.agentik.toolsets.ToolsetContribution import pw.binom.agentik.toolsets.ToolsetContribution
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
@@ -147,6 +147,14 @@ internal class ChatAgent(
* секция в system prompt НЕ добавляется (полная невидимость per A1-α). * секция в system prompt НЕ добавляется (полная невидимость per A1-α).
*/ */
private val toolsets: List<ToolsetContribution> = emptyList(), private val toolsets: List<ToolsetContribution> = emptyList(),
/**
* Счётчик событий агента. По умолчанию in-memory: offset'ы начинаются с 0
* при каждом старте процесса. Production передаёт
* [pw.binom.agentik.outbox.PersistentOffsetSequencer] поверх ksqlite
* (`SqliteStores.outboxSequencer`), чтобы `offset`/`epoch` переживали
* рестарт и клиент продолжал инкрементально (см. [Cursor]).
*/
private val outboxSequencer: OffsetSequencer = InMemoryOffsetSequencer(),
) : MutableAgent, AutoCloseable { ) : MutableAgent, AutoCloseable {
/** /**
@@ -213,8 +221,16 @@ private val testTools: MutableList<LiteTool> = mutableListOf()
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore( private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
maxMessages = null, maxMessages = null,
ttl = null, ttl = null,
sequencer = outboxSequencer,
) )
/**
* Сериализатор durable-записей: reserve offset → state → event (см. [DurableLog]).
* Делит [eventStore] со всеми беседами агента, поэтому offset'ы сквозные
* по агенту (и по всем беседам сразу).
*/
private val durableLog: DurableLog = DurableLog(eventStore)
/** /**
* Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры). * Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры).
* Онлайн-события никогда не сохраняются и не реплеятся — см. [OnlineOutbox]. * Онлайн-события никогда не сохраняются и не реплеятся — см. [OnlineOutbox].
@@ -473,7 +489,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
messageStore = messageStore, messageStore = messageStore,
workingMemoryStore = workingMemoryStore, workingMemoryStore = workingMemoryStore,
reflectionStore = reflectionStore, reflectionStore = reflectionStore,
eventStore = eventStore, durableLog = durableLog,
onlineEventStore = onlineEventStore, onlineEventStore = onlineEventStore,
llm = llm, llm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
@@ -497,12 +513,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
// фоновые задачи. // фоновые задачи.
attachConversation(conv.asHandle()) attachConversation(conv.asHandle())
runBlocking { runBlocking {
eventStore.append( durableLog.appendAgent(AgentEvent.Created(date = now(), conversationId = conv.id))
CommonEvent.Agent(
date = now(),
event = AgentEvent.Created(date = now(), conversationId = conv.id),
)
)
} }
return conv return conv
} }
@@ -527,26 +538,60 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
// отдельный store должен знать только про свою таблицу. // отдельный store должен знать только про свою таблицу.
messageStore.clear(id) messageStore.clear(id)
workingMemoryStore.clear(id) workingMemoryStore.clear(id)
val event = AgentEvent.Deleted(date = now(), id = id) durableLog.appendAgent(AgentEvent.Deleted(date = now(), id = id))
eventStore.append(CommonEvent.Agent(date = now(), event = event))
} }
return ok return ok
} }
override suspend fun renameConversation(id: String, title: String?): Instant? { override suspend fun renameConversation(id: String, title: String?): Instant? {
val newUpdatedAt = mutableConversationStore.rename(id, title) ?: return null val newUpdatedAt = mutableConversationStore.rename(id, title) ?: return null
val event = AgentEvent.Renamed(date = newUpdatedAt, id = id, title = title) durableLog.appendAgent(AgentEvent.Renamed(date = newUpdatedAt, id = id, title = title))
eventStore.append(CommonEvent.Agent(date = newUpdatedAt, event = event))
return newUpdatedAt return newUpdatedAt
} }
/**
* Cursor-first снапшот списка диалогов (см. [ProtoAgent.conversationsSnapshot]).
* Курсор читается до состояния: изменения во время чтения получат offset
* `> cursor` и приедут потоком.
*/
override suspend fun conversationsSnapshot(): ConversationsSnapshot {
val cursor = durableLog.currentCursor()
val out = ArrayList<ConversationRecord>()
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<MessageRecord>()
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( private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
record = rec, record = rec,
conversationStore = mutableConversationStore, conversationStore = mutableConversationStore,
messageStore = messageStore, messageStore = messageStore,
workingMemoryStore = workingMemoryStore, workingMemoryStore = workingMemoryStore,
reflectionStore = reflectionStore, reflectionStore = reflectionStore,
eventStore = eventStore, durableLog = durableLog,
onlineEventStore = onlineEventStore, onlineEventStore = onlineEventStore,
llm = llm, llm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
@@ -68,7 +68,7 @@ internal class CompactionCoordinator(
} }
if (toCompact.isEmpty()) return false 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 -> val turns = toCompact.mapNotNull { row ->
when (val e = row.entry) { when (val e = row.entry) {
@@ -1,44 +1,55 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import pw.binom.agentik.outbox.CommonEvent import kotlinx.coroutines.runBlocking
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.outbox.DurableEvent
import pw.binom.agentik.outbox.MutableOnlineOutbox import pw.binom.agentik.outbox.MutableOnlineOutbox
import pw.binom.agentik.outbox.MutableOutboxStore
import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineEvent
/** /**
* Фасад эмиссии и чтения событий одного диалога. Разводит два канала: * Фасад эмиссии и чтения событий одного диалога. Разводит два канала:
* - durable ([Event]) → [globalEventStore] (`:outbox`), с catchup по `after`; * - durable ([DurableEvent]) → [DurableLog] (`:outbox`), с catchup по [Cursor];
* - online ([OnlineEvent]) → [onlineStore], live-only (без catchup). * - online ([OnlineEvent]) → [onlineStore], live-only (без catchup).
*
* Все durable-эмиссии идут через [DurableLog], чтобы state-row и парное
* событие получали один offset (см. KDoc [DurableLog]).
*/ */
internal class ConversationEvents( internal class ConversationEvents(
private val globalEventStore: MutableOutboxStore, private val durableLog: DurableLog,
private val onlineStore: MutableOnlineOutbox, private val onlineStore: MutableOnlineOutbox,
private val conversationId: String, private val conversationId: String,
) { ) {
fun tryEmit(event: Event): Boolean { /**
runBlocking { * Атомарная durable-запись: сначала [writeState] (journal/...) с
globalEventStore.append( * забронированным `seq`, затем парное событие [event] с тем же offset.
CommonEvent.Conversation( */
date = event.date, suspend fun <T> commit(writeState: suspend (seq: Long) -> T, event: (seq: Long) -> DurableEvent): T =
conversationId = conversationId, durableLog.commit(conversationId = conversationId, writeState = writeState, event = event)
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 return true
} }
fun events(after: kotlin.time.Instant?): Flow<Event> = fun events(after: Cursor?): Flow<DurableEvent> =
globalEventStore.conversationEvents(after = after, conversationId = conversationId) durableLog.outbox.conversationEvents(after = after, conversationId = conversationId)
.map { it.event } .map { it.event }
/** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */ /** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */
fun tryEmitOnline(event: OnlineEvent): Boolean = fun tryEmitOnline(event: OnlineEvent): Boolean =
onlineStore.tryAppendOnline(conversationId, event) onlineStore.tryAppendOnline(event)
/** Live-поток онлайн-событий диалога (без catchup — см. KDoc [pw.binom.agentik.outbox.OnlineOutbox]). */ /** Live-поток онлайн-событий диалога (без catchup — см. KDoc [pw.binom.agentik.outbox.OnlineOutbox]). */
fun onlineEvents(): Flow<OnlineEvent> = onlineStore.onlineEvents(conversationId) fun onlineEvents(): Flow<OnlineEvent> = onlineStore.onlineEvents(conversationId)
@@ -11,17 +11,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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 mu.KotlinLogging
import pw.binom.agentik.memory.MemoryPrefetcher import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.content.Content as ProtoContent import pw.binom.agentik.content.Content as ProtoContent
import pw.binom.agentik.proto.Conversation as ProtoConversation 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.OnlineEvent
import pw.binom.agentik.outbox.MutableOnlineOutbox import pw.binom.agentik.outbox.MutableOnlineOutbox
import pw.binom.agentik.reflection.ReflectionStore import pw.binom.agentik.reflection.ReflectionStore
@@ -54,7 +50,7 @@ class ConversationLoop(
private val messageStore: MutableJournalStore, private val messageStore: MutableJournalStore,
private val workingMemoryStore: ContextStore, private val workingMemoryStore: ContextStore,
private val reflectionStore: ReflectionStore?, private val reflectionStore: ReflectionStore?,
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore, private val durableLog: DurableLog,
/** /**
* Live-канал онлайн-событий (дельты ответа). Не сохраняется; подписка * Live-канал онлайн-событий (дельты ответа). Не сохраняется; подписка
* возможна только «онлайн». Durable-события по-прежнему в [eventStore]. * возможна только «онлайн». Durable-события по-прежнему в [eventStore].
@@ -108,7 +104,7 @@ class ConversationLoop(
) )
private val events = ConversationEvents( private val events = ConversationEvents(
globalEventStore = eventStore, durableLog = durableLog,
onlineStore = onlineEventStore, onlineStore = onlineEventStore,
conversationId = state.id, conversationId = state.id,
) )
@@ -148,7 +144,7 @@ class ConversationLoop(
reflector = reflector, reflector = reflector,
reflectionStore = reflectionStore, reflectionStore = reflectionStore,
), ),
eventStore = eventStore, eventStore = durableLog.events,
conversationIdProvider = { id }, conversationIdProvider = { id },
).also { it.start(agentScope) } ).also { it.start(agentScope) }
@@ -183,7 +179,7 @@ class ConversationLoop(
// увидел «агент работает» ещё до turnLock.withLock { launch } и до // увидел «агент работает» ещё до turnLock.withLock { launch } и до
// первого токена от LLM. Working/End — онлайн-маркеры (live-only), // первого токена от LLM. Working/End — онлайн-маркеры (live-only),
// терминатор durable-части — AssistantMessage/Interrupted/Error. // терминатор durable-части — AssistantMessage/Interrupted/Error.
emitOnline(OnlineEvent.Working(date = turnStarted)) emitOnline(OnlineEvent.Working(date = turnStarted, conversationId = id))
val userMessageId = newId("msg") val userMessageId = newId("msg")
val storageContext = context?.toStorage() val storageContext = context?.toStorage()
@@ -196,7 +192,11 @@ class ConversationLoop(
) )
if (!state.isTemporal) { if (!state.isTemporal) {
messageStore.append(userRecord) // State-first: journal.append + workingMemory под одним offset'ом,
// затем парное durable-событие (см. DurableLog).
events.commit(
writeState = { seq ->
messageStore.append(userRecord.copy(seq = seq))
workingMemoryStore.append( workingMemoryStore.append(
conversationId = id, conversationId = id,
entry = WorkingMemoryEntry.User( entry = WorkingMemoryEntry.User(
@@ -206,15 +206,15 @@ class ConversationLoop(
), ),
now = turnStarted, now = turnStarted,
) )
// Durable-событие user-сообщения: позволяет восстановить историю },
// по курсору outbox без отдельного запроса в journal. event = {
emitEvent(
ProtoEvent.UserMessage( ProtoEvent.UserMessage(
date = turnStarted, date = turnStarted,
id = userMessageId, id = userMessageId,
content = userRecord.content, content = userRecord.content,
context = storageContext, context = storageContext,
) )
},
) )
} }
@@ -262,7 +262,7 @@ class ConversationLoop(
// ловит это и делает final reflection (last chance вытащить insights). // ловит это и делает final reflection (last chance вытащить insights).
// После cancel() подписка умерла бы. // После cancel() подписка умерла бы.
runCatching { 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() } } state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
runCatching { runBlocking { activeTurn?.cancelAndJoin() } } runCatching { runBlocking { activeTurn?.cancelAndJoin() } }
@@ -277,8 +277,14 @@ class ConversationLoop(
compactor.compactPreTurnIfNeeded() compactor.compactPreTurnIfNeeded()
} }
emitOnline(OnlineEvent.StartReasoning(date = turnStarted)) emitOnline(OnlineEvent.StartReasoning(date = turnStarted, conversationId = id))
emitOnline(OnlineEvent.StartResponse(date = now(), responseType = OnlineEvent.ResponseType.TEXT)) emitOnline(
OnlineEvent.StartResponse(
date = now(),
conversationId = id,
responseType = OnlineEvent.ResponseType.TEXT,
)
)
val parts = userRecord.content.mapNotNull { c -> val parts = userRecord.content.mapNotNull { c ->
when (c) { when (c) {
@@ -345,7 +351,7 @@ class ConversationLoop(
lc.sendStreamContents(pendingParts).collect { delta -> lc.sendStreamContents(pendingParts).collect { delta ->
if (delta.text.isNotEmpty()) { if (delta.text.isNotEmpty()) {
reply.append(delta.text) 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()) { if (delta.toolCalls.isNotEmpty()) {
collectedCalls.addAll(delta.toolCalls) collectedCalls.addAll(delta.toolCalls)
@@ -383,7 +389,7 @@ class ConversationLoop(
} }
if (delta.text.isNotEmpty()) { if (delta.text.isNotEmpty()) {
reply.append(delta.text) 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()) { if (delta.toolCalls.isNotEmpty()) {
nextCalls.addAll(delta.toolCalls) nextCalls.addAll(delta.toolCalls)
@@ -400,7 +406,7 @@ class ConversationLoop(
lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp -> lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
if (followUp.text.isNotEmpty()) { if (followUp.text.isNotEmpty()) {
reply.append(followUp.text) 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()) { if (followUp.toolCalls.isNotEmpty()) {
collectedPostTool.addAll(followUp.toolCalls) collectedPostTool.addAll(followUp.toolCalls)
@@ -456,10 +462,11 @@ class ConversationLoop(
createdAt = assistantAt, createdAt = assistantAt,
tokens = turnTokens, tokens = turnTokens,
) )
messageStore.append(assistantRecord) // State-first: journal.append под забронированным offset'ом,
// затем парное durable-событие.
// Durable-событие готового ответа агента. events.commit(
emitEvent( writeState = { seq -> messageStore.append(assistantRecord.copy(seq = seq)) },
event = {
ProtoEvent.AssistantMessage( ProtoEvent.AssistantMessage(
date = assistantAt, date = assistantAt,
id = assistantId, id = assistantId,
@@ -467,6 +474,7 @@ class ConversationLoop(
reasoning = null, reasoning = null,
tokens = turnTokens, tokens = turnTokens,
) )
},
) )
workingMemoryStore.append( workingMemoryStore.append(
@@ -489,14 +497,11 @@ class ConversationLoop(
state.record = state.record.copy(updatedAt = assistantAt) state.record = state.record.copy(updatedAt = assistantAt)
conversationStore.touch(id, assistantAt) conversationStore.touch(id, assistantAt)
if (!state.isTemporal) { if (!state.isTemporal) {
eventStore.append( durableLog.appendAgent(
pw.binom.agentik.outbox.CommonEvent.Agent( pw.binom.agentik.outbox.AgentEvent.Touched(
date = assistantAt,
event = pw.binom.agentik.outbox.AgentEvent.Touched(
date = assistantAt, date = assistantAt,
id = id, id = id,
updatedAt = assistantAt, updatedAt = assistantAt,
),
) )
) )
} }
@@ -510,14 +515,14 @@ class ConversationLoop(
if (wasInterrupted || interrupted.get()) { if (wasInterrupted || interrupted.get()) {
emitEvent(ProtoEvent.Interrupted(date = now())) emitEvent(ProtoEvent.Interrupted(date = now()))
} }
emitOnline(OnlineEvent.End(date = now())) emitOnline(OnlineEvent.End(date = now(), conversationId = id))
interrupted.set(false) interrupted.set(false)
} }
} }
private fun emitEvent(event: ProtoEvent) { private suspend fun emitEvent(event: ProtoEvent) {
events.tryEmit(event) events.emit(event)
} }
private fun emitOnline(event: OnlineEvent) { private fun emitOnline(event: OnlineEvent) {
@@ -527,6 +532,8 @@ class ConversationLoop(
private suspend fun failTurn(message: String, code: String? = null) { private suspend fun failTurn(message: String, code: String? = null) {
val ts = now() val ts = now()
if (!state.isTemporal) { if (!state.isTemporal) {
events.commit(
writeState = { seq ->
messageStore.append( messageStore.append(
MessageRecord.Error( MessageRecord.Error(
id = newId("err"), id = newId("err"),
@@ -534,11 +541,16 @@ class ConversationLoop(
message = message, message = message,
code = code, code = code,
createdAt = ts, 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 = private fun now(): Instant =
Instant.fromEpochMilliseconds(System.currentTimeMillis()) Instant.fromEpochMilliseconds(System.currentTimeMillis())
@@ -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 <T> 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
}
}
@@ -15,7 +15,7 @@ import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.ContextStore import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.llm.tools.LlmReflector import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.outbox.CommonEvent 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 pw.binom.agentik.outbox.OutboxStore
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@@ -36,7 +36,7 @@ import java.util.concurrent.atomic.AtomicLong
* Подписка идёт через outbox (а не через per-conversation BackgroundEventBus * Подписка идёт через outbox (а не через per-conversation BackgroundEventBus
* который был раньше): outbox — единый канал для всех событий (как клиентских, * который был раньше): outbox — единый канал для всех событий (как клиентских,
* так и внутренних), persistent tail с TTL работает из коробки, а клиенты * так и внутренних), persistent tail с TTL работает из коробки, а клиенты
* по тому же потоку могут самостоятельно видеть/логировать [Event.ToolFailed] * по тому же потоку могут самостоятельно видеть/логировать [DurableEvent.ToolFailed]
* без скрытой телеметрии. * без скрытой телеметрии.
*/ */
internal data class ReflectionConfig( internal data class ReflectionConfig(
@@ -68,11 +68,11 @@ internal class ReflectionScheduler(
merge( merge(
eventStore.events(after = null) eventStore.events(after = null)
.filterIsInstance<CommonEvent.Conversation>() .filterIsInstance<CommonEvent.Conversation>()
.filter { it.event is Event.ConversationClosing && it.conversationId == conversationIdProvider() } .filter { it.event is DurableEvent.ConversationClosing && it.conversationId == conversationIdProvider() }
.onEach { onClosing() }, .onEach { onClosing() },
eventStore.events(after = null) eventStore.events(after = null)
.filterIsInstance<CommonEvent.Conversation>() .filterIsInstance<CommonEvent.Conversation>()
.filter { it.event is Event.ToolFailed && it.conversationId == conversationIdProvider() } .filter { it.event is DurableEvent.ToolFailed && it.conversationId == conversationIdProvider() }
.onEach { onToolFailure() }, .onEach { onToolFailure() },
).collect {} ).collect {}
} }
@@ -4,7 +4,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import mu.KotlinLogging 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.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
@@ -37,9 +37,10 @@ internal class ToolDispatcher(
val nowTs = now() val nowTs = now()
val startMs = System.currentTimeMillis() val startMs = System.currentTimeMillis()
events.tryEmit(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson))
if (!state.isTemporal) { if (!state.isTemporal) {
// State-first: journal-запись и парное событие под одним offset'ом.
events.commit(
writeState = { seq ->
messageStore.append( messageStore.append(
MessageRecord.ToolCall( MessageRecord.ToolCall(
id = callId, id = callId,
@@ -48,8 +49,16 @@ internal class ToolDispatcher(
toolTitle = null, toolTitle = null,
toolArgsJson = argsJson, toolArgsJson = argsJson,
createdAt = nowTs, 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 { val toolDeferred = state.agentScope.async {
@@ -87,7 +96,29 @@ internal class ToolDispatcher(
} }
val resultAt = now() val resultAt = now()
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)) events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText))
}
// Только реальные падения тула попадают в outbox как background-event // Только реальные падения тула попадают в outbox как background-event
// (reflection-подобные потребители). Cancellation — not a failure, // (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( return WorkingMemoryEntry.ToolExchange(
sourceMessageId = callId, sourceMessageId = callId,
toolName = call.name, toolName = call.name,
@@ -6,20 +6,24 @@ import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.MutableConversationStore import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore 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.ReflectionStore
import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
/** /**
* Bundle из 4 ksqlite-сторов для standalone-агента. * Bundle из ksqlite-сторов для standalone-агента.
* *
* Internal helper `:standalone` — bundle нужен только агенту, поэтому не * Internal helper `:standalone` — bundle нужен только агенту, поэтому не
* торчит наружу через публичный API модуля. Каждый store (conversation, * торчит наружу через публичный API модуля. Каждый store (conversation,
* message, working_memory, reflection) живёт в своём ksqlite-модуле; * message, working_memory, reflection, outbox cursor) живёт в своём
* этот класс собирает их вокруг одной shared-connection и закрывает их * ksqlite-модуле; этот класс собирает их вокруг одной shared-connection и
* в правильном порядке в [close]. * закрывает их в правильном порядке в [close].
* *
* Lifecycle: открывает [SQLiteConnection] и возвращает 4 store'а. Каждый * Lifecycle: открывает [SQLiteConnection] и возвращает сторы. Каждый
* store сам прогоняет свою схему в конструкторе (`Schema.migrate(connection)` * store сам прогоняет свою схему в конструкторе (`Schema.migrate(connection)`
* — idempotent `CREATE TABLE IF NOT EXISTS`), явных вызовов миграции в bundle * — idempotent `CREATE TABLE IF NOT EXISTS`), явных вызовов миграции в bundle
* нет. Caller ДОЛЖЕН вызвать [close] при завершении. * нет. Caller ДОЛЖЕН вызвать [close] при завершении.
@@ -34,6 +38,12 @@ internal class SqliteStores internal constructor(
val messages: MutableJournalStore, val messages: MutableJournalStore,
val workingMemory: ContextStore, val workingMemory: ContextStore,
val reflections: ReflectionStore, val reflections: ReflectionStore,
private val outboxCursorStore: KsqliteCursorStore,
/**
* Персистентный счётчик событий агента (см. [PersistentOffsetSequencer]).
* Передаётся в `ChatAgent`, чтобы offset'ы переживали рестарт процесса.
*/
val outboxSequencer: OffsetSequencer,
) : AutoCloseable { ) : AutoCloseable {
override fun close() { override fun close() {
@@ -41,6 +51,7 @@ internal class SqliteStores internal constructor(
messages.close() messages.close()
workingMemory.close() workingMemory.close()
reflections.close() reflections.close()
outboxCursorStore.close()
connection.close() connection.close()
} }
@@ -48,12 +59,39 @@ internal class SqliteStores internal constructor(
fun open(path: String): SqliteStores = assemble(SQLiteConnection.open(path)) fun open(path: String): SqliteStores = assemble(SQLiteConnection.open(path))
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name)) fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
private fun assemble(conn: SQLiteConnection): SqliteStores = SqliteStores( private fun assemble(conn: SQLiteConnection): SqliteStores {
val cursorStore = KsqliteCursorStore(conn)
return SqliteStores(
connection = conn, connection = conn,
conversations = KsqliteMutableConversationStore(conn), conversations = KsqliteMutableConversationStore(conn),
messages = KsqliteJournalStore(conn), messages = KsqliteJournalStore(conn),
workingMemory = KsqliteContextStore(conn), workingMemory = KsqliteContextStore(conn),
reflections = KsqliteReflectionStore(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
}
}
}
}
} }
@@ -2,21 +2,20 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.content.Content 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.skill.mining.SkillReadTool
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.SqliteStores import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteContentPart
@@ -339,7 +338,7 @@ class ChatAgentTest {
val events = mutableListOf<ProtoEvent>() val events = mutableListOf<ProtoEvent>()
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { 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<OnlineEvent>() val online = mutableListOf<OnlineEvent>()
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
@@ -381,7 +380,7 @@ class ChatAgentTest {
val durable = mutableListOf<ProtoEvent>() val durable = mutableListOf<ProtoEvent>()
val online = mutableListOf<OnlineEvent>() val online = mutableListOf<OnlineEvent>()
val durableJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { 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) { val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) } agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
@@ -421,7 +420,7 @@ class ChatAgentTest {
// отправки событий подписка ничего не увидит. // отправки событий подписка ничего не увидит.
val events = mutableListOf<ProtoEvent>() val events = mutableListOf<ProtoEvent>()
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { 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<OnlineEvent>() val online = mutableListOf<OnlineEvent>()
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
@@ -485,7 +484,7 @@ class ChatAgentTest {
// Подписываемся ДО send — SharedFlow без replay // Подписываемся ДО send — SharedFlow без replay
val events = mutableListOf<ProtoEvent>() val events = mutableListOf<ProtoEvent>()
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { 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 { val sendJob = launch {
@@ -649,7 +648,7 @@ class ChatAgentTest {
// Agent.events() удалён из :proto — события живут в // Agent.events() удалён из :proto — события живут в
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>; // agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
// распаковываем .event для получения AgentEvent. // распаковываем .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) val conv = agent.createConversation(temp = false)
agent.deleteConversation(conv.id) agent.deleteConversation(conv.id)
@@ -662,6 +661,71 @@ class ChatAgentTest {
assertEquals(conv.id, created.conversationId) assertEquals(conv.id, created.conversationId)
assertEquals(conv.id, deleted.id) 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<CommonEvent.Conversation>()
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<AgentEvent>()
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. */ /** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */