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