Files
agentik/SYNC-SYSTEM.md
T
subochev 5bdc517988 Добавляет 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) удалён.
2026-10-02 01:16:15 +03:00

459 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ: Синхронизация клиент-сервер для чат-приложения с локальными и удалёнными ассистентами
## 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 с нового курсора. Локальный и удалённый ассистенты — просто генераторы событий, неотличимые для клиента.