Добавляет 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:
+459
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+20
-12
@@ -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 }
|
||||
|
||||
|
||||
+70
-6
@@ -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()
|
||||
}
|
||||
|
||||
+7
-5
@@ -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)
|
||||
}
|
||||
+11
-11
@@ -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
|
||||
}
|
||||
}
|
||||
+34
@@ -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()
|
||||
}
|
||||
}
|
||||
+9
-15
@@ -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'ом при выходе ссылки.
|
||||
|
||||
+87
-86
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+147
-83
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+2
-4
@@ -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 и его внутренних типов.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+29
-18
@@ -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)
|
||||
|
||||
+44
-32
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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 {}
|
||||
}
|
||||
|
||||
+34
-16
@@ -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,
|
||||
|
||||
+44
-6
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+73
-9
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user