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

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

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

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

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

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

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

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

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

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

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

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

sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
+459
View File
@@ -0,0 +1,459 @@
# ТЗ: Синхронизация клиент-сервер для чат-приложения с локальными и удалёнными ассистентами
## 0. Контекст и цель
Есть приложение-чат. В чатах пользователь общается с ассистентами. Ассистенты бывают:
- **удалённые** — работают на сервере;
- **локальные** — работают прямо на устройстве клиента.
Приложение должно:
- работать **офлайн** (пользователь может читать и писать, пока нет сети);
- отрисовывать UI **мгновенно**, не дожидаясь сети;
- **бесшовно** синхронизироваться, когда сеть появляется;
- использовать **один и тот же код** для клиента и сервера там, где это возможно;
- не хранить события вечно — журнал событий **компактится**.
Ключевая архитектурная идея: **клиент рисует UI исключительно из своей локальной базы**. Сетевые запросы нужны только для синхронизации, а не для отрисовки.
---
## 1. Основные понятия (глоссарий)
| Термин | Определение |
|---|---|
| **Событие (Event)** | Атомарный факт изменения состояния. Append-only. Имеет монотонный курсор `seq`. |
| **Журнал событий (Event Log)** | Упорядоченная по `seq` последовательность событий. Append-only. Подвергается компакции. |
| **Материализованное состояние (Materialized State)** | Текущее состояние домена (чаты, сообщения), полученное применением событий. Изменяемое. Каждая строка имеет `last_seq` — курсор последнего события, которое её изменило. |
| **Курсор (seq)** | Монотонно возрастающее целое число. Глобально уникальное. Присваивается сервером при записи события. |
| **Состояние на курсоре N** | Множество строк материализованного состояния, у которых `last_seq <= N`. Это «снимок» состояния на момент N. |
| **Апдейты после N** | Множество событий из журнала, у которых `seq > N`. |
| **Full resync** | Полная замена локального состояния клиента состоянием с сервера. Не merge, а replace. |
| **Live sync** | Инкрементальная догрузка событий после известного курсора. |
| **Компакция** | Удаление старых событий из журнала, которые уже «схлопнуты» в материализованное состояние. |
---
## 2. Инварианты системы
Эти инварианты должны соблюдаться всегда. Если хоть один нарушен — система некорректна.
1. **Курсор монотонен.** `seq` строго возрастает. Никаких дыр, никаких сбросов.
2. **Журнал append-only.** События не изменяются и не удаляются, кроме как через компакцию.
3. **Материализация консистентна журналу.** Для любого `N`: `apply(events where seq <= N) == SELECT * FROM state WHERE last_seq <= N`. Это значит, что материализованное состояние — это не «что-то отдельное», а результат применения журнала.
4. **События самодостаточны.** Каждое событие несёт **полный payload** изменённой сущности, а не дельту. Это нужно, чтобы клиент мог применить событие к незнакомой сущности.
5. **Full resync = replace.** При полной синхронизации клиент **заменяет** своё локальное состояние, а не мержит.
6. **Клиент отрисовывает только из локальной базы.** Никакой запрос к серверу не блокирует UI.
7. **Компакция не удаляет события, которые ещё нужны активным клиентам.** Либо удаляет, но тогда клиент обязан сделать full resync.
---
## 3. Модель данных
### 3.1. На сервере
#### 3.1.1. Журнал событий
```
EventLog:
seq : int64, PK, монотонный
event_id : UUID, уникальный идентификатор события
type : enum (ChatCreated, MessageAppended, MessageEdited, MessageDeleted,
ToolCallRequested, ToolCallSucceeded, ToolCallFailed, ...)
payload : JSON / бинарный blob с полным состоянием изменённой сущности
created_at : timestamp
origin : device_id / user_id / assistant_id (кто породил)
```
Индексы: `PK(seq)`, `INDEX(created_at)`.
#### 3.1.2. Материализованное состояние
Отдельные таблицы под каждую сущность. Примеры:
```
Chat:
id : UUID, PK
title : string
created_at : timestamp
deleted : bool
last_seq : int64 -- курсор последнего события, изменившего строку
Message:
id : UUID, PK
chat_id : UUID, FK -> Chat.id
role : enum (user, assistant, tool_call, tool_result, error)
content : blob
parent_id : UUID, nullable (для ветвлений)
deleted : bool
last_seq : int64
created_at : timestamp
```
Индексы: `PK(id)`, `INDEX(chat_id, last_seq)`, `INDEX(last_seq)`.
**Важно:** поле `last_seq` — это не «версия строки» в смысле MVCC. Это **«на каком событии строка стала такой, какая она сейчас»**. Строка всегда хранит только актуальную версию.
#### 3.1.3. Метаданные компакции
```
CompactionState:
min_available_seq : int64 -- самый старый курсор, который ещё можно запросить
last_compacted_at : timestamp
```
Если клиент запрашивает курсор `< min_available_seq` — сервер отвечает «курсор протух, делай full resync».
### 3.2. На клиенте
Клиент хранит **те же таблицы**, что и сервер (материализованное состояние), плюс дополнительно:
```
SyncState:
last_seq : int64 -- курсор, до которого клиент синхронизирован
last_sync_at : timestamp
PendingEvent:
local_id : UUID, PK
type : enum
payload : JSON
created_at : timestamp
status : enum (pending, sent, failed)
```
`PendingEvent` — это события, которые клиент сгенерировал локально (пользователь написал сообщение, локальный ассистент ответил), но которые ещё не подтверждены сервером.
**Важно:** `PendingEvent` не имеет `seq` — он появится только после подтверждения сервером.
---
## 4. Абстракции кода
### 4.1. Общие для клиента и сервера
```kotlin
// Доменные события — общие
sealed interface DomainEvent {
val eventId: UUID
val type: EventType
val payload: EventPayload
}
// Материализованное состояние — общее
interface StateStore {
fun readState(upToSeq: Long): StateSnapshot
fun applyEvent(event: LoggedEvent) // применяет событие к состоянию
fun lastSeq(): Long
}
// Журнал — общий интерфейс чтения
interface EventLog {
fun readUpdates(afterSeq: Long, limit: Int): List<LoggedEvent>
fun lastSeq(): Long
}
// Событие с назначенным курсором
data class LoggedEvent(
val seq: Long,
val eventId: UUID,
val type: EventType,
val payload: EventPayload,
val createdAt: Instant,
)
```
### 4.2. Только сервер
```kotlin
interface EventLogWriter : EventLog {
// Атомарно: назначает seq, пишет в журнал, применяет к материализации
fun append(event: DomainEvent): LoggedEvent
// Компакция
fun compact(upToSeq: Long)
}
```
### 4.3. Только клиент
```kotlin
interface EventLogReplica : EventLog {
// Применить событие от сервера к локальному состоянию
fun applyRemote(event: LoggedEvent)
// Заменить всё локальное состояние состоянием с сервера
fun replaceState(snapshot: StateSnapshot, upToSeq: Long)
// Локальные (ещё не подтверждённые) события
fun pendingEvents(): List<PendingEvent>
fun markPendingAsSent(localId: UUID, seq: Long)
fun markPendingAsFailed(localId: UUID)
}
```
### 4.4. Ассистент — общая абстракция
Ключевая идея прозрачности: **ассистент — это просто генератор событий**. Клиент не знает, локальный он или удалённый.
```kotlin
interface Assistant {
// Запускает генерацию, возвращает поток событий
fun run(input: RunInput): Flow<DomainEvent>
}
class RemoteAssistant(...) : Assistant {
override fun run(input: RunInput): Flow<DomainEvent> {
// Стримит события от сервера через WebSocket
}
}
class LocalAssistant(...) : Assistant {
override fun run(input: RunInput): Flow<DomainEvent> {
// Генерирует события локально (например, вызывает локальную LLM)
}
}
```
Клиент подписывается на поток событий и применяет их так же, как события от сервера. Локальный ассистент порождает `PendingEvent`, который потом уходит на сервер и подтверждается.
---
## 5. Протокол синхронизации
### 5.1. Точки входа (API)
**`GET /sync/state?upToSeq=N`**
- Возвращает материализованное состояние на момент N: `SELECT * FROM state WHERE last_seq <= N`.
- Если N не указан — возвращает актуальное состояние.
- Если N < `min_available_seq` — возвращает ошибку `cursor_expired` с указанием актуального `last_seq`.
- Ответ: `{ snapshot: StateSnapshot, upToSeq: N, currentSeq: M }`, где M — текущий максимальный курсор.
**`GET /sync/updates?afterSeq=N&limit=K`**
- Возвращает события `WHERE seq > N ORDER BY seq LIMIT K`.
- Если N < `min_available_seq` — ошибка `cursor_expired`.
- Ответ: `{ events: [LoggedEvent], hasMore: bool, currentSeq: M }`.
**`WS /sync/live?afterSeq=N`**
- WebSocket. Сервер пушит события `seq > N` в реальном времени.
- Если N < `min_available_seq` — сервер закрывает соединение с кодом `cursor_expired`.
**`POST /sync/events`**
- Клиент отправляет `PendingEvent`(ы) на сервер.
- Сервер валидирует, назначает `seq`, применяет, возвращает `LoggedEvent`(ы).
- Идемпотентность: `eventId` (UUID). Повторная отправка того же `eventId` — no-op, возвращается уже назначенный `seq`.
### 5.2. Алгоритм синхронизации на клиенте
```
sync():
1. Прочитать local lastSeq.
2. Попробовать GET /sync/updates?afterSeq=lastSeq&limit=K.
3. Если ответ cursor_expired:
3.1. GET /sync/state (без upToSeq) → получить актуальное состояние и currentSeq.
3.2. replaceState(snapshot, currentSeq).
3.3. lastSeq = currentSeq.
3.4. Перейти к шагу 5.
4. Если ответ ок:
4.1. Для каждого события в ответе: applyRemote(event).
4.2. lastSeq = max(seq в ответе).
4.3. Если hasMore — повторить с шага 2.
5. Отправить все PendingEvent через POST /sync/events.
5.1. Для каждого подтверждённого: markPendingAsSent(localId, seq).
5.2. Для каждого неподтверждённого: markPendingAsFailed(localId).
6. Открыть WS /sync/live?afterSeq=lastSeq.
6.1. При получении события: applyRemote(event), lastSeq = event.seq.
6.2. При разрыве: вернуться к шагу 2.
```
**Важно:** шаг 3 (full resync) **заменяет** состояние, а не мержит. Все локальные данные, которых нет в снапшоте, удаляются. Pending-события при этом **сохраняются** и отправляются после resync (шаг 5).
### 5.3. Обработка локальных событий (пользователь пишет сообщение)
```
userSendsMessage(chatId, content):
1. Создать DomainEvent(MessageAppended, {chatId, content, role: user, ...}).
2. Сохранить в PendingEvent со status = pending.
3. Применить событие к локальному состоянию (оптимистично), чтобы UI сразу показал сообщение.
4. Запустить sync() в фоне.
5. Когда сервер подтвердит — markPendingAsSent(localId, seq).
Если сервер вернул ошибку — markPendingAsFailed(localId), откатить состояние.
```
### 5.4. Обработка локального ассистента
```
localAssistantRuns(chatId, input):
1. Запустить Assistant.run(input), получить Flow<DomainEvent>.
2. Для каждого события в потоке:
2.1. Сохранить в PendingEvent со status = pending.
2.2. Применить к локальному состоянию.
3. Запустить sync() — события уйдут на сервер, получат seq, станут частью журнала.
```
Клиент **не различает** события от пользователя, от локального ассистента и от удалённого. Все они идут через `PendingEvent` → сервер → `LoggedEvent`.
---
## 6. Компакция журнала
### 6.1. Зачем
Журнал растёт бесконечно. Старые события уже «схлопнуты» в материализованное состояние. Их можно удалить.
### 6.2. Как
Периодический фоновый процесс на сервере:
1. Определить `compaction_seq` = минимальный курсор, который ещё нужен активным клиентам. Если неизвестно — использовать `current_seq - safety_margin`.
2. Удалить события `WHERE seq <= compaction_seq`.
3. Обновить `CompactionState.min_available_seq = compaction_seq + 1`.
### 6.3. Что делать клиенту с протухшим курсором
Если `lastSeq < min_available_seq`:
- Не пытаться догрузить апдейты (их нет).
- Сделать **full resync**: `GET /sync/state` → `replaceState` → продолжить live sync.
Это **не ошибка**, это штатный сценарий. Клиент просто получает актуальное состояние и продолжает жить.
### 6.4. Ключевое правило
**Full resync = replace, не merge.** Если клиент мержит, удалённые и отредактированные сущности останутся в старом виде. Если заменяет — всё консистентно.
---
## 7. Обработка редактирования и удаления
### 7.1. Редактирование
Событие `MessageEdited` несёт **полный новый content** сообщения.
- В журнал пишется событие с `seq = S`.
- В материализации строка обновляется на месте: `content = newContent`, `last_seq = S`.
**Клиент на живом курсоре N < S:**
- Получает событие `MessageEdited` из журнала.
- Применяет: обновляет `content` у себя, ставит `last_seq = S`.
**Клиент на протухшем курсоре, resync на M > S:**
- В снапшоте видит сообщение уже с новым content.
- Событие `S` не приходит (оно ≤ M).
- Всё консистентно.
### 7.2. Удаление
Событие `MessageDeleted`. В материализации строка либо **физически удаляется**, либо помечается `deleted = true` с `last_seq = S`.
Рекомендуется **физическое удаление**, потому что replace при resync всё равно уберёт строку. Tombstones нужны только если требуется показывать «сообщение удалено» в UI.
**Клиент на живом курсоре N < S:**
- Получает событие `MessageDeleted`.
- Удаляет строку у себя.
**Клиент на протухшем курсоре, resync на M > S:**
- В снапшоте строки нет.
- replace убирает её у клиента.
- Событие `S` не приходит.
- Всё консистентно.
---
## 8. Гарантии и краевые случаи
### 8.1. Идемпотентность
Каждое событие имеет `eventId` (UUID). Применение события с уже известным `eventId` — no-op. Это защищает от:
- повторной отправки `PendingEvent`;
- повторного применения события при реконнекте;
- дублирования в live sync.
### 8.2. Порядок событий
Клиент применяет события **в порядке `seq`**. Если пришло событие с `seq > lastSeq + 1`, значит есть пропуск — клиент должен догрузить пропущенные через `GET /sync/updates?afterSeq=lastSeq`.
### 8.3. Конфликты
Конфликты не разрешаются «в общем виде». Правила:
- Сообщения **append-only** (редактирование = новое событие, не мутация).
- Правки от разных устройств одного пользователя — сервер применяет в порядке поступления, последняя побеждает (last-write-wins по `seq`).
- Ветвления (regenerate) — через `parent_id`, а не через мутацию.
### 8.4. Офлайн-запись
Пользователь пишет офлайн → событие в `PendingEvent` → применяется локально → UI показывает. При появлении сети → sync → сервер назначает `seq` → `markPendingAsSent`.
Если сервер отверг событие (валидация не прошла) → `markPendingAsFailed` → клиент откатывает локальное изменение.
### 8.5. Мультиустройство
У каждого клиента есть `device_id`. События помечаются `origin`. При синке клиент не применяет свои же события повторно (идемпотентность по `eventId` решает это автоматически).
### 8.6. Большие снапшоты
`GET /sync/state` может вернуть много данных. Решения:
- Пагинация: `GET /sync/state?upToSeq=N&chatId=X` — по одному чату.
- Стриминг: HTTP chunked / gRPC streaming.
- Сжатие (gzip / zstd).
Для чата с ассистентом обычно достаточно per-chat снапшотов.
---
## 9. Что должен реализовать кодовый агент
### 9.1. Сервер
1. **Хранилище журнала событий** (`EventLog`): append-only, монотонный `seq`, компакция.
2. **Хранилище материализованного состояния** (`StateStore`): таблицы `Chat`, `Message`, поле `last_seq`.
3. **Логика применения события** (`applyEvent`): обновляет материализацию, ставит `last_seq`.
4. **Атомарная операция `append`**: в одной транзакции назначает `seq`, пишет в журнал, применяет к материализации.
5. **HTTP API**: `/sync/state`, `/sync/updates`, `/sync/events`.
6. **WebSocket**: `/sync/live`.
7. **Фоновый компактор**: периодически удаляет старые события, обновляет `min_available_seq`.
8. **Идемпотентность**: таблица `processed_event_ids` или проверка по `eventId`.
### 9.2. Клиент
1. **Локальное хранилище состояния** (`StateStore`): те же таблицы, что на сервере.
2. **Локальное хранилище `PendingEvent`**: очередь несинхронизированных событий.
3. **Логика применения события** (`applyEvent`): общая с сервером (один код).
4. **Логика `replaceState`**: полная замена локального состояния.
5. **Sync-клиент**: реализует алгоритм из раздела 5.2.
6. **WebSocket-клиент**: live sync.
7. **UI**: рисует исключительно из локального `StateStore`. Никогда не ждёт сеть.
8. **Ассистенты**: `LocalAssistant` и `RemoteAssistant` через общий интерфейс `Assistant`, возвращающий `Flow<DomainEvent>`.
### 9.3. Общее
1. **Модель `DomainEvent`** с самодостаточным payload.
2. **Интерфейсы** `EventLog`, `StateStore`, `Assistant`.
3. **Логика сериализации/десериализации** событий.
4. **UUID-генерация** для `eventId`.
5. **Логика идемпотентности** по `eventId`.
---
## 10. Чего делать НЕ надо
1. **Не делать MVCC.** Строки хранят только текущую версию. История — в журнале, но она компактится.
2. **Не делать tombstones**, если не нужно показывать «удалено». Физическое удаление + replace при resync решают всё.
3. **Не мержить при full resync.** Только replace.
4. **Не запрашивать сервер для отрисовки.** UI читает только локальную базу.
5. **Не различать локального и удалённого ассистента на уровне клиента.** Оба — `Assistant`, возвращающий `Flow<DomainEvent>`.
6. **Не хранить счётчик в БД отдельно.** `seq` — это либо sequence в БД, либо ULID в событии. Отдельный «счётчик» — лишняя сущность.
7. **Не бояться, что «старый курсор протух».** Это штатный сценарий: full resync.
---
## 11. Критерии готовности
1. Пользователь может писать офлайн, UI обновляется мгновенно.
2. При появлении сети события уходят на сервер, получают `seq`, синхронизируются.
3. Второй клиент видит изменения в реальном времени.
4. Локальный ассистент работает так же, как удалённый, с точки зрения клиента.
5. Компакция журнала не ломает синхронизацию: клиент с протухшим курсором делает full resync и продолжает.
6. Редактирование и удаление сообщений обрабатываются консистентно во всех сценариях.
7. Full resync заменяет состояние, не оставляя «фантомных» строк.
8. Идемпотентность: повторная отправка события не создаёт дублей.
---
## 12. Резюме идеи в одном абзаце
Есть **журнал событий** с монотонным курсором и **материализованное состояние**, где каждая строка помечена курсором последнего изменившего её события. Состояние на курсоре N — это строки с `last_seq <= N`. Апдейты после N — это события с `seq > N`. Клиент хранит локальную копию состояния и свой `lastSeq`. Для синхронизации он либо догружает апдейты (если курсор жив), либо заменяет состояние целиком (если курсор протух из-за компакции). В обоих случаях результат консистентен: клиент видит актуальные данные, не видит «шума» про отредактированные/удалённые сущности, которые были до его курсора, и продолжает live sync с нового курсора. Локальный и удалённый ассистенты — просто генераторы событий, неотличимые для клиента.