textmachine/docs/research/23-engine-platform-seam.md

84 lines
22 KiB
Markdown
Raw Permalink 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.

# 23 — Шов движок ↔ платформа: как индустрия интегрирует batch-движок с control plane
> **→ 05.08 (D39.106): ТРАНСПОРТНАЯ часть §1 SUPERSEDED** — по холодному ревью `25-seam-cold-review.md`
> (4 панели, ~18 агентов, две модели) и решению владельца «прогон переживает деплой платформы».
> Поток идёт НЕ в stdout ребёнку-платформе, а в `events.jsonl` каталога книги (проекция коммитов
> SQLite движка); движок — systemd-юнит на прогон; платформа тейлит по курсору `(run_id, seq)`.
> Всё остальное этого дока (формат NDJSON/hello/seq · три канала · запрет на живой SQLite · деньги)
> подтверждено теми же панелями и В СИЛЕ.
> **→ 04.08 (D39.99/101): контракт API v0 РАТИФИЦИРОВАН по этому шву** — `../architecture/14-api-contract/`; строка 95 закрыта, хвосты движка = блокеры 99/100/101/103/125; код платформы существует (P0, ждёт приёмки). Упоминания «строки 95» ниже — история.
> **Ревью-шапка (оркестратор №9, 02.08.2026).** Статус: ПРИНЯТ, направление ратифицировано **D39.85**. Метод: 5-агентный воркфлоу — три веб-направления по первоисточникам (официальные доки/блоги авторов инструментов, вторичных статей в выводах нет) + репо-инвентаризация + независимый арбитраж репо-находок (все 9 вердиктов подтверждены, 3 мелкие поправки внесены сюда). Правила `internal` проверены ЖИВЫМИ сборками на go1.26.4, не пересказом доков. Стоимость $0. Вопрос ресёрча: как чисто организовать read-путь и статус-поток от движка (CLI, процесс-на-прогон, приватный SQLite под EXCLUSIVE flock) к платформе (отдельный Go-модуль, net/http + Postgres + River + SSE) — не нарушая инвариантов «сервер в backend не пишется» (D39.81) и «пользователи/квоты/HTTP не проникают в движок».
## §0. Решение одним экраном
**Делаем (три канала, каждый со своей ролью):**
1. **Живой статус** — движок эмитит версионированный однонаправленный NDJSON-поток событий; воркер-обёртка платформы материализует его в СВОЙ Postgres; SSE и все чтения фронта — ТОЛЬКО из Postgres. Движок никогда не опрашивается.
2. **Массивный контент** (текст глав, банк, манифест) — машиночитаемые артефакты на границах стадий с явным контрактом.
3. **Пост-фактум чтение и ре-синк**`tmctl status --json` / `export` (санкционированный CLI-режим по завершённому/остановленному прогону).
**НЕ делаем (анти-паттерны, каждый аргументирован ниже):** платформа не читает живой SQLite движка (§4) · движок не пушит HTTP и не получает сервер (§2-направление) · публичный Go-read-пакет из ядра не выносится (§5) · человеческий вывод не парсится, неверсионированный ad-hoc JSON не эмитится (§2) · полный CQRS/event-sourcing не строится — достаточно статус-таблицы (§3).
## §1. Наша форма — уже индустриальная
«CLI-движок, спавнутый на джобу, + control plane над ним» — форма Bazel (клиент → BES-бэкенд), BuildKit (buildctl → buildkitd → внешние потребители), CI-раннеров (gitlab-runner → Rails, GH Actions runner → orchestrator), terraform (CLI → TFC/автоматизации). Ни один из них не встраивает HTTP-сервер в сам движок сборки/выполнения; все решают наш вопрос одинаково — направленным потоком событий и артефактами. D39.81 (процесс-на-прогон, сервер в platform/) ресёрчем подтверждён дословно.
## §2. Канал 1: версионированный NDJSON-поток событий
**Эталоны.** Bazel Build Event Protocol: protobuf-схема событий как DAG, три транспорта (бинарный файл / NDJSON-файл / gRPC-стрим); упорядочение — гарантией анонса («каждое событие, кроме первого, анонсировано предыдущим»), полнота — только на штатном завершении ([bazel.build/remote/bep](https://bazel.build/remote/bep)). BuildKit наружу: `docker buildx --progress=rawjson` — «режим спроектирован для чтения внешней программой», финальные метаданные отдельным `--metadata-file` ([docs.docker.com](https://docs.docker.com/reference/cli/docker/buildx/build/)).
**Контракт потока — шаблон `terraform -json`** ([machine-readable-ui](https://developer.hashicorp.com/terraform/internals/machine-readable-ui)): один JSON-объект на строку; **первая строка всегда version-хендшейк**; semver-обещания — «минорную версию инкрементируем для обратно-совместимых добавлений: игнорируйте незнакомые поля; мажорную — для несовместимых: отвергайте неподдерживаемую». Дисциплина stdout: в машинном режиме stdout принадлежит ТОЛЬКО потоку, человеческие логи — stderr (у нас уже так: slog пишет в stderr, `obs/logging.go`).
**Почему версионирование обязательно:** прецедент гниения — Docker Engine JSON-stream (`jsonmessage`): неверсионированные поля депрекейтнулись, пакеты уехали в internal, потребители сломались (moby PR #51153). Ad-hoc JSON без version-поля дрейфует всегда.
**Направление, а не транспорт.** CI-раннеры пушат по HTTP (gitlab: `PATCH /jobs/:id/trace` каждые ~3с, сервер даёт backpressure заголовком; GH: push статуса + heartbeat) — но они долгоживущие демоны с сетевым стеком. Нам переносима не механика, а **направление**: движок ЭМИТИТ (stdout/файл), а пушит дальше адаптер на стороне платформы. HTTP в движок не проникает.
**Обязательная пара к потоку — реконсиляция.** BEP-каveat: при краше анонсированные события могут не прийти — гарантия полноты действует только на штатное завершение. Следствия: (а) консюмер идемпотентен — апсерт по `(run_id, seq)`; дубликаты at-least-once нормальны (transactional-outbox-грабли задокументированы: [microservices.io/outbox](https://microservices.io/patterns/data/transactional-outbox.html)); (б) на обрыве материализация пересобирается из `status --json` и артефактов завершённых стадий — у нас они уже есть как инвариант детерминизма, готовый ре-синк-источник.
## §3. Канал 2: материализация = Reporting Database, НЕ CQRS
Паттерн «воркер пушит → платформа складывает в свою БД → UI читает только её» подтверждён работающими системами (BuildBuddy как BES-бэкенд Bazel; GitLab/GitHub — статус джоба в их Postgres, раннер никогда не опрашивается). При этом Фаулер прямо предостерегает от полного CQRS: «для большинства систем CQRS добавляет рискованную сложность» ([CQRS](https://martinfowler.com/bliki/CQRS.html)); для чтений достаточно [ReportingDatabase](https://martinfowler.com/bliki/ReportingDatabase.html) — статус-таблица, наполняемая из потока. Event-sourcing, реплей истории, отдельные write/read-модели — не строить.
## §4. Анти-паттерн: чтение живого SQLite движка платформой
Честная рамка: сам SQLite мультипроцессное чтение ПОДДЕРЖИВАЕТ (FAQ #5; WAL-читатели не блокируют писателя) — вердикт «нет» держится не на «SQLite так нельзя», а на четырёх аргументах:
1. **Локи.** У движка `locking_mode=EXCLUSIVE`/EXCLUSIVE flock (`store.go:43`) — читатель получит SQLITE_BUSY либо вовсе не откроет файл; ослаблять локинг движка ради платформы = обратная связность. Смешение локинг-протоколов (flock у одного, SQLite-локи у другого) — задокументированный путь к порче БД ([howtocorrupt §2.4](https://sqlite.org/howtocorrupt.html)); обходы `nolock`/`immutable` на живом файле — «incorrect results and/or SQLITE_CORRUPT» ([uri.html](https://sqlite.org/uri.html)).
2. **Checkpoint starvation.** Долгий читатель в WAL блокирует checkpoint — «WAL file will grow without bound» ([wal.html](https://sqlite.org/wal.html)): даже «безобидное» чтение платформы операционно меняет поведение и файлы движка. Проникновение не кодом, так локами.
3. **Схемная связка.** Чтение приватной схемы v1v14+ = IntegrationDatabase-антипаттерн ([Fowler](https://martinfowler.com/bliki/IntegrationDatabase.html)): каждая миграция движка становится breaking change платформы; read-only НЕ амнистируется — schema coupling остаётся целиком ([microservices.io/shared-database](https://microservices.io/patterns/data/shared-database.html)).
4. **Индустрия единодушна.** Litestream (VFS-реплики «do not open the original live SQLite file»), LiteFS (page-репликация), официальный `sqlite3_rsync` (консистентный снапшот) — даже авторы инструментов, живущие внутри WAL-механики, для потребителей строят копию, не выдают путь к живому файлу. Fossil (SCM от авторов SQLite) объявляет собственную БД «implementation detail», интерфейс — экспортируемый формат поверх. Firefox держит эксклюзив на places.sqlite — профильная БД де-факто приватна.
**Допустимая будущая форма «да»** (если когда-нибудь понадобится): движок САМ экспортирует консистентный снапшот (`VACUUM INTO` — механизм уже есть, F4-бэкап) в отдельный файл с явным export-контрактом (стабильные export-таблицы/вьюхи + application_id/user_version-гейт версии), платформа открывает его `mode=ro&immutable=1` — на настоящем снапшоте immutable легален и избавляет от локов вовсе. sqlite.org благословляет SQLite-файл как interchange-формат ([appfileformat](https://sqlite.org/appfileformat.html)) — но это замораживает экспортированную схему, поэтому чище узкая JSON-проекция, пока не доказана нужда в большем.
**CLI-как-API** (сабпроцесс-на-запрос) легитимен по трём критериям из первоисточников: явный machine-режим с обещанием стабильности (git `--porcelain`), версионированная схема (terraform `-json format_version`), герметичность вызова (kubectl conventions: только машинные форматы, никакого неявного состояния). Но — только для **редких чтений завершённого состояния**; для continuous-статуса живого прогона индустрия единодушно использует push-стрим уже запущенного процесса, не спавн-на-поллинг.
## §5. Почему НЕ «публичный read-пакет» из ядра движка
Репо-факт (проверен арбитражем): `NewReadOnlyRunner``openRunner` грузит ПОЛНЫЙ стек — LoadBook/LoadModels/LoadPipeline + Pricer + промпт-шаблоны всех стадий + langpack + repair/terminology-шаблоны (`runner.go:151-266`), потому что честный `Status` обязан ре-рендерить снапшоты волн (drift/rebill: `status.go:492-500``rebill.go:95` → snapshot/render), а деноминатор N/M — это $0 ре-ингест источника (`status.go:177-195`). Читальный срез = **12 из 14 internal-пакетов + распил пакета pipeline**. «Тонкой read-библиотеки» не существует; вынос = перенос почти всего ядра из internal. Экосистема подтверждает отсутствие паттерна «библиотека читает живой чужой стор»: bbolt/badger блокируются на локах, prometheus `DBReadOnly` — только для неживых директорий, живые чтения Prometheus отдаёт исключительно через API процесса-владельца.
## §6. Go-модули: текущая структура правильная; один гард
- **Два sibling-модуля** (`textmachine/backend` + `textmachine/platform`) — то, что нужно, но по честной причине: НЕ ради видимости (правило `internal` действует по дереву каталогов — один модуль дал бы ту же изоляцию), а ради **изоляции графа зависимостей**: pgx/river платформы не попадают в go.mod/go.sum движка и не двигают его транзитивные версии через MVS — прямой довод от детерминизма прогона.
- **Живые эксперименты (go1.26.4):** sibling-модуль при импорте `backend/internal/*` получает ошибку компиляции — шов запечатан toolchain'ом. **Лазейка:** модуль с путём, ВЛОЖЕННЫМ под `textmachine/backend/*`, импортирует internal успешно (проверка — по префиксу import-пути, golang/go#23970). **Ревью-гард: путь Go-модуля платформы/фронта никогда не вкладывать под путь движка.**
- **go.work** — коммитить, когда у платформы появится Go-код (паттерн kubernetes/grafana: легитимно для деплойных, не-импортируемых-извне модулей; совет ref/mod «не коммитить» — про публичные библиотеки, не наш случай). go.work решает только dev-удобство: ни видимость, ни версии, ни деплой-скью он не меняет; co-deployed модули синхронизируются git SHA.
- Curated-surface-паттерн (k8s staging/apimachinery, gopls в x/tools) — держать в уме, применять ТОЛЬКО если платформе однажды реально понадобится импортировать Go-код движка; пока платформа зовёт движок процессом — версии между модулями не нужны вовсе.
## §7. Зрелость интеграционной поверхности движка (инвентаризация, арбитраж-подтверждено)
**Уже готово:** exit-контракт 0 чистый / 2 completed-with-flags / 3 банк-стоп / 1 сбой (`main.go:30-52`) · `status --json` (StatusReport: enum'ы disposition/flag_reason, деньги committed/reserved/ceiling_pct, дрифт, паспорта — `status.go:37-130`) · `export` = детерминированный JSON по умолчанию (+`--pairs`) · NDJSON-логи `LOG_FORMAT=json` на stderr с авто-осями trace_id/book/chapter/chunk/stage/role (`obs/logging.go:15-54`) и живыми событиями (старты волн, `calling model` с оценкой $ ДО вызова, `attempt completed` с ценой, банк-стоп) · 4 класса сайдкаров (mined-signature.yaml · bank-stop.txt · auto-bank.yaml · backups/) · деньги per-book одним запросом из `spend` (`ledger.go:314-322`); per-run — `SUM(request_log.cost_usd) WHERE trace_id` (нижняя граница, телеметрия) либо дельта spend.
**Малое касание:** событийный эмиттер со стабильным словарём — все границы суть единичные call-sites в pipeline рядом с готовыми slog-вызовами (`bookrun.go:160`, `waverun.go:116/171/131-135`, unit-done = `stagerun.go:296-307`); **событие потолка — единственное, что надо ДОБАВИТЬ** (сейчас деньги только в тексте ошибки, `stagerun.go:472-494`) → **строка 103** · `report --json` (QualityReport уже json-tagged; RequestLogView тегов НЕ имеет — нужна локальная view, поправка арбитража) · JSON-таблица банка (строка 101) · внешний trace-контекст (строка 102) · пофазный прогресс (строка 99, без миграции).
**Большая работа (и почему не делаем):** публичный read-пакет (§5 — отклонён) · персист манифеста против ре-ингеста (строка 100 — делаем, нужен и самому движку) · глобальный брокер рейт-лимитов (П-2 — единственная по-настоящему новая механика стыка).
## §8. Ратифицировано (D39.85) — носители
1. Форма шва = три канала §0; входы дизайна контракта API зафиксированы в строке 95: (i) NDJSON-поток (словарь-enum + version-хендшейк + событие потолка), (ii) артефактный контракт (export-JSON · 101 · 100), (iii) правило ре-синка (идемпотентный апсерт (run_id, seq); `status --json` = канал согласования).
2. НОВАЯ строка 103 — событийный эмиттер (малое касание; форма — вместе с контрактом 95).
3. Анти-паттерны §0 — запреты; чтение живого SQLite платформой не проектировать и не предлагать.
4. Ревью-гард модульных путей (§6); go.work — при появлении кода платформы.
5. П-1 платформенного бэклога уточнён формой потока (воркер-обёртка супервайзит процесс, ингестит идемпотентно, SSE из Postgres).
## §9. Источники (первоисточники)
bazel.build/remote/bep · developer.hashicorp.com/terraform/internals/machine-readable-ui и /json-format · docs.docker.com/reference/cli/docker/buildx/build · git-scm.com/docs/git-status (porcelain) · kubernetes.io/docs/reference/kubectl/conventions · martinfowler.com/bliki/{CQRS,ReportingDatabase,IntegrationDatabase}.html · microservices.io/patterns/data/{shared-database,transactional-outbox}.html · sqlite.org/{faq#q5,wal,howtocorrupt,uri,rsync,appfileformat,pragma#locking_mode}.html · litestream.io/how-it-works (+/vfs) · fly.io/blog/introducing-litefs · fossil-scm.org/home/doc/trunk/www/fileformat.wiki · go.dev/{ref/mod,doc/go1.4#internalpackages,blog/get-familiar-with-workspaces} · kubernetes.dev/blog/2024/03/19/go-workspaces-in-kubernetes · github.com/kubernetes/kubernetes/blob/master/staging/README.md · github.com/grafana/grafana/blob/main/go.work · pkg.go.dev/{go.etcd.io/bbolt,github.com/dgraph-io/badger/v4,github.com/prometheus/prometheus/tsdb} · живые сборки-эксперименты internal-правил: go1.26.4 linux/amd64 (scratchpad, воспроизводимы по §6).