refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api

Семантика outbox'а — теперь чистый live-канал:
  * OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
    Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
    на live. Outbox ответственен только за уведомления «что-то произошло».
  * OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
    Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
    рестарт и инкремент для каждого commit.
  * DurableEvent: commit принимает блок { cursor -> MessageRecord }
    (cursor выдаёт CursorStore; клиент не вычисляет offset сам).
  * MessageRecord больше не несёт cursor — это не его ответственность.

Новые модули:
  * :cursor-api         — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
  * :cursor-ksqlite     — KsqliteCursorHolder (персистентный)
  * :cursor-inmemory    — для тестов
  * :client-sync        — LocalSyncAgent (мини-агент поверх :client для десктопа)

Удалены:
  * :sync-core          — старая референсная реализация, заменена
                           cursor-разделением и :client.
  * outbox-ksqlite      — CursorStore/Schema уехали в :cursor-ksqlite.
  * OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.

standalone:
  * ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
    ConversationEvents — подписка через push-паттерн (collect событий).
  * A2aBridge — currentCursor() и conversationEvents(after=) убраны.
  * SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
    читает MAX(created_at).
  * Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
    transport удалён; debug-routes удалены; A2A остался.
  * Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
    (subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅

server / client:
  * Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
  * AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
  * ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
  * JournalStore API расширен count(after=Instant?) для unread-badge.
This commit is contained in:
2026-10-05 23:33:35 +03:00
parent 22a167ed03
commit 9e888227a3
115 changed files with 2147 additions and 6025 deletions
@@ -32,25 +32,18 @@ import kotlinx.coroutines.withContext
* ## Миграция
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
* (CREATE TABLE / INDEX IF NOT EXISTS + гейтированный ADD COLUMN), так что
* лишних эффектов нет ни в standalone-форме, ни в shared-connection bundle'е,
* где несколько store'ов прогоняют миграцию одной и той же схемы по очереди.
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
* прогоняют миграцию одной и той же схемы по очереди.
*
* Prepared statements (insert / list / clear) препарируются один раз в
* Prepared statements (insert / list / clear / count) препарируются один раз в
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
* GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
* parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
* (см. [pw.binom.db.ksqlite.StmtHolder]).
*
* `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.
* Адресация — по `created_at` (см. [JournalStore]). Курсор/`seq` здесь
* отсутствуют: монотонный порядок для удалённого доступа держит sync-слой.
*/
class KsqliteJournalStore private constructor(
private val connection: SQLiteConnection,
@@ -88,14 +81,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}, ${Schema.COL_SEQ})
VALUES (?, ?, ?, ?, ?, ?)
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
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_SEQ}
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
@@ -103,18 +96,6 @@ 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} = ?"
)
@@ -128,13 +109,6 @@ 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)
@@ -146,7 +120,6 @@ 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()
}
}
@@ -174,29 +147,6 @@ 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()
@@ -231,27 +181,12 @@ class KsqliteJournalStore private constructor(
}
}
override suspend fun count(conversationId: String, afterSeq: Long): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAfterSeqStmt.reset()
countAfterSeqStmt.clearBindings()
countAfterSeqStmt.bindText(1, conversationId)
countAfterSeqStmt.bindLong(2, afterSeq)
countAfterSeqStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override fun close() {
insertStmt.close()
listStmt.close()
listSeqStmt.close()
clearStmt.close()
countAllStmt.close()
countAfterStmt.close()
countAfterSeqStmt.close()
if (ownsConnection) {
connection.close()
}
@@ -43,28 +43,26 @@ 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, seq = seq)
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
}
"assistant" -> {
val d = decodeBodyPayload(payload)
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning, seq = seq)
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning)
}
"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, seq = seq)
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
}
"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, seq = seq)
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
}
"error" -> {
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt, seq = seq)
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
}
else -> error("Unknown message kind in audit log: $kind")
}
@@ -19,11 +19,13 @@ object Schema {
/**
* Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL.
*
* v2: `message.seq` — монотонный per-agent offset (курсор снапшота),
* синхронный с `OutboxStore`'ом. Старые БД до-мигрируются через
* `ALTER TABLE ... ADD COLUMN` (см. [migrate]).
* v4: из `message` убраны колонки курсора (`cursor_created_at` /
* `cursor_offset`) и индекс `idx_msg_conv_cursor`. Позиция в потоке —
* только `created_at`; монотонный курсор для удалённого доступа живёт в
* sync-слое. Существующие БД сохраняют legacy-колонки как неиспользуемые
* (в DDL они больше не описываются).
*/
const val CURRENT_VERSION: Int = 2
const val CURRENT_VERSION: Int = 4
// ───── Таблицы ─────
const val TABLE_CONVERSATION = "conversation"
@@ -40,16 +42,13 @@ 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"
/** Hot-path `list(convId, after, offset, limit)` по времени. */
const val IDX_MSG_CONV = "idx_msg_conv"
/** Keyset-индекс для `list(convId, afterSeq, upToSeq, limit)`. */
const val IDX_MSG_CONV_SEQ = "idx_msg_conv_seq"
private val v1ConversationDdl = """
private val conversationDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_TITLE TEXT,
@@ -59,28 +58,22 @@ object Schema {
);
"""
private val v2MessageDdl = """
private val messageDdl = """
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_SEQ INTEGER NOT NULL DEFAULT 0
$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
);
"""
private val v2IndexesDdl = """
private val indexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
-- 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);
"""
/**
@@ -88,45 +81,26 @@ object Schema {
*
* Гарантии:
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* уже-мигрированной БД; `ADD COLUMN` гейтится проверкой `PRAGMA table_info`;
* уже-мигрированной БД;
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* ROLLBACK оставит БД консистентной.
*
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
* user_version как gate перестал работать — два модуля ставят его в 1,
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
** NOTE**: в сплит-мире (несколько ksqlite-модулей, каждый владеет своей
* таблицей) user_version как gate перестал работать — два модуля ставят его
* в 1, второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
*/
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
try {
conn.exec(v1ConversationDdl)
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(conversationDdl)
conn.exec(messageDdl)
conn.exec(indexesDdl)
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
}
}