Land the platform pack that mounts the bank correction door, carries provider keys to the engine, and makes the run's bar one fraction, with the canon minor it required
This commit is contained in:
parent
420da3c9ed
commit
58bae309c0
59 changed files with 4127 additions and 410 deletions
349
docs/PROGRESS.md
349
docs/PROGRESS.md
File diff suppressed because one or more lines are too long
|
|
@ -22,11 +22,11 @@
|
|||
| Роль | Активный промт | Статус |
|
||||
|---|---|---|
|
||||
| Оркестратор | [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) | роль и нормы; счётчик роли — CURRENT-STATE |
|
||||
| Бэкенд | [BACKEND_SILENT_HARM_SESSION_PROMPT.md](BACKEND_SILENT_HARM_SESSION_PROMPT.md) | ⚠ **НАПИСАН, но ещё НЕ ВЫДАН** — исполнителя нет. Пак «тихая порча»: эскалация теряет инъекцию банка (208) · дыры выдачи молчат в выгруженном файле (193). Второй рубеж ПРОЙДЕН — 12 находок опровергателя применены, включая вырожденный тест самопроверки. Состав — §3 промта. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) |
|
||||
| Платформа | [PLATFORM_P9_SESSION_PROMPT.md](PLATFORM_P9_SESSION_PROMPT.md) | ВЫДАН 27.08, пак **P9** — пункт (2в) очереди D39.156, последний в связке шва. Состав — §3 промта, здесь не пересказан. ⚠ **Второй рубеж (независимое опровержение промта) НЕ пройден** — сессия окружения, которой он был заказан, кончилась вместе с рестартом; исполнителю это сказано, его право отказа по §9 несёт этот рубеж |
|
||||
| Бэкенд | [BACKEND_SILENT_HARM_SESSION_PROMPT.md](BACKEND_SILENT_HARM_SESSION_PROMPT.md) | **ВЫДАН 28.08**, пак «тихая порча»: эскалация теряет инъекцию банка (208) · дыры выдачи молчат в выгруженном файле (193) · движковая идемпотентность повтора документа (§3.3). Состав — §3 промта. ⚠ **Второй рубеж ПРОТУХ:** опровергатель отработал (12 находок, включая вырожденный тест самопроверки) и лёг коммитом `3a65989`, но ПОСЛЕ него в промт добавлены три вещи, которых он не видел — §3.3 целиком (`9fd1d3e`), четвёртая дыра со своей границей и предупреждение о столкновении с `201` (`2c1fa9a`). Исполнителю это сказано прямо; его право отказа по §9 несёт этот рубеж. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) |
|
||||
| Платформа | активного НЕТ | пак **P9** ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.162**): дверь правок банка смонтирована, ключи доехали, полоса стала сквозной вместе с каноном **0.6.0**. Промт — в `archive/prompts/`. ⚠ Открытым остался архитектурный стоп `PD-410` (платформа продаёт главы, движку идёт только `--ceiling-usd`) и продуктовый вопрос владельцу по `Н2`/`Н3`. Следующая работа зоны — по строкам **215**/**216** и регистру, запуск по слову владельца |
|
||||
| Полигон | [POLYGON_EXP2223_REDO_SESSION_PROMPT.md](POLYGON_EXP2223_REDO_SESSION_PROMPT.md) (отложенный — [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md), строка 85) | фаза Д ИДЁТ; ⚠ живой носитель курса — в `eval/dovodka/`, какой именно называет зона (⚠ [POLYGON_PHASE_D_HANDOFF.md](POLYGON_PHASE_D_HANDOFF.md) — перекрытый снимок, читать не как курс) |
|
||||
| Фронт | активного НЕТ | **ЗОНА ЗАМОРОЖЕНА** (D39.136 п.2 + D39.147: разморозка отдельным словом владельца, не привязана к P7); перечень первого касания — в зонном журнале |
|
||||
| Контракт | активного НЕТ | минор **0.5.0** ПРИНЯТ и заленджен 27.08 (D39.161): отменённая пер-термная модель снесена, дверь `POST …/bank/corrections` объявлена и выведена из словаря глагола, признак «не построено» машиночитаем. Следующая работа по контракту — ПОСЛЕ монтажа двери паком (2в) |
|
||||
| Контракт | активного НЕТ | минор **0.6.0** ПРИНЯТ и заленджен 28.08 (**D39.162**/**D39.163**): полоса прогресса объявлена сквозной, `Progress.stage` заведён ВТОРЫМ ограниченным исключением из границы «ничего о том, КАК переводится книга». До него — **0.5.0** (D39.161, дверь правок банка). Хвост компаньона — строка **203**, следующий минор по её пункту (к) |
|
||||
|
||||
Отработанные промты — `archive/prompts/`, отчёты с ревью-шапками — `archive/reports/`. Зонные журналы фронта и платформы — `frontend-PROGRESS.md` / `platform-PROGRESS.md` в их зонах (прогресс зон только там, D39.100).
|
||||
- Чужие зоны — фронт и платформа (читать при касании стыка; каждая ведёт СВОЙ зонный бэклог — единый бэклог их строк не принимает, D39.84): [../frontend/](../frontend/) — веб-интерфейс: промт фронт-сессий + [STACK_DECISIONS.md](../frontend/docs/STACK_DECISIONS.md) (пины версий точными числами и ловушки) + [BACKLOG.md](../frontend/docs/BACKLOG.md) · [../platform/](../platform/) — SaaS control plane: README + [BACKLOG.md](../platform/BACKLOG.md) + `docs/` (зонный журнал `platform-PROGRESS.md` · регистр дефектов · `STACK_DECISIONS.md` с рецептом стенда и инвентарём каналов шва · **[ENGINEERING_STANDARDS.md](../platform/docs/ENGINEERING_STANDARDS.md) — ратифицирован; КАЖДЫЙ промт платформенной сессии обязан на него ссылаться, отступление = пинг** · **[PLATFORM_DIRECTION.md](../platform/docs/PLATFORM_DIRECTION.md) — ратифицированное направление зоны: аутентификация, деньги, стандарты, скорость** · архив промтов).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Реестр D-нот — карта актуальности v2 (D1–D39.161;
|
||||
# Реестр D-нот — карта актуальности v2 (D1–D39.163;
|
||||
|
||||
> ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА
|
||||
> у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк
|
||||
|
|
@ -222,4 +222,6 @@
|
|||
| D39.159 | 27.08 | Пак P8-REVIEW принят: числа воспроизведены приёмкой на дереве С лендингом бэкенда (18 пакетов, EXIT=0, скипов 0; регистр 398/96). Ратифицированы норма копии-с-каноном (`ENGINEERING_STANDARDS` §3 п.3), эррата `STACK_DECISIONS` §13 и токен `ОСПОРЕНО(PD-N)` при двусторонней ссылке; статусы трёх спорных строк НЕ пере-открыты. `PD-379` подтверждён чтением, `PD-376` — своей посадкой. Правка гейта формы регистра отклонена замером (краснит 7 законных строк). | ✅ |
|
||||
| D39.160 | 27.08 | Контрактный минор 0.4.0 → 0.5.0 выдан промтом; **сквозная полоса прогресса (строка 200) ИЗЪЯТА из него словом владельца** и едет с платформенным паком (2в) — гейт версии требует совпадения канона и деплоя в момент лендинга, а сервер прогресс по-новому не считает. Цена «одним куском» обнулена заморозкой фронта. Промт прошёл оба рубежа: 9 находок опровергателя, все применены. | ✅ |
|
||||
| D39.161 | 27.08 | Контрактный минор **0.5.0** принят и заленджен: отменённая пер-термная модель снесена из канона и компаньона, дверь `POST …/bank/corrections` выведена из словаря `bank-apply` поле в поле, признак «не построено» машиночитаем, счётчики упразднённой модели сняты. Константа платформы поднята тем же коммитом — батарея зоны 18/EXIT=0. Ошибка промта про «поля навсегда нули» найдена исполнителем и вынесена `PD-399`. | ✅ |
|
||||
| D39.162 | 28.08 | **Платформенный пак P9 принят и заленджен + контрактный минор 0.6.0**: дверь правок банка смонтирована синхронно, ключи доехали `--keys-file` (строка 211 закрыта), дубль конвенции пути снят (213 сужена), полоса стала СКВОЗНОЙ вместе с каноном, `PD-399` снят. Батарея пере-прогнана приёмкой с живым Postgres (793/18/0/0). Две мои диспозиции сессия опровергла исполнением, я принял; одну её рекомендацию (закрыть 186) отклонил — жив остаток. Р3 — архитектурный стоп `PD-410`, канон НЕ смягчён. | ✅ |
|
||||
| D39.163 | 28.08 | **Граница контракта получает ВТОРОЕ исключение — `Progress.stage`**, ограниченное двумя условиями: значение ВЫВОДИТСЯ платформой из тех же счётчиков (не проброс из движка) и словарь ОТКРЫТ (клиент рисует незнакомое нейтрально, версия не поднимается). Ради канона мультиязычности: пара с иной формой работы не требует нового клиента. Оговорка по `PD-410`: подпись — модель платформы, не отчёт движка. Третьего исключения нота НЕ разрешает. | ✅ |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Журнал решений оркестратора — контракт D1–D39.161 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
# Журнал решений оркестратора — контракт D1–D39.163 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
|
||||
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто:
|
||||
> ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 живёт ниже в этом файле (голова D39.106+).
|
||||
|
|
@ -807,3 +807,102 @@
|
|||
8. **Щели v1 названы, не спрятаны** (в каноне и компаньоне): машинного словаря причин отказа нет ·
|
||||
алиасы клиенту невидимы · счёт `signature` недостижим без правки или превью · на 409 не едут
|
||||
`preexisting`/`signature`, хотя движок их печатает — названная цена, не забывчивость.
|
||||
|
||||
## D39.162 — ПЛАТФОРМЕННЫЙ ПАК P9 ПРИНЯТ И ЗАЛЕНДЖЕН + КОНТРАКТНЫЙ МИНОР 0.6.0: дверь правок банка смонтирована, ключи доехали, полоса стала сквозной (28.08, оркестратор №19). ✅
|
||||
|
||||
**Что заленджено.** Пункт (2в) очереди D39.156 — последний в связке шва — отработан целиком:
|
||||
дверь `POST /books/{bookId}/bank/corrections` смонтирована синхронно и под пер-книжным мьютексом ·
|
||||
ключи деплоя доезжают до движка аргументом `--keys-file` (строка **211** ЗАКРЫТА, 11 пинов) ·
|
||||
дубль движковой конвенции пути снят, путь банка берётся из конверта артефактов движка (**213**
|
||||
сужена до одной строки дев-пути) · полоса прогресса стала ОДНОЙ монотонной долей на всю работу
|
||||
прогона через обе волны, с канонной половиной — минором **0.6.0** (строка **200** ЗАКРЫТА) · с
|
||||
провода сняты `pending_decisions`/`complete` (`PD-399`). Бэклог 176 → 175.
|
||||
|
||||
**Приёмка — исполнением, не чтением отчёта.** Батарея пере-прогнана мной с живым Postgres:
|
||||
`EXIT=0`, 18 пакетов, `=== RUN` **793**, FAIL 0, DATA RACE 0, SKIP 2 (оба гейчены живым бинарём
|
||||
движка). ⚠ Первый мой прогон БЕЗ `TM_PLATFORM_TEST_DSN` дал 279 пропусков из 774 — то есть «зелёная
|
||||
батарея» без DSN проверяет две трети зоны, и это стоит помнить всякой будущей приёмке.
|
||||
|
||||
**Две мои диспозиции сессия ОПРОВЕРГЛА, и я принял опровержение.**
|
||||
1. **Р4 — «`limitedBuffer` не убивает чайлда» НЕВЕРНА, мой заказ отозван.** Проверено МОИМ
|
||||
исполнением, а не её доводом: отдельная программа с чайлдом, печатающим бесконечно, и writer'ом,
|
||||
отказывающим на первой записи, — чайлд умер САМ за 1 мс с `signal: broken pipe`. Механизм стоит в
|
||||
исходнике Go (`os/exec`, `writerDescriptor`: `pr.Close() // in case io.Copy stopped due to write
|
||||
error`) и работает для `Stdout = io.Writer`; `drain()` носит свой `Kill` потому, что читает через
|
||||
`StdoutPipe`, где никто ничего не закрывает. Ревьюер экстраполировал одну семантику на другую и
|
||||
НЕ исполнял. Урок общий: находка «по аналогии с соседним кодом» — гипотеза, пока не посажена.
|
||||
2. **Р2-dispute (READ COMMITTED между двумя UPDATE) растворён фиксом** — миграция сведена к одному
|
||||
стейтменту, окна не существует; проверено чтением миграции.
|
||||
|
||||
**Одну её рекомендацию я ОТКЛОНИЛ.** Строку **186** она предлагала закрыть по своему замеру. Замер
|
||||
верен и внесён в строку (`If-None-Match` → `304`/0 Б; gzip ×4.1; ниже 1024 Б честно не применяется),
|
||||
но он подтверждает шаги 1–2, заленденные ещё P7, — а у строки живы шаги 3–5. Закрытие унесло бы долг
|
||||
целиком. Строка остаётся открытой с ПОДТВЕРЖДЁННОЙ половиной.
|
||||
|
||||
**Р3 — архитектурный СТОП, и он назван, а не заметён (`PD-410`).** Проверено мной по коду и жёстче её
|
||||
формулировки: у движка НЕТ флага числа глав вовсе — `TranslateArgs` несёт только `--ceiling-usd`
|
||||
(`platform/internal/runner/engine.go:90-98`), а сам флаг каппит КУМУЛЯТИВНУЮ трату по книге
|
||||
(`backend/cmd/tmctl/invocation.go:129`), тогда как волна идёт по всем чанкам. Платформа продаёт
|
||||
ГЛАВЫ, движок останавливается по ДЕНЬГАМ: единицы разные, и никакая формула полосы их не мирит, пока
|
||||
платёжная модель и план работ не согласованы. **Канон я НЕ смягчил** — по прецеденту §3.3 бэкенд-пака:
|
||||
обещание верное, дефект в реализации; смягчение канона под текущий деплой — это то, как обещание
|
||||
тихо становится ложью. Вместо смягчения канон получил честную оговорку у `stage` (D39.163) и явное
|
||||
«прогон, кончившийся рано, до `total` не доходит».
|
||||
|
||||
**Строка 227 заведена с ИСПРАВЛЕННЫМ якорем.** Находка Д4 (квитанция считает `signature` от карты,
|
||||
которую переписывает любая граница майнинга) ссылалась на `pipeline/signaturemap.go` — такого файла
|
||||
НЕТ. Существо находки верно, носитель другой: идентификатор карты живёт в
|
||||
`backend/internal/membank/decisions.go:903-912` (`seed.SignatureMapID`), и шов его не читает, то есть
|
||||
починка аддитивна.
|
||||
|
||||
**Тесты под зелень не подгонялись — проверено диффом, а не заявлением.** 108 удалённых строк в тестах
|
||||
разобраны поимённо: словарь кодов 16 → 18 (минор 0.5.0 добавил два банковских), «шесть фактов» →
|
||||
«семь» с новым полем в проверяемом списке, снесённые утверждения о `pending_decisions` — вслед за
|
||||
снесёнными полями. Единственная снесённая ФУНКЦИЯ
|
||||
(`TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline`) проверяла пере-взятие базовой
|
||||
линии — механику, которую сквозная полоса упразднила; на её месте стоит тест противоположного
|
||||
инварианта («снятие стопа не двигает бар») плюс два новых, каждый с названной пойманной мутацией.
|
||||
|
||||
**Фикс-раунд приёмки — 13 позиций** (`platform/docs/platform-PROGRESS.md`, таблица F1-F13). Из
|
||||
проверенных мной по коду: потолок 1 МиБ меряется на ОТРЕНДЕРЕННОМ документе до спавна, поэтому
|
||||
законное тело получает канонный `413`, а не движковое «пере-решите» (экранирование HTML выключено —
|
||||
документ читает движок, не браузер); гард resume разделил слова честно — живой сосед `run_in_flight`,
|
||||
финишировавший `not_resumable`; грейс стопа поднят с 10 с выше измеренных 11.3 с непрерываемого
|
||||
участка.
|
||||
|
||||
**Что осталось открытым и НЕ спрятано:** `PD-410` (стоп Р3) · `PD-281` (прогон-применение над полной
|
||||
книгой) · строка **213** одной строкой дев-пути · строка **186** шагами 3–5 · строка **214** вопросом
|
||||
«кто и когда подписал» · строка **227**. Наследие `Н2`/`Н3` (смена формы конвейера на живой книге
|
||||
перепродаёт купленные главы) упирается в ПРОДУКТОВОЕ решение владельца и ждёт его словом, а не
|
||||
кодом.
|
||||
|
||||
## D39.163 — ГРАНИЦА КОНТРАКТА получает ВТОРОЕ исключение: `Progress.stage`, и оно ограничено двумя условиями (28.08, оркестратор №19). ✅
|
||||
|
||||
**Что было.** Шапка канона (`14-api-contract/openapi.yaml`, §Boundaries) запрещает проводу всё о том,
|
||||
КАК переводится книга: имена моделей, фаз и стадий, внутренние словари, суммы денег. Исключение было
|
||||
ОДНО — банк памяти: стоп, который снимает пользователь, спрятать нельзя.
|
||||
|
||||
**Почему понадобилось второе.** Владелец заказал (строка 200) полосу с подписью «что делается
|
||||
сейчас». Бар, который движется молча, отвечает хуже бара с подписью, а любая подпись «что происходит»
|
||||
по построению говорит нечто о том, КАК идёт работа. Спрятать её значило бы отказать в заказанном;
|
||||
пропустить молча — завести дыру в границе без имени.
|
||||
|
||||
**Решение: исключение вписано ЯВНО и ОГРАНИЧЕНО двумя условиями,** оба выполняются кодом уже сейчас:
|
||||
1. **Значение ВЫВОДИТСЯ платформой** из тех же счётчиков, что и бар (`runStage` — SQL над теми же
|
||||
колонками), и НИКОГДА не пробрасывается идентификатором из движка. Отсюда же следствие: подпись и
|
||||
бар не могут разойтись между собой.
|
||||
2. **Словарь ОТКРЫТ**, и клиент ОБЯЗАН рисовать незнакомое значение нейтрально. Новые значения
|
||||
ломающими не считаются и версию не поднимают.
|
||||
|
||||
**Ради чего условия и поставлены** — ради канона мультиязычности: пара или конвейер с ИНОЙ формой
|
||||
работы не требуют нового клиента. Захардкоженный закрытый словарь `drafting`/`editing` привязал бы
|
||||
клиента к сегодняшней двухволновой форме, то есть ответ на ревью-вопрос «заработает ли пара, которой
|
||||
в репо ещё НЕТ» стал бы «нет» — в поле, не в Go.
|
||||
|
||||
⚠ **Честная оговорка, добавленная по следствию `PD-410`:** подпись описывает МОДЕЛЬ ПЛАТФОРМЫ о
|
||||
работе прогона, а не отчёт движка — движок об этом не спрашивают и он не отвечает. Где двое
|
||||
расходятся, подпись — приближение, а факт — готовый текст. Без этой оговорки поле читалось бы как
|
||||
свидетельство движка, каким оно не является.
|
||||
|
||||
**Что этой нотой НЕ разрешено:** третьего исключения нет, и «раз уж есть два» аргументом не будет.
|
||||
Каждое следующее — отдельная нота с собственным ограничением, иначе граница перестанет быть границей.
|
||||
|
|
|
|||
|
|
@ -51,30 +51,34 @@ cmp-сверка обязательна (D39.138 п.3).
|
|||
> сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML;
|
||||
> при вопросе «почему так» — этот файл.
|
||||
>
|
||||
> **Статус: РАТИФИЦИРОВАН по 0.4.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) ·
|
||||
> **Статус: РАТИФИЦИРОВАН по 0.6.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) ·
|
||||
> 0.2.2 (D39.129, 10.08) · 0.2.3 (D39.135, 15.08) · **0.3.0 (D39.138, 16.08) — ломающий минор по
|
||||
> целостному ревью research/28** · **0.4.0 (D39.152, 20.08) — синк с платформой, §6в** ·
|
||||
> **0.5.0 (сдано контрактной сессией 27.08, ратификация — акт оркестратора при лендинге) — снос
|
||||
> отменённой пер-термной модели подписи (PD-370) + дверь правок банка, выведенная из построенного
|
||||
> глагола движка (D39.158), + предупреждение о конверте вне `/v0`; вывод и провенанс — §2.19,
|
||||
> отчёт сессии — `docs/CONTRACT_MINOR_REPORT.md`**. Дом канона — этот каталог;
|
||||
> **0.5.0 (D39.161, 27.08) — снос отменённой пер-термной модели подписи (PD-370) + дверь правок
|
||||
> банка, выведенная из построенного глагола движка (D39.158), + предупреждение о конверте вне
|
||||
> `/v0`; вывод и провенанс — §2.19, отчёт сессии — `docs/CONTRACT_MINOR_REPORT.md`** ·
|
||||
> **0.6.0 (D39.162, 28.08) — сквозная полоса прогресса и подпись стадии, приехавшие ОДНИМ лендингом
|
||||
> с платформенным паком P9; вывод и провенанс — §2.20**. Дом канона — этот каталог;
|
||||
> `frontend/docs/api-contract/openapi.yaml` — байт-зеркало.
|
||||
>
|
||||
> ⚠ **0.5.0 ломающий по построению (мажор `0`): снесены путь `POST …/bank/decisions` и три его
|
||||
> схемы, из `BankPage` и `EventBank` сняты `pending_decisions`/`complete` (§2.19-бис), в
|
||||
> `Capabilities.required` добавлен `bank_corrections_enabled`.** Дверь
|
||||
> `POST …/bank/corrections` объявлена И НЕ ОБСЛУЖИВАЕТСЯ до платформенного пака (2в очереди
|
||||
> D39.156): деплой честно говорит это флагом `bank_corrections_enabled: false` и отвечает `404`.
|
||||
> `Capabilities.required` добавлен `bank_corrections_enabled`.**
|
||||
>
|
||||
> ⚠ **Совпадение канона и деплоя при лендинге — точное по ПУТЯМ, не по полям** (находка
|
||||
> опровергателя этой сессии, сверена с кодом): свою половину ПУТИ платформа снесла 22.08, а
|
||||
> проекция `GET /bank` ещё кладёт на провод снятые 0.5.0 счётчики (`wireBankPage`,
|
||||
> `platform/internal/httpapi/reading.go` — `PendingDecisions`/`Complete`; комментарий «the wire
|
||||
> fields are the canon's» с этого минора ложен). Клиент 0.5.0 лишние поля игнорирует по общему
|
||||
> правилу, но аллоулист-норма нарушена, пока пак (2в) не снимет их одной строкой проекции —
|
||||
> носитель: пункт (2в) очереди D39.156, §2.19-бис. Гейт версии
|
||||
> (`TestTheAnnouncedContractVersionIsTheOneTheCanonRatified`) этого класса не ловит — он сверяет
|
||||
> только номер.
|
||||
> ⚠ **Оговорка «дверь объявлена И НЕ ОБСЛУЖИВАЕТСЯ» СНЯТА лендингом P9 (D39.162).** Платформа
|
||||
> смонтировала `POST …/bank/corrections`; флаг `bank_corrections_enabled` теперь следует
|
||||
> включённости прогонов (`platform/cmd/tmplatformd/runner.go:195` — `cfg.RunsEnabled()`), потому что
|
||||
> дверь спавнит тот же глагол движка и без прогонов ей нечем работать. Деплой без прогонов
|
||||
> по-прежнему честно отвечает `404`, и это тот же флаг, а не новый.
|
||||
>
|
||||
> ⚠ **Расхождение «точное по ПУТЯМ, не по полям» — ЗАКРЫТО лендингом P9 (D39.162).** Было: путь
|
||||
> платформа снесла 22.08, а проекция `GET /bank` ещё клала на провод снятые 0.5.0 счётчики
|
||||
> (`wireBankPage` в `platform/internal/httpapi/reading.go` — `PendingDecisions`/`Complete`, носитель
|
||||
> `PD-399`). Полей в проекции больше нет, аллоулист-норма восстановлена.
|
||||
>
|
||||
> ⚠ **Урок оставлен НАМЕРЕННО, он переживает свой дефект: гейт версии этого класса не ловит.**
|
||||
> `TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` сверяет НОМЕР, а не поля — совпадение
|
||||
> версий не значит совпадения форм, и следующее расхождение поля найдёт снова не он.
|
||||
>
|
||||
> ⚠ **0.4.0 ломающий ровно по одному месту, и это ЗАМЕРЕНО, а не объявлено.** Дифф генерённых
|
||||
> типов 0.3.0 → 0.4.0 (`openapi-typescript@7`, комментарии отброшены) — **одна строка:**
|
||||
|
|
@ -251,6 +255,14 @@ the book, while chapter ids survive» (`backend/internal/pipeline/manifest.go:10
|
|||
читало бы ноль всю первую волну, а это и была исходная жалоба К-10, и снятие фаз само по себе её не
|
||||
лечит.
|
||||
|
||||
⚠ **(0.6.0: клауза «той же логикой, что и полоса» ПЕРЕКРЫТА — прежде чем читать дальше.)** Полоса
|
||||
ПРОГОНА сегментной быть перестала: она одна монотонная доля на всю работу прогона и через стоп
|
||||
подписи не обнуляется (§2.20). Глава — по-прежнему состояние ОДНОГО прохода и по-прежнему возвращается
|
||||
к нулю, когда начинается следующий. То есть две величины теперь законно РАСХОДЯТСЯ, и это записано
|
||||
предупреждением в самом каноне (`ChapterProgress.units_done`): клиент, зеркалящий одну в другую,
|
||||
нарисует прыгающий бар. Абзац сохранён как провенанс батча 0.3.0 — вердикт К-10 «пофазные счётчики на
|
||||
главу НЕ строить» им не затронут и в силе.
|
||||
|
||||
**Книжная полоса — отдельная величина.** `Book.chapters_done` против `chapter_count` — прогресс
|
||||
КНИГИ (строка библиотеки), он не откатывается при старте нового прогона. Величина уже считается в
|
||||
SQL и до 0.3.0 не отдавалась: `b.chapter_count - (select count(*) from chapters c where … units_done
|
||||
|
|
@ -741,13 +753,18 @@ the original request was never applied». Каждый `POST /books` созда
|
|||
это рестарт деплоя, `5xx`, ретрай сходится; потолок 1 МиБ → `413`, потолок 5000 и вся валидация
|
||||
формы → `400` `invalid_request`. 14 и 15 получили СВОИ корневые коды: разные адресаты ремеди
|
||||
(пере-решить человек / повторить машина), совет промта принят.
|
||||
⚠ **Три обязательства монтажа (2в), которые раскладка создаёт — названы, чтобы не потерялись
|
||||
(находки опровергателя этой сессии, сверены с кодом):** (а) оба потолка движок классифицирует
|
||||
⚠ **Три обязательства монтажа — ИСПОЛНЕНЫ пакетом P9 (D39.162); список сохранён, потому что
|
||||
первое из трёх монтаж сперва исполнил НЕВЕРНО и это стоило отдельной находки (см. ниже):** (а) оба потолка движок классифицирует
|
||||
КЛАССОМ 14 («the document is well-formed and the deployment is fine», `bankdecisions.go:656-673`),
|
||||
а канон раскладывает их в `400`/`413` — платформа обязана мерить оба на ПРОВОДНОЙ форме до
|
||||
спавна; остаточный случай (HTTP-тело < 1 МиБ, отрендеренный документ шва — больше) падает в
|
||||
`409` `bank_corrections_refused`, чьё описание несёт «a set larger than the service applies in
|
||||
one act»; (б) канонный `400` с `errors[/book_id]` на несовпадение тела с путём производит
|
||||
one act». ⚠ **Здесь монтаж и ошибся:** он мерил ПРОВОДНОЕ тело, а движку отдавал документ,
|
||||
отрендеренный `json.Marshal` с HTML-экранированием (`&`/`<`/`>` — один байт в шесть), так что
|
||||
законное тело под проводным потолком перепрыгивало движковый и возвращалось `409` «пере-решите»
|
||||
вместо канонного `413`. Найдено воркфлоу-ревью P9 замером (не рассуждением: два агента ошибочно
|
||||
положили ось в «не опровергнуто», рассуждая от `omitempty`), вылечено выключением экранирования —
|
||||
документ читает движок, не браузер; (б) канонный `400` с `errors[/book_id]` на несовпадение тела с путём производит
|
||||
ПЛАТФОРМА до спавна — движковая сверка того же факта (`bankdecisions.go:267-269`) есть класс 10,
|
||||
«конфиг вызывающего», и в `400` сама не раскладывается; (в) свои вызовы двери по одной книге
|
||||
монтаж СЕРИАЛИЗУЕТ сам: флок движка держит любой глагол, включая второй `bank-apply` и превью
|
||||
|
|
@ -812,17 +829,53 @@ the original request was never applied». Каждый `POST /books` созда
|
|||
описании `listBankTerms`; фраза «marked unverified inside the service» из того же абзаца снята —
|
||||
это обещание без носителя, снятое ещё ФБ-8 (§6б) и уцелевшее в одном месте.
|
||||
|
||||
Цена, названная честно — ТРЕМЯ половинами: (1) чтение банка больше не отвечает «сколько
|
||||
осталось» — экран узнаёт счёт из квитанции двери правок (`signature`), то есть только имея что
|
||||
послать или что превьюировать; (2) **до монтажа пака (2в) счёт недоступен НИГДЕ** — дверь не
|
||||
обслуживается, чтение не отвечает: это названная цена окна между минорами, а не пробел; (3) до
|
||||
того же монтажа проекция `GET /bank` деплоя ещё шлёт снятые поля (см. ⚠ шапки — лишние поля,
|
||||
клиент их игнорирует; снимает монтаж). Возврат счёта в чтение — день, когда экран подписи закажут
|
||||
Цена, названная честно — ТРЕМЯ половинами, и две из трёх ПОГАШЕНЫ лендингом P9 (D39.162):
|
||||
(1) чтение банка больше не отвечает «сколько осталось» — экран узнаёт счёт из квитанции двери правок
|
||||
(`signature`), то есть только имея что послать или что превьюировать: **эта половина В СИЛЕ**;
|
||||
(2) ~~до монтажа пака (2в) счёт недоступен НИГДЕ~~ — окно между минорами закрыто, дверь смонтирована
|
||||
и отвечает; (3) ~~проекция `GET /bank` деплоя ещё шлёт снятые поля~~ — сняты, носитель `PD-399`
|
||||
закрыт. Возврат счёта в чтение — день, когда экран подписи закажут
|
||||
и движок опубликует нерешённость проекцией (`18-bank-ontology.md`, «Чего эта форма НЕ несёт»);
|
||||
сегодняшние носители лгать не будут.
|
||||
|
||||
---
|
||||
|
||||
### 2.20. Сквозная полоса прогресса и подпись стадии (0.6.0) — ✓ выведено из построенного сервера
|
||||
|
||||
**Что изменилось.** `Progress` перестал быть счётчиком СЕГМЕНТА между двумя стопами и стал ОДНОЙ
|
||||
монотонной долей на всю работу прогона через все его проходы. Оговорка «при снятии стопа счётчик
|
||||
начинается заново с нуля» снесена; добавлен обязательный член `stage`.
|
||||
|
||||
**Почему минор приехал ПОСЛЕ сервера, а не до него.** Требование владельца (строка 200) — одна доля
|
||||
на всю работу прогона, считает СЕРВЕР (иначе клиент снова начнёт знать про фазы). D39.160 изъял его
|
||||
из минора 0.5.0 ровно потому, что гейт версии требует совпадения канона и деплоя В МОМЕНТ лендинга:
|
||||
объявить сквозную полосу раньше, чем сервер её считает, значило бы завести класс `PD-370` в поле
|
||||
вместо пути. Канон догнал сервер тем же коммитом, которым сервер приехал.
|
||||
|
||||
**Провенанс формы — код, а не рассуждение** (`platform/internal/pgstore/readmodel.go`): числитель
|
||||
складывает вклад каждого прохода (`runDone`), знаменатель — работу, которую прогон РЕАЛЬНО должен
|
||||
(`runTotal`), и каждая половина мерится от СВОЕЙ базовой линии прогона (`chapters_before` для
|
||||
последнего прохода, `draft_before` для чернового), после чего каппится покупкой. Отсюда обе новые
|
||||
фразы канона — «счётчики только растут и ничто под ними не движется» и «прогон, кончившийся рано, до
|
||||
`total` не доходит»: первая описывает базовые линии, вторая — то, что доля до единицы обязана
|
||||
доходить лишь у ЗАВЕРШЁННОЙ покупки.
|
||||
|
||||
⚠ **`stage` — ВТОРОЕ исключение из границы «ничего о том, КАК переводится книга».** Первое — банк:
|
||||
стоп, который снимает пользователь, спрятать нельзя. Второе завёл этот минор, потому что бар,
|
||||
который движется молча, отвечает хуже, чем бар с подписью. Исключение ОГРАНИЧЕНО двумя условиями, и
|
||||
оба выполняются кодом уже сейчас: значение ВЫВОДИТСЯ платформой из тех же счётчиков, что и бар
|
||||
(`runStage` — SQL над теми же колонками, а не проброс движковой строки), и словарь ОТКРЫТ — клиент
|
||||
обязан рисовать незнакомое значение нейтрально. Следствие, ради которого условия и поставлены:
|
||||
**пара или конвейер с ИНОЙ формой работы не требуют нового клиента и не поднимают версию.**
|
||||
Сегодняшние значения — `drafting` и `editing`.
|
||||
|
||||
⚠ **Ложная перекрёстная ссылка, снятая этим же минором.** `ChapterProgress.units_done` обещал «то же
|
||||
счетоводство, что у `Progress`, уровнем ниже». После сквозной полосы это неправда: глава по-прежнему
|
||||
обнуляется каждым новым проходом, прогон — больше никогда. Оставленная, фраза научила бы клиента
|
||||
зеркалить одно в другое и рисовать прыгающий бар.
|
||||
|
||||
---
|
||||
|
||||
## 3. Зависимости: чтение → источник → строка бэклога
|
||||
|
||||
**Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки
|
||||
|
|
@ -843,9 +896,9 @@ the original request was never applied». Каждый `POST /books` созда
|
|||
| `PATCH`/`DELETE /books/{id}`, `GET /runs/{id}` | колонки есть | НЕ ПОСТРОЕНО (заведено 0.3.0) | вход P7 |
|
||||
| `GET /books/{id}/chapters`, `/units` | материализация манифеста | **ПОСТРОЕНО P7** — `httpapi/reading.go`, материализатор `internal/readmodel` | закрыто D39.153 |
|
||||
| `GET /books/{id}/notes` | `unit_done` несёт флаг и причину (`runevents.go:126-135`), платформа хранит (`sink.go:227-233`), колонка `notes.reason` заведена под это | канал ЕСТЬ; не хватает карты «причина → код → фраза» (приложение А) и проекции | приложение А + вход P7 |
|
||||
| `GET /books/{id}/bank` | движок пишет сайдкар всего банка (`pipeline/bankexport.go:16-33,72`, D39.122) | ⚠ пере-снято 0.5.0: проекция платформы ПОСТРОЕНА (P7 — маршрут в `contractSurface`, `wireBankPage` в `httpapi/reading.go`, `SaveBank` в `pgstore`; прежняя запись «не хватает проекции» устарела при израсходованном носителе «вход P7», D39.153); живой дефект другой — проекция несёт форму 0.4.0 со снесёнными счётчиками, см. ⚠ шапки | пункт (2в) очереди D39.156 — снять счётчики при монтаже |
|
||||
| `GET /books/{id}/bank` | движок пишет сайдкар всего банка (`pipeline/bankexport.go:16-33,72`, D39.122) | ⚠ пере-снято 0.5.0: проекция платформы ПОСТРОЕНА (P7 — маршрут в `contractSurface`, `wireBankPage` в `httpapi/reading.go`, `SaveBank` в `pgstore`; прежняя запись «не хватает проекции» устарела при израсходованном носителе «вход P7», D39.153); форма синхронна канону с лендингом P9: счётчики `pending_decisions`/`complete` сняты с проекции (`PD-399`) | закрыто D39.162 |
|
||||
| ~~`POST /bank/decisions`~~ | стоп-механика майнера | ⚠ **НЕ «не построено», а ОТМЕНЕНО**: было построено P7 и СНЯТО 22.08 вместе с пер-термной моделью подписи (D39.144, слово владельца). **0.5.0 снёс и канон-половину — `PD-370` закрыт этим минором**; преемник — строка `POST …/bank/corrections` ниже | отменено D39.144; снесено 0.5.0 |
|
||||
| `POST /books/{bookId}/bank/corrections` | `tmctl bank-apply` — движковая половина ПОСТРОЕНА (D39.158, лендинг `d1eb8a9`) | **объявлено 0.5.0, платформой НЕ обслуживается**: деплой говорит это `Capabilities.bank_corrections_enabled: false` и отвечает `404`; монтаж (перевод словаря, спавн глагола, раскладка отказов) — платформенный пак | **пункт (2в) очереди D39.156** |
|
||||
| `POST /books/{bookId}/bank/corrections` | `tmctl bank-apply` — движковая половина ПОСТРОЕНА (D39.158, лендинг `d1eb8a9`) | **ПОСТРОЕНО пакетом P9**: перевод словаря (`platform/internal/ingest/bankdecisions.go`), спавн глагола (`runner/bankapply.go`), раскладка отказов и пер-книжная сериализация (`runs/bank.go`), дверь синхронная. Флаг `bank_corrections_enabled` следует включённости прогонов (`cmd/tmplatformd/runner.go:195`) — дверь спавнит тот же глагол | закрыто D39.162 |
|
||||
| `GET /books/{id}/events` (SSE) | эмиттер шва построен (D39.131) | **ПОСТРОЕНО P7** — `httpapi/stream.go`, поток регистрируется ВНЕ слоя сжатия (сжатие буферизует поток — единственное, что канон запрещает этому маршруту) | закрыто D39.153 |
|
||||
| `POST`/`GET /exports` | у движка только stdout-JSON и `--plaintext` (`cmd/tmctl/invocation.go:107`) | НЕ ПОСТРОЕНО с обеих сторон | строка 49 / D29.1 «tmctl export-контракт» |
|
||||
| Условные чтения (`ETag`/304), сжатие | — | **ПОСТРОЕНО P7** — `httpapi/conditional.go`; валидатор считается от БАЙТ ответа. Остаток строки 186 — шаги 3–5 (скоуп кадра, дельта-чтение, `staleTime`) | шаги 1–2 закрыты D39.153 |
|
||||
|
|
@ -856,7 +909,7 @@ the original request was never applied». Каждый `POST /books` созда
|
|||
| Настоящие названия глав (`Chapter.heading` ≠ null) | парсер структуры | НЕ ПОСТРОЕНО | строка 160 (Этап 0) |
|
||||
| `title_raw` / `kind` (глава ↔ фрагмент) | дизайн-пак структуры глав | передано паку, аддитивно | строка 161 |
|
||||
| `ErrorCode.content_refused` (400) **и** `RejectReason.content_refused` | прескрин злоупотреблений | НЕ ПОСТРОЕН ни на одной стороне: в платформе только объявление константы (`httpapi/problem.go:42,94`), `ContractRejectReason` (`ingest/vocabulary.go:59-69`) его не отображает; в движке отказ провайдера живёт как ПРИЧИНА ЗАМЕЧАНИЯ (`disposition.go:60-63` → `Note.code: content_withheld`) и в exit-контракт не выходит — мостá между двумя словарями нет | **строка 94 (ПТ-16)**; там же ограничение числа попыток аккаунта — обязанность падает ВМЕСТЕ с производителем, не раньше |
|
||||
| `decline` в подписи банка доезжает до работы | ⚠ пере-снято 0.5.0: старый носитель («таблица `bank_decisions`, движок её не читает») умер вместе с моделью — таблица снесена 22.08, предупреждение на `BankDecision.action` снесено вместе со схемой | новая дверь пишет ФАЙЛЫ ДВИЖКА (`tmctl bank-apply`), decline доезжает до следующего прогона ПО ПОСТРОЕНИЮ — как только платформа смонтирует дверь; до монтажа двери нет вовсе (флаг `bank_corrections_enabled`) | **пункт (2в) очереди D39.156** |
|
||||
| `decline` в подписи банка доезжает до работы | ⚠ пере-снято 0.5.0: старый носитель («таблица `bank_decisions`, движок её не читает») умер вместе с моделью — таблица снесена 22.08, предупреждение на `BankDecision.action` снесено вместе со схемой | дверь смонтирована (D39.162), и decline доезжает до следующего прогона ПО ПОСТРОЕНИЮ. ⚠ **Остаток ДВИЖКОВЫЙ, две штуки, обе найдены воркфлоу-ревью P9:** decline не энтити-широк на обратном пути (отклонённая сущность возвращается через свой АЛИАС, `mining.go:548`), и повтор ТОГО ЖЕ документа на одном пути не сходится (`membank/decisions.go:374`) — второе ломает обещание сходимости, на котором стоит синхронная дверь | заказано бэкенд-паку «тихая порча», `docs/BACKEND_SILENT_HARM_SESSION_PROMPT.md` §3.3 |
|
||||
| Снятие замечания (переход «флаг снят») | движок | **НЕДОСТИЖИМО сегодня, проверено чтением движка** — п. H §6в | **PD-298** регистра платформы (`platform/docs/DEFECT_REGISTER.md`) — там строка и живёт; механизма не строим |
|
||||
| Счёт «сделанного» на деплое без второго прохода | платформа | канон 0.4.0 определил «сделано» = последний проход ЭТОГО деплоя; проекция платформы считает жёстко второй проход | **вход P7** (PD-202) |
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ openapi: 3.1.0
|
|||
|
||||
info:
|
||||
title: TextMachine API
|
||||
version: 0.5.0
|
||||
version: 0.6.0
|
||||
summary: Ratified contract between the frontend and the TextMachine platform.
|
||||
description: |
|
||||
**RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`;
|
||||
|
|
@ -15,10 +15,17 @@ info:
|
|||
The client reads the platform read-model only; no path below addresses the translation service.
|
||||
|
||||
**Nothing about HOW a book is translated crosses this boundary** — no model names, no phase or
|
||||
stage names, no internal vocabularies, no money sums. The one exception is the book's memory
|
||||
bank: the work stops there for a signature, and a stop the user must clear cannot be hidden. The
|
||||
projection is an allowlist: a field not named here never reaches the client. The rule binds the
|
||||
PROSE too — every description here is compiled into the generated client's source.
|
||||
stage names, no internal vocabularies, no money sums. The projection is an allowlist: a field not
|
||||
named here never reaches the client. The rule binds the PROSE too — every description here is
|
||||
compiled into the generated client's source.
|
||||
|
||||
**Two exceptions, both bounded, both because hiding the fact would make the wire lie.** The book's
|
||||
memory bank: the work stops there for a signature, and a stop the user must clear cannot be
|
||||
hidden. And `Progress.stage`: a bar that moves without saying what is moving answers worse than a
|
||||
caption does. The second carries its own bound — the value is DERIVED by the platform from the
|
||||
counters it already sends, never an identifier forwarded from the translation service, and its
|
||||
vocabulary is OPEN, so a client that meets a value it does not know renders it neutrally and a
|
||||
deployment whose work has a different shape needs no new client.
|
||||
|
||||
## On every response
|
||||
|
||||
|
|
@ -1267,7 +1274,7 @@ components:
|
|||
The version this deployment serves — the only place a non-streaming client learns it. A
|
||||
client generated against a different one REFUSES to work and says so: while the major is
|
||||
`0` a differing minor carries breaking changes by design.
|
||||
examples: ['0.5.0']
|
||||
examples: ['0.6.0']
|
||||
language_pairs:
|
||||
type: array
|
||||
description: |
|
||||
|
|
@ -1334,35 +1341,57 @@ components:
|
|||
Progress:
|
||||
type: object
|
||||
description: |
|
||||
How far the current SEGMENT of work has got, in chapters. A segment is the work between two
|
||||
stops: to the point where the run stops for the bank to be signed, and from there to the end
|
||||
of what the run bought. **When a stop is cleared the counter starts again from zero** — the
|
||||
two segments cover the same chapters and are never added together or compared.
|
||||
How far THIS RUN has got through the whole of its own work, in chapters, as ONE monotonic
|
||||
fraction. A run may make more than one pass over the chapters it bought, and it may stop
|
||||
part-way for the book's terms to be signed; the counter spans all of that. **It never
|
||||
restarts from zero** — not when a signing stop is cleared, not when one pass gives way to the
|
||||
next. The counters only grow and nothing under them moves.
|
||||
|
||||
**`total` is what this run BOUGHT**, not the length of the book, so the fraction always
|
||||
reaches one. The book-wide figure is `Book.chapters_done` against `Book.chapter_count` and
|
||||
answers a different question.
|
||||
**`total` is what this run BOUGHT**, counted across the passes this run actually owes — not
|
||||
the length of the book. The book-wide figure is `Book.chapters_done` against
|
||||
`Book.chapter_count` and answers a different question.
|
||||
|
||||
⚠ **A run that ends early stops short of `total`** — at its ceiling, stopped by the user, or
|
||||
failed. Reaching one is the shape of a COMPLETED purchase, not a promise about every run, and
|
||||
a fraction below one is not an error to render.
|
||||
|
||||
⚠ **"Finished" means the work THIS deployment does on a chapter is finished** — the last pass
|
||||
the book actually gets, whatever that is.
|
||||
|
||||
No ready-made percentage is shipped: how a fraction is drawn is a product decision.
|
||||
required: [done, total, eta_seconds]
|
||||
required: [done, total, stage, eta_seconds]
|
||||
properties:
|
||||
done:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Chapters finished in this segment.
|
||||
description: Chapters of this run's own work that are finished.
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Chapters this segment covers — what the run bought.
|
||||
description: Chapters of work this run bought, counted across the passes it owes.
|
||||
stage:
|
||||
type: string
|
||||
description: |
|
||||
What the run is doing NOW — the caption beside the bar, phrased by the client.
|
||||
|
||||
**The vocabulary is OPEN, and a client MUST render a value it does not know neutrally**
|
||||
— the bar alone, or a generic phrase — rather than failing, hiding the run, or guessing.
|
||||
Today's deployments answer `drafting` or `editing`; other values are not a breaking
|
||||
change and do not raise this version.
|
||||
|
||||
⚠ Derived by the platform from the same counters as `done` and `total`, so the caption
|
||||
and the bar cannot disagree with each other. It is never an identifier forwarded from
|
||||
inside the translation service — see the bound on this exception under **Boundaries**.
|
||||
|
||||
⚠ **It therefore describes the platform's model of the run's work, not a report from the
|
||||
service**, which is neither asked nor answers. Where the two diverge, the caption is the
|
||||
approximation and the finished text is the fact.
|
||||
eta_seconds:
|
||||
type: [integer, 'null']
|
||||
minimum: 0
|
||||
description: |
|
||||
Estimated seconds to the end of the segment, or `null` when there is nothing to estimate
|
||||
from. The screen renders without it rather than showing a zero.
|
||||
Estimated seconds to the end of this run's work, or `null` when there is nothing to
|
||||
estimate from. The screen renders without it rather than showing a zero.
|
||||
|
||||
BookStatus:
|
||||
type: string
|
||||
|
|
@ -1716,14 +1745,17 @@ components:
|
|||
type: integer
|
||||
minimum: 0
|
||||
description: |
|
||||
Pairs of this chapter finished IN THE CURRENT PASS over the book — the same accounting
|
||||
as `Progress`, one level down. `0` for a chapter the pass has not reached, `units_total`
|
||||
Pairs of this chapter finished IN THE CURRENT PASS over the book. `0` for a chapter the
|
||||
pass has not reached, `units_total`
|
||||
for one it has finished; it restarts from zero for the chapters a NEW pass re-walks, and
|
||||
a chapter outside the current pass keeps what the last pass left. Counted end to end
|
||||
instead, the tree would read zero through the whole first pass.
|
||||
|
||||
⚠ The state of a PASS, not the lifetime of the chapter, so it can legally return to zero.
|
||||
The lifetime figure is `Book.chapters_done`.
|
||||
The lifetime figure is `Book.chapters_done`. **And it is NOT the accounting `Progress`
|
||||
uses**: the run's bar spans every pass and never goes back, this figure returns to zero
|
||||
when the next pass begins. A client that mirrors one into the other draws a bar that
|
||||
jumps.
|
||||
note_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
|
@ -2494,7 +2526,7 @@ components:
|
|||
|---|---|---|
|
||||
| `hello` | `EventHello` | always the first frame |
|
||||
| `status` | `EventStatus` | product status changed |
|
||||
| `progress` | `EventProgress` | the segment counter moved |
|
||||
| `progress` | `EventProgress` | the run's counter moved |
|
||||
| `chapter` | `EventChapter` | a chapter's own progress changed |
|
||||
| `note` | `EventNote` | a note appeared |
|
||||
| `bank` | `EventBank` | the bank changed, or a signing stop happened |
|
||||
|
|
@ -2572,8 +2604,8 @@ components:
|
|||
properties:
|
||||
contract:
|
||||
type: string
|
||||
description: Contract version this deployment serves, e.g. `0.5.0`.
|
||||
examples: ['0.5.0']
|
||||
description: Contract version this deployment serves, e.g. `0.6.0`.
|
||||
examples: ['0.6.0']
|
||||
|
||||
EventStatus:
|
||||
allOf:
|
||||
|
|
@ -2603,7 +2635,7 @@ components:
|
|||
allOf:
|
||||
- $ref: '#/components/schemas/EventBase'
|
||||
- type: object
|
||||
description: The segment counter moved. Applied as it is; no read follows.
|
||||
description: The run's counter moved. Applied as it is; no read follows.
|
||||
required: [progress]
|
||||
properties:
|
||||
progress: { $ref: '#/components/schemas/Progress' }
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
*Почему жёстко:* движок пере-читает сид ПОСРЕДИ прогона (`mining.go:221`), запись в живой прогон
|
||||
въехала бы в снапшот недетерминированно. Потолок посреди прогона менять НЕЛЬЗЯ — он едет со
|
||||
стартом и после не меняется, лекарство — новый прогон
|
||||
(`platform/internal/runs/reconcile.go:1228`=`answering 202 with the run in the state`). Дверь «менять потолок файлом» закрыта;
|
||||
(`platform/internal/runs/reconcile.go:1251`=`answering 202 with the run in the state`). Дверь «менять потолок файлом» закрыта;
|
||||
не выдумывать её заново из `research/25`.
|
||||
|
||||
3. **Каждый JSON-выход глагола несёт версию документа.** Сегодня асимметрия: `tm-bank-v1`
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
границы процессов; единственный мид-ран кандидат закрыт каноном (п.2); инбокс-файл возвращает
|
||||
гонку, ради исключения которой существует flock.
|
||||
⚠ **Эррата, которую ратифицирующая нота обязана поставить на `research/25`:** там «поднятие потолка файлом через
|
||||
atomic rename» отнесено к носителям D39.106 — в теле D39.106 этого механизма НЕТ вовсе (проверено по ЖИВОМУ телу `architecture/05-decisions-log.md:239`=`ШОВ: прогон переживает деплой платформы`; ⚠ испр. 23.08 — прежняя редакция ссылалась на слайс
|
||||
atomic rename» отнесено к носителям D39.106 — в теле D39.106 этого механизма НЕТ вовсе (проверено по ЖИВОМУ телу ноты **D39.106** («ШОВ: прогон переживает деплой платформы») в `architecture/05-decisions-log.md` — адресуем НОМЕРОМ, а не строкой: прежний line-якорь уже съехал внутрь чужого тела (гейт якорей, 28.08); ⚠ испр. 23.08 — прежняя редакция ссылалась на слайс
|
||||
`archive/architecture/05-decisions-D39-106-123.md`, где тела D39.106 НЕТ, и греп по нему подтвердил бы эррату вакуумно; первый хит темы — только в D39.110). Строка была КАНДИДАТОМ и
|
||||
никогда не ратифицировалась, а живой канон решил ось иначе (п.2).
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
> Отчёт относит этот механизм к носителям решения D39.106 — в теле D39.106 его НЕТ вовсе (проверено грепом по слайсу
|
||||
> живого тела D39.106 — `../architecture/05-decisions-log.md:241`=`ШОВ: прогон переживает деплой платформы`; первый хит темы — только в D39.110. ⚠ Испр. 23.08: прежняя редакция ссылалась на слайс `archive/architecture/05-decisions-D39-106-123.md`, где тела D39.106 НЕТ — греп по нему подтвердил бы эррату вакуумно). Строка была КАНДИДАТОМ панели,
|
||||
> а живой канон решил ось иначе: **потолок едет со стартом прогона и после не меняется**, лекарство при упоре — НОВЫЙ прогон
|
||||
> (`platform/internal/runs/reconcile.go:1228`=`answering 202 with the run in the state`). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого
|
||||
> (`platform/internal/runs/reconcile.go:1251`=`answering 202 with the run in the state`). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого
|
||||
> кандидата, а не как обязательный механизм; закон входной двери шва (`../architecture/17-seam-inbound-law.md` п.2) это фиксирует:
|
||||
> посреди прогона внутрь идёт ТОЛЬКО сигнал.
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ func runsConfig(cfg config.Config, marker []string, ceiling []string) runs.Confi
|
|||
EngineBinary: cfg.Runner.EngineBinary,
|
||||
MarkerArgv: marker,
|
||||
Ceiling: ceiling,
|
||||
KeysFile: cfg.Runner.KeysFile,
|
||||
MemoryMax: cfg.Runner.MemoryMax,
|
||||
TasksMax: cfg.Runner.TasksMax,
|
||||
AllowEngineVersionChange: cfg.Runner.AllowEngineVersionChange,
|
||||
|
|
@ -71,6 +72,12 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log
|
|||
// account's money under the BOOK's ceiling instead of the one the user chose (row 145).
|
||||
log.Warn("TM_PLATFORM_ENGINE_CEILING_ARG is empty: starting a run will be refused rather than run under the book's own ceiling (row 145)")
|
||||
}
|
||||
if cfg.Runner.KeysFile == "" {
|
||||
// Runs still start: a stand may keep a `.env` beside each book. What cannot be allowed is the
|
||||
// silent version — a SaaS deployment whose engine gets no keys from anywhere fails at the first
|
||||
// provider call, hours of queueing later (row 211).
|
||||
log.Warn("no TM_PLATFORM_ENGINE_KEYS_PATH: the engine is passed no provider keys; a run finds them only in a .env beside its own book.yaml")
|
||||
}
|
||||
marker, err := markerArgv(cfg.Runner.MarkerBinary)
|
||||
if err != nil {
|
||||
// Reads keep working. Losing the exit-marker command means a finished run could not be
|
||||
|
|
@ -89,10 +96,14 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log
|
|||
Store: db,
|
||||
Runner: rn,
|
||||
Engine: rn,
|
||||
Bank: rn,
|
||||
Pricing: model,
|
||||
Cfg: runsConfig(cfg, marker, ceiling),
|
||||
Log: log,
|
||||
}
|
||||
// Before the door serves: a decision document still on disk here outlived its process, and
|
||||
// nothing else ever deletes by that mask (PD-409's cure).
|
||||
svc.SweepCorrectionScratch()
|
||||
intake, err := startIntake(cfg, db, rn, reader, log, deps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -110,6 +121,9 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log
|
|||
}
|
||||
svc.Queue = queue
|
||||
deps.Runs = svc
|
||||
// The correction door mounts with the run machinery: it needs the same engine binary, the same
|
||||
// state directory and the same per-book serialization. The capability flag says the same fact.
|
||||
deps.Bank = svc
|
||||
if intake != nil {
|
||||
intake.Queue = queue
|
||||
}
|
||||
|
|
@ -174,8 +188,12 @@ func capabilities(cfg config.Config) httpapi.Capabilities {
|
|||
IntakeMaxBytes: cfg.Intake.MaxUploadBytes,
|
||||
// None are built here yet: the export path is deferred, and answering a format this
|
||||
// deployment cannot produce would be a promise the first click discovers is empty.
|
||||
ExportFormats: []string{},
|
||||
PageSizeDefault: pgstore.DefaultPage,
|
||||
ExportFormats: []string{},
|
||||
// The same condition that mounts the door (startRunner sets deps.Bank exactly when runs are
|
||||
// enabled): the flag and the 404 must be one fact, or a client learns the truth by failing
|
||||
// a user's save.
|
||||
BankCorrectionsEnabled: cfg.RunsEnabled(),
|
||||
PageSizeDefault: pgstore.DefaultPage,
|
||||
}
|
||||
for _, p := range cfg.LanguagePairs {
|
||||
caps.Pairs = append(caps.Pairs, httpapi.LanguagePair{
|
||||
|
|
|
|||
|
|
@ -56,12 +56,18 @@ func TestTheOperatorsRunnerKnobsReachTheReconciler(t *testing.T) {
|
|||
cfg.Runner.AllowEngineVersionChange = true
|
||||
cfg.Runner.ResyncEvery = 5 * time.Minute
|
||||
cfg.Runner.RunBudget = 90 * time.Second
|
||||
cfg.Runner.KeysFile = "/etc/tm/keys.env"
|
||||
|
||||
got := runsConfig(cfg, []string{"/usr/bin/tmplatformctl", "exit-marker"}, []string{"--max-cost"})
|
||||
switch {
|
||||
case got.RunBudget != cfg.Runner.RunBudget:
|
||||
t.Errorf("RunBudget is %v, want the operator's %v: TM_PLATFORM_RUN_BUDGET does nothing",
|
||||
got.RunBudget, cfg.Runner.RunBudget)
|
||||
case got.KeysFile != cfg.Runner.KeysFile:
|
||||
// The exact regression class this test exists for: dropped here, the engine silently starves
|
||||
// for keys again (row 211) at the price of a whole paid run.
|
||||
t.Errorf("KeysFile is %q, want the operator's %q: TM_PLATFORM_ENGINE_KEYS_PATH does nothing",
|
||||
got.KeysFile, cfg.Runner.KeysFile)
|
||||
case got.ResyncEvery != cfg.Runner.ResyncEvery:
|
||||
t.Errorf("ResyncEvery is %v, want %v", got.ResyncEvery, cfg.Runner.ResyncEvery)
|
||||
case got.StateDir != cfg.Runner.StateDir || got.EngineBinary != cfg.Runner.EngineBinary:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -434,6 +434,11 @@ callback не `http://`, — отказ на старте: §30) ·
|
|||
Дев-стенд: `TM_PLATFORM_DEV_LOGIN` (личность дев-входа; вместе с OIDC — отказ на старте, §30).
|
||||
Наблюдаемость: `TM_PLATFORM_METRICS_ADDR` (дефолт `127.0.0.1:9464`; пусто = метрики не отдаются).
|
||||
Раннер: `TM_PLATFORM_ENGINE_BIN` (версионированный путь tmctl; пусто = инстанс только читает) ·
|
||||
`TM_PLATFORM_ENGINE_KEYS_PATH` (только АБСОЛЮТНЫЙ путь к файлу провайдерских ключей деплоя,
|
||||
KEY=VALUE; едет движку АРГУМЕНТОМ `--keys-file` на `translate` — строка 211, ключи не проходят ни
|
||||
через процесс платформы, ни через окружение юнита; пусто = флаг не передаётся и движок зависит от
|
||||
`.env` рядом с book.yaml, которого SaaS-путь не пишет — WARN на буте; ⚠ суффикс `_PATH`, потому что
|
||||
`*_FILE` в этой зоне значит «файл со ЗНАЧЕНИЕМ секрета», а тут значение — сам путь) ·
|
||||
`TM_PLATFORM_STATE_DIR` (только АБСОЛЮТНЫЙ путь: маркер пишет юнит из каталога книги, читает демон
|
||||
из своего — относительный назвал бы два разных файла; отказ на буте) · `TM_PLATFORM_CTL_BIN` ·
|
||||
`TM_PLATFORM_ENGINE_CEILING_ARG` (шаблон с `{{usd}}`; ДЕФОЛТ — `--ceiling-usd {{usd}}`, залендённая
|
||||
|
|
|
|||
169
platform/docs/p9/door-live-probe.md
Normal file
169
platform/docs/p9/door-live-probe.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# P9 — транскрипт живого пробоя двери правок банка (27.08.2026)
|
||||
|
||||
Требование §4.2 промта пака: дверь пробита ЖИВЫМ стендом против НАСТОЯЩЕГО движка. Ниже — команды
|
||||
и СЫРЫЕ фрагменты их вывода из сессии, дословно (норма «заявление = команда»; вторая редакция —
|
||||
первая была пересказом, что приёмка справедливо назвала).
|
||||
|
||||
## Стенд
|
||||
|
||||
Демон и движок собраны из дерева сессии; свои порты/БД/каталоги, чужой стенд не тронут:
|
||||
|
||||
```sh
|
||||
W=~/tm-p9-work
|
||||
cd backend && go build -o $W/tmctl ./cmd/tmctl
|
||||
cd platform && go build -o $W/tmplatformd ./cmd/tmplatformd && go build -o $W/tmplatformctl ./cmd/tmplatformctl
|
||||
~/.local/pgsql/bin/psql -h 127.0.0.1 -p 5432 -U postgres -d postgres -c "create database tmp9stand"
|
||||
# env: TM_PLATFORM_DSN=…/tmp9stand · _MIGRATE=1 · _ADDR=127.0.0.1:8123 · _DEV_LOGIN=p9dev ·
|
||||
# _INSECURE_COOKIES=1 · _BOOKS_DIR=$W/stand/books · _STATE_DIR=$W/stand/state ·
|
||||
# _ENGINE_BIN=$W/tmctl · _BOOK_TEMPLATE=$W/book-template.yaml · _CTL_BIN=$W/tmplatformctl ·
|
||||
# _LANGUAGE_PAIRS='zh>ru' · _METRICS_ADDR=off · _ENGINE_KEYS_PATH=<repo>/backend/.env
|
||||
nohup $W/tmplatformd > $W/stand/daemon.log 2>&1 &
|
||||
curl -s --noproxy '*' http://127.0.0.1:8123/readyz # → ready
|
||||
$W/tmplatformctl seed --url http://127.0.0.1:8123
|
||||
```
|
||||
|
||||
Вывод seed (дословно):
|
||||
|
||||
```
|
||||
account u_QNMBL43CZVBE42IJ (identity dev/p9dev)
|
||||
credit granted 25.000000 to u_QNMBL43CZVBE42IJ (key seed-u_QNMBL43CZVBE42IJ); balance is 25.000000
|
||||
book bk_QLS63SDD3NWF2YUV (parsing)
|
||||
intake not_started, 3 chapters
|
||||
```
|
||||
|
||||
## Почему провайдер — локальная $0-заглушка
|
||||
|
||||
Первый прогон демо-книги упал классом 10; журнал юнита (дословно):
|
||||
|
||||
```
|
||||
tmctl[823168]: tmctl: missing API keys (fill in backend/.env):
|
||||
tmctl[823168]: - provider deepseek: env DEEPSEEK_API_KEY is not set (needed for model deepseek-v4-flash)
|
||||
```
|
||||
|
||||
Механика `--keys-file` при этом доказана двумя $0-пробами на КОПИИ книги (значения ключей в сессию
|
||||
не читались — гардрейл `.env` цел):
|
||||
|
||||
```
|
||||
$ tmctl translate --config book.yaml --keys-file /nonexistent/keys.env --ceiling-usd 0.05
|
||||
tmctl: --keys-file /nonexistent/keys.env cannot be read: open /nonexistent/keys.env: no such file or directory
|
||||
$ echo "DEEPSEEK_API_KEY=sk-dummy-p9-probe" > keys-dummy.env && tmctl translate … --keys-file keys-dummy.env …
|
||||
… msg="calling model" model=deepseek-v4-flash attempt=0 … # пре-флайт пройден, файл загружен
|
||||
```
|
||||
|
||||
Пробы остальных провайдеров тем же способом (`--ceiling-usd 0.000001`: отказ резервации ДО вызова,
|
||||
`committed=$0.000000 reserved=$0.000000`, exit 10 = ключа нет):
|
||||
|
||||
```
|
||||
gemini-3.1-pro-preview EXIT=10 :: … env GEMINI_API_KEY is not set …
|
||||
mistral-large-2512 EXIT=10 :: … env MISTRAL_API_KEY is not set …
|
||||
gpt-5-mini EXIT=10 :: … env OPENAI_API_KEY is not set …
|
||||
```
|
||||
|
||||
Живого облачного ключа в файле деплоя нет ⇒ пробой цепи — на `provider: local` (models.yaml),
|
||||
которого на стенде обслуживает OpenAI-совместимая заглушка `$W/fake_provider.py` (переводчику —
|
||||
проза, терминологу/классификатору — TSV по строкам `key:`). Майнингу даны `langpack_root` (реальные
|
||||
langpacks движка) и фабрикованный jieba-контраст (76 частотных слов); книга — крафтовый исходник
|
||||
(方源 ×54, 苏长老, 青云山; 3 главы, 1222 байта).
|
||||
|
||||
## Цепь, команда за командой
|
||||
|
||||
Загрузка и старт (сессия `curl -c jar` через `/auth/dev-login`, всюду `-H "X-TM-Client: p9"`):
|
||||
|
||||
```
|
||||
$ curl … -F title="Проба P9" -F source_lang=zh -F target_lang=ru -F file=@probe-book.txt /v0/books
|
||||
"id": "bk_MNPEAQTOVKK5SMCI", "status": "parsing" … → not_started 3
|
||||
$ curl … -d '{"stop_for_signing":true,"ceiling_chapters":3}' /v0/books/bk_MNPEAQTOVKK5SMCI/runs
|
||||
run_BMLFFO5TCESMXRW4 translating {'done': 0, 'total': 6, 'stage': 'drafting', 'eta_seconds': None}
|
||||
```
|
||||
|
||||
Стоп достигнут (поллер карточки, дословно): `23:12:10 awaiting_bank 3 6 editing` — полоса на стопе
|
||||
50%, НЕ обнулилась. Чтение банка на стопе:
|
||||
|
||||
```
|
||||
$ curl … /v0/books/bk_MNPEAQTOVKK5SMCI/bank
|
||||
{"revision": 23, "next_cursor": null, "structure_version": 1, "total": 0, "signed": 0, "terms": []}
|
||||
```
|
||||
|
||||
— ни `pending_decisions`, ни `complete` (PD-399 живьём); пустота банка на стопе — строка 224
|
||||
(читающая сторона — движковый пак). Поверхности стопа — из карты подписи на сервере
|
||||
(`sample-zh.db.mined-signature.yaml`: `方源`, `苏长老`, …; `.bank-stop.txt`: «BANK VERIFICATION
|
||||
TABLE — 3 term(s)»).
|
||||
|
||||
ПРЕВЬЮ (approve 方源 → «Фан Юань-П9» kind name + decline 苏长老), ответ дословно:
|
||||
|
||||
```
|
||||
{"preview": true, "changed": true, "depth": "refinement",
|
||||
"accepted": [
|
||||
{"index": 0, "action": "approve", "id": "e1e2b5625d031d33", "src": "方源",
|
||||
"dst": "Фан Юань-П9", "state": "applied", "displaced": false},
|
||||
{"index": 1, "action": "decline", "id": "bf4a082f8bf17aed", "src": "苏长老",
|
||||
"dst": null, "state": "applied", "displaced": false}],
|
||||
"preexisting_faults": 0,
|
||||
"signature": {"surfaces": 3, "undecided": 1, "unreadable": false}}
|
||||
```
|
||||
|
||||
ПРАВКА и ПОВТОР того же документа (вывод скрипта сверки, дословно):
|
||||
|
||||
```
|
||||
== APPLY ==
|
||||
preview False changed True accepted [('方源', 'applied', False), ('苏长老', 'applied', False)] signature {'surfaces': 3, 'undecided': 1, 'unreadable': False}
|
||||
== RETRY SAME ==
|
||||
changed False states ['already_applied', 'already_applied']
|
||||
```
|
||||
|
||||
ОТКАЗ (противоречие в одном документе: decline 方源 + approve 方源):
|
||||
|
||||
```
|
||||
code bank_corrections_refused refusals [('/corrections/1', 'decision 0 already decides this term (sense "", window [0,0]')]
|
||||
HTTP=409
|
||||
```
|
||||
|
||||
RESUME и правка при живом прогоне (второй замер — на живом прогоне демо-книги, тело дословно):
|
||||
|
||||
```
|
||||
$ curl … /v0/runs/run_BMLFFO5TCESMXRW4/resume
|
||||
translating {'done': 3, 'total': 6, 'stage': 'editing', 'eta_seconds': 1} # полоса ПРОДОЛЖИЛА с 3/6
|
||||
$ curl … -d @corr-apply.json /v0/books/…/bank/corrections # прогон живой
|
||||
{"type":"about:blank","title":"Run in flight","status":409,"code":"run_in_flight","request_id":"5M6SCTCU4XYQP5P2"}
|
||||
```
|
||||
|
||||
Финал (поллер + чтение банка, дословно):
|
||||
|
||||
```
|
||||
23:14:04 ready 6 6 editing
|
||||
$ curl … /v0/books/bk_MNPEAQTOVKK5SMCI/bank
|
||||
{"revision": 34, …, "total": 2, "signed": 1, "terms": [
|
||||
{"id": "tm_73f73a5b396d7d40b68bac09", "src": "方源", "dst": "Фан Юань-П9",
|
||||
"kind": "name", "status": "approved", …},
|
||||
{"id": "tm_9e446a871eb17f39eade265c", "src": "青云山", "dst": "", "kind": "place",
|
||||
"status": "proposed", "since_chapter": 1, …}]}
|
||||
```
|
||||
|
||||
Следующий прогон правку УВИДЕЛ: `方源` — approved с моим dst; задеклайненный `苏长老` из
|
||||
предложений исчез; нерешённый `青云山` стоит proposed.
|
||||
|
||||
## Деньги (ось §5.1 — леджер двумя путями)
|
||||
|
||||
```
|
||||
$ $W/tmplatformctl balance --user u_QNMBL43CZVBE42IJ
|
||||
balance 25.000000
|
||||
$ psql … -d tmp9stand -c "select kind, count(*), sum(amount_micro_usd) from credit_ledger group by kind order by kind"
|
||||
grant | 1 | 25000000
|
||||
hold | 3 | -270000
|
||||
hold_release | 3 | 270000
|
||||
settlement | 3 | 0
|
||||
```
|
||||
|
||||
Сошлось до цента. Три холда = три попытки (демо-книга exit 10; пробная книга, попытки 1 и 2 через
|
||||
resume); расчёты $0 — заглушка. Оговорка силы замера: расчёты нулевые, открытого холда в момент
|
||||
сверки не было; сверку на открытом холде и после ненулевого расчёта исполнил ревьюер приёмки на
|
||||
этом же дереве — расхождений нет.
|
||||
|
||||
## Что пробой доказал
|
||||
|
||||
Цепь строк 199/202 срослась: правка термина через контрактную дверь доезжает до движковых файлов
|
||||
решений, возобновление пере-собирает банк с ней, читающая поверхность следующей границы её
|
||||
показывает. Плюс живьём: сквозная полоса через обе волны без обнуления на стопе; PD-399 не на
|
||||
проводе; превью/идемпотентность/отказ/взаимоисключение — по канону 0.5.0. Ограничение честно:
|
||||
качество перевода и поведение под латентностью настоящего провайдера пробой НЕ доказывает —
|
||||
заглушка, строка 202 «по-настоящему» ждёт живого ключа.
|
||||
|
|
@ -4,6 +4,434 @@
|
|||
> вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор
|
||||
> читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).
|
||||
|
||||
## ФИКС-РАУНД ПО ДИСПОЗИЦИЯМ ОРКЕСТРАТОРА ИСПОЛНЕН — 13 фиксов, 9/9 посадок пойманы, одна находка воркфлоу ОПРОВЕРГНУТА исполнением, канон 0.6.0 принят (сессия платформы, 28.08, после записи ниже)
|
||||
|
||||
Диспозиции пришли двумя сообщениями оркестратора (28.08) + третьим — канонная половина полосы
|
||||
(0.6.0). Всё исполнено, кроме ДВУХ пунктов с несогласием (аргументы ниже — обе позиции по норме
|
||||
«несогласие говори»).
|
||||
|
||||
### Фиксы в дереве (сверх сданного пака; каждый с пином, посадки — в копии `~/tm-p9-mut2`)
|
||||
|
||||
| # | Что | Пин | Посадка |
|
||||
|---|---|---|---|
|
||||
| F1 | **MAJOR(а) 1 МиБ**: `ingest.EncodeDecisions` рендерит с `SetEscapeHTML(false)` (документ читает движок, не браузер; выбор обоснован в комментарии) **+ жёсткий гейт**: рендер сверяется с зеркалом движкового капа `ingest.MaxDecisionsDocument` ДО спавна → `ErrBankDocumentTooLarge` → **413** (остаточная конвертная полоса ~40 байт закрыта гейтом, слово всегда канонное) | `TestTheRenderedDocumentDoesNotInflateEscapableBytes` (ingest) · `TestARenderedDocumentOverTheEngineCapIsRefusedBeforeTheSpawn` (runs, движок НЕ спавнится) · `TestARenderedDocumentOverTheEngineCapAnswers413` (httpapi) | M6 (возврат `json.Marshal`) и M7 (снятие гейта) пойманы топично |
|
||||
| F2 | **MAJOR(б) мьютекс**: `lockBook(ctx)` — одноместный канал вместо `sync.Mutex`, ожидание наблюдает контекст; **бюджет двери ставится ДО очереди** (накрывает ожидание+вызов); Start/Resume передают свои ctx; отмена в очереди корректно декрементит refcount | `TestAWaiterWhoseContextEndsLeavesTheBookQueue` (+ leak-тест расширен) | M11 (ожидание игнорирует ctx) поймана |
|
||||
| F3 | **Р1 окно стопа**: `ReadBookForRun` вырос полем `LiveRunAwaitingBank` (живая строка в `awaiting_bank`); предикат двери — `HasLiveRun && !LiveRunAwaitingBank`; Start по-прежнему держится на `HasLiveRun` (вторая строка сломала бы `runs_one_live_per_book`); худший случай окна — движок ещё дожёвывает → честный класс 12 → 503 «повтори». Довод в комментарии переписан, противоречие с `reconcile.go:1122` разрешено в пользу кода | `TestTheDoorOpensOnTheSigningStopWindow` (фикстура — как `control_test`: журнальный `awaiting_bank` без `finished_at`); обратная сторона держится старым `TestCorrectionsRefuseWhileTheBookIsBeingTranslated` | M8 (старый предикат) поймана — refuse-тест при этом остался зелёным |
|
||||
| F4 | **Р2 миграция 00026**: два UPDATE сведены к ОДНОМУ — `draft_before := chapters_before` для ВСЕХ строк (та аппроксимация, которую ревью само проверило как самосогласованную для finished): пере-снятие БАЗ ПОСЛЕ работы прогона — единственный источник вечного недоезда −2·p_e — удалено; заодно умер и dispute про READ COMMITTED между двумя стейтментами (стейтмент один). Остаток по эпохам старого `chapters_before` честно назван в комментарии миграции. `migrations.sha256` пере-подписан (миграция не релизнута — слово оркестратора) | пин исполнением невозможен (тестовые БД наливаются миграциями ДО данных — старая семантика юнитом задним числом непроверяема); держится формулой + комментарием | — (названо, не скрыто) |
|
||||
| F5 | **Р5 presence-vs-null**: все строковые члены `wireCorrection` — `nullableString` (парный `nullableInt`); явный `null` на любом = malformed ТИПА (рефьюз до правил присутствия); правила присутствия (`id`×tuple, `dst`/`kind` на decline) считают КЛЮЧИ | +3 кейса в `TestACorrectionRequestIsValidatedWholeWithPointers` (null-src при id · null-dst на decline · null-action) | M10 (снятие null-гейта) поймана |
|
||||
| F6 | **Р6(i)**: комментарий SIGKILL-ветки переписан честно (ветка достижима только когда SIGTERM НЕ отработал; полу-приземлённая пара без отчёта — остаток в PD-407) | — (комментарий) | — |
|
||||
| F7 | **Р6(ii)**: `bankStopGrace` 10 с → **30 с**, число обосновано замером движка в комментарии (11.3 с непрерываемого фолда на документе-максимуме, ×2.5 запас на медленный хост) | — (константа с доводом) | — |
|
||||
| F8 | **Р6(iii)**: связка «ручка `TM_PLATFORM_RUN_BUDGET` × движковый кап 5000×11.3с» и цена понижения названы комментарием у бюджета двери (механизм не строился — слово оркестратора) | — | — |
|
||||
| F9 | **Р7**: `Service.SweepCorrectionScratch()` — подметание `bank-corrections-*.json` на буте (вызов в композиционном корне `tmplatformd` ДО подъёма HTTP), файлы вне маски не трогаются | `TestBootSweepsOrphanedCorrectionDocuments`. ⚠ Честно: вызов ИЗ main юнитом не запинен — мутация «не звать на буте» ловится только чтением | M14 (маска мимо) поймана |
|
||||
| F10 | **Д5**: комментарий `spawn.go` о сбросе `--verify-bank` переписан с упразднённого D39.144-контракта на настоящее основание (память v16 покрывает карту; старое основание питало дырявый гард — названо в комментарии) | — | — |
|
||||
| F11 | **Н5 hello**: при `resuming` hello несёт `last` клиента, не голову истории (свежий коннект — голову, как и `from` четырьмя строками ниже) | `TestAResumingHelloCarriesTheClientsOwnWatermark` (+ свежий коннект отдаёт голову) | M13 поймана |
|
||||
| F12 | **Н1-гард**: `Resume` под мьютексом сверяется с новым `pgstore.LatestRunID` (тот же порядок, что `lastRun`) — не-последний прогон получает `ErrNotResumable` с человеческим доводом; реконсилерский RestartRun в гарде не нуждается (живой прогон всегда последний по `started_at`: пока он жив, новый не стартует) | `TestAnOlderRunCannotBeResumedOverANewerOne` (Now сдвигается между стартами — фикстурный Now дал бы одинаковый `started_at` и флейк по случайному id) | M12 поймана |
|
||||
| F13 | **Канон 0.6.0** (третье сообщение): `ContractVersion` → 0.6.0; комментарий `wireProgress` переписан (сквозная доля и `stage` теперь КАНОННЫ, открытый словарь; оба условия исключения шапки — вывод платформой из тех же счётчиков и открытость — коду отвечают, проверила); протухший абзац про `stop_requested` заменён на «ни одного члена впереди канона»; комментарий у теста строки библиотеки сужен до правды (сам тест не тронут) | гейт версии (`reading_test`) снова зелёный | — |
|
||||
|
||||
### Два НЕСОГЛАСИЯ с диспозициями (норма «говори»)
|
||||
|
||||
1. **Р4 (`limitedBuffer` не убивает чайлда) — находка воркфлоу ОПРОВЕРГНУТА исполнением, фикс
|
||||
ОТКАЧЕН.** Посадка M9 (kill вырезан) прошла пин за **0.09 с** — чайлд умер сам; исходник Go
|
||||
называет механизм прямо: копирующая горутина exec закрывает читающий конец при ошибке Write
|
||||
(`os/exec/exec.go` writerDescriptor: «pr.Close() // in case io.Copy stopped due to write
|
||||
error») → EPIPE на следующей записи. Агент экстраполировал семантику `StdoutPipe` (там `drain()`
|
||||
Kill действительно нужен — exec ничего не закрывает за ручного читателя) на `Stdout=io.Writer`
|
||||
и НЕ исполнял. Мой добавленный было kill снесён (он был бы кодом под ложный довод); исходный
|
||||
комментарий `limitedBuffer` был ВЕРЕН и расширен ссылкой на опровержение; поведение запинено
|
||||
живьём: `TestAnEndlessBankApplyIsRefusedRatherThanRead` (0.1 с; посадка M9b «кап перестал
|
||||
отказывать» валит его таймаутом). Это второй случай за ревью, где замер бьёт рассуждение — в
|
||||
обе стороны.
|
||||
2. **Р2-dispute (READ COMMITTED между двумя UPDATE миграции) — строкой НЕ заведён:** фикс F4 свёл
|
||||
миграцию к одному стейтменту, окна больше не существует.
|
||||
|
||||
### Регистр и якоря
|
||||
|
||||
- `PD-400.2` пере-описана честно (снято «ни ложного слова», мульти-репличный суб-кейс с ложным
|
||||
503 назван прямо; оговорка внесена и в комментарий ветки класса 12) — редакция для акта лендинга.
|
||||
- Новые строки: **PD-402..PD-406, PD-410** (major: resume не-последнего [read-половина, write-гард
|
||||
закрыт F12] · sticky `edit_wave`/re-sell · `chapters_done` назад · бездеревная книга С ЗАМЕРОМ
|
||||
достижимости 0.018 с из лога стенда · SSE hello [закрыта F11 — оркестратору решить статус] ·
|
||||
Р3-полоса/план движка — АРХИТЕКТУРНОЕ, отдельным паком), **PD-407, PD-409, PD-411..413** (minor:
|
||||
SIGKILL-слово+грейс [грейс закрыт F7] · утечка temp-файлов [закрыта F9] · мёртвые колонки классом
|
||||
A второго яруса онтологии-18 · карточка 5 сканов · emitProgress под блокировкой), **PD-408**
|
||||
(info: ручки двери). Статусы строк, чьё лечение легло этим раундом (405-замер/406/407-часть/409),
|
||||
НЕ переводила — закрытие актом лендинга, как у PD-401.
|
||||
- Гейты регистра: `counts.py --check` → **413 строк, 109 открытых (9/33/67), битая форма [], хвост
|
||||
[]**. Якоря, сдвинутые МОИМИ правками, пере-нацелены (PD-375 `runs.go:269`, PD-*` books.go:1065`).
|
||||
⚠ Для оркестратора: `docs/PROGRESS.md:159-160` и `17-seam-inbound-law.md:59` /
|
||||
`25-seam-cold-review.md:7` держат якоря, уехавшие НЕ моими правками (supervisor/book.go — чужие
|
||||
сдвиги) и моей (reconcile.go:1228 → теперь `:1246`) — файлы твоей зоны, чинить тебе.
|
||||
|
||||
### Числа финальной батареи фикс-раунда (каждое — командой)
|
||||
|
||||
`go test ./... -race -count=1 -v` с тремя гейтами → **EXIT=0, 18 пакетов ok, `grep -c -- '--- SKIP'`
|
||||
→ 0, `grep -c '^=== RUN'` → 793** (было 781 на сдаче ревью — +12 новых пинов), `FAIL|DATA RACE` — 0
|
||||
вхождений; `golangci-lint run` → **0 issues**; `gofmt -l` пусто; `go vet` чисто (в составе линта).
|
||||
⚠ Промежуточная батарея №2 имела РОВНО ОДИН честный FAIL — старый пин
|
||||
`TestResumeIsRefusedWhenTheBookHasAnotherLiveRun` поймал, что первый вариант гарда F12 перекрывал
|
||||
канонное слово `run_in_flight` при живом чужом прогоне; чинился КОД (словоразделение: живой сосед →
|
||||
`run_in_flight`, финишировавший → `ErrNotResumable`), тест не тронут. Опись дерева:
|
||||
`git status --short -- platform/` → **50 путей (39 M + 11 ??)**.
|
||||
|
||||
## ВОРКФЛОУ-РЕВЬЮ ДЕРЕВА P9 ОТРАБОТАНО — 16 линз, оба отложенных MAJOR подтверждены замером, сводка находок для оркестратора (сессия платформы, 28.08)
|
||||
|
||||
Заказ владельца (релей 28.08): адверсариальная вычитка дерева воркфлоу-оркестрацией — полоса ×4,
|
||||
дверь ×3, миграция, раскладка кодов (`wf_cac14b2f-e84`) + гонки данных по 4 траекториям владельца,
|
||||
баг-хант нового кода, стоимость per-request (`wf_155de7c3-bb4`). Раскладка моделей — по слову
|
||||
владельца: 1×Fable на воркфлоу (самая тяжёлая линза), остальным явный opus/sonnet. 16/16 агентов
|
||||
дошли (0 ошибок), ~2.83M токенов. Мандат: «найди, где рассуждение неверно», каждая находка —
|
||||
severity + file:line + траектория с числами; PD-281, PD-401-остаток и пере-нарезка исключены
|
||||
заданием как названные границы. **По находкам НИЧЕГО не чинил — жду слова оркестратора.**
|
||||
|
||||
Дисциплина честности: пометка «подтверждено прогоном» в траекториях — исполнение АГЕНТОВ (их
|
||||
overlay-тесты и /tmp-замеры в копиях дерева), не моё; я пере-исполнил чтением два клейма своего кода
|
||||
(Р4, Р5 ниже — оба подтвердились) и одним своим EXPLAIN-замером ось стоимости (сошлось, ниже).
|
||||
Полные результаты с траекториями сохранены вне репо: журналы воркфлоу в каталоге сессии
|
||||
(`subagents/workflows/wf_*/journal.jsonl`).
|
||||
|
||||
### Два отложенных MAJOR ревьюера приёмки — оба ПОДТВЕРЖДЕНЫ, чинить
|
||||
|
||||
- **MAJOR(а) «1 МиБ на двух документах» — ПОДТВЕРЖДЁН тремя агентами с независимыми замерами.**
|
||||
Механизм: `json.Marshal` в `ingest.EncodeDecisions` HTML-экранирует `&`/`<`/`>` (1 байт → 6) и
|
||||
меняет конверт (+~25 Б), так что тело под проводным капом 1 МиБ рендерится в документ НАД
|
||||
движковым капом 1 МиБ. Замер агента: 5000 declines с `note` из `&`×160 → тело 1 030 050 Б →
|
||||
рендер 5 030 075 Б → движковый exit 14 → **409 `bank_corrections_refused` «split it» вместо
|
||||
канонного 413**. Полоса законных тел широкая (~550 КБ плотного спецсимволами тела уже
|
||||
перескакивает), а существующий пин `TestAnOversizedCorrectionDocumentAnswers413` дыру не ловит —
|
||||
набит кириллицей, которая НЕ экранируется. ⚠ Конфликт линз: два агента первого воркфлоу положили
|
||||
эту ось в «не опровергнуто», рассуждая от omitempty («рендер меньше тела») и НЕ меряя; замер двух
|
||||
других бьёт рассуждение (один из «опровергателей» сам оговорил условие плотности `&<>`).
|
||||
Бонус-cosmetic туда же: скраб пути (`runs/bank.go:146`) на этом отказе даёт задвоенную фразу «the
|
||||
decision document the decision document» и пропускает на провод движковый префикс `pipeline:`.
|
||||
- **MAJOR(б) «мьютекс без контекста, бюджет после захвата» — ПОДТВЕРЖДЁН** (`runs/bank.go:94` vs
|
||||
`:126`, `lockBook` без ctx `:270-291`): K одинаковых ретраев дают очередь K×60 с; отменённый
|
||||
клиентом запрос ЖДЁТ в очереди и, дождавшись, делает работу (чтение книги, temp-файл) за мёртвый
|
||||
запрос; в ту же очередь встают Start и Resume — заявление «ждать один вызов движка»
|
||||
(`runs.go:242-246`) держится только для одного ожидающего. Дёшево лечится ожиданием с
|
||||
наблюдением ctx (канал вместо `sync.Mutex`).
|
||||
|
||||
### Находки в МОЁМ коде P9 (сверх двух MAJOR; лечение — после слова оркестратора)
|
||||
|
||||
| # | Вес | Что | Где |
|
||||
|---|---|---|---|
|
||||
| Р1 | breaks | **Окно стопа подписи**: `bank_stop` ставит `awaiting_bank` БЕЗ `finished_at`, а `HasLiveRun` читает `finished_at is null` → до ближайшего свипа (15 с штатно; до 30 мин на бэкоффе отсрочек) дверь отвечает **409 `run_in_flight` на экран подписи, который платформа сама только что объявила**; соседняя кнопка Resume в том же окне отказывает ДРУГИМ словом («being settled»). Мой довод в `runs/bank.go:110-112` («its run is closed») прямо противоречит `reconcile.go:1122`; состояние строится собственным тестом дерева (`control_test.go:598-603`). Нашли НЕЗАВИСИМО 3 агента | `runs/bank.go:109` |
|
||||
| Р2 | breaks | **Миграция 00026 для прогонов в полёте**: пере-снятие баз посреди прогона, уже сделавшего p_e правок, оставляет его финал на `runDone−runTotal = −2·p_e` — полностью доделанный прогон закрывается, например, 5/7 навсегда. Двух-базовая схема корректна ровно потому, что базы берутся ДО работы прогона; миграция — единственное место, берущее их ПОСЛЕ. + dispute: READ COMMITTED между двумя UPDATE допускает двойное касание строки при конкурентном финише (при штатном деплое одной реплики окно закрыто остановкой старого демона — вес мал) | `migrations/00026:20-27` |
|
||||
| Р3 | breaks | **`draftWork` не моделирует порядок волн движка**: continuation над недочерновленной книгой (`draft_before ≥ chapters_before+C`, напр. прогон-предшественник упёрся в потолок на черновике) даёт `draftWork=0`, движок продолжает ЧЕРНОВУЮ волну — полоса 0/C весь прогон, stage «editing» на прогоне, который только черновит. Зеркало дефекта, который draftWork чинил | `readmodel.go:460` |
|
||||
| Р4 | breaks | **`limitedBuffer` НЕ убивает чайлда** при переполнении stdout — вопреки своему комментарию (контраст: `drain()` в `engine.go:262-272` при том же переполнении явно зовёт `Process.Kill`); чайлд виснет на записи в полный пайп до чужого дедлайна (~70 с под пер-книжным мьютексом), без дедлайна — навсегда. Подтвердил чтением. + dispute: stderr — НЕограниченный `bytes.Buffer` (не-тот бинарь по сконфигурированному пути может раздуть демона до OOM; читается всё равно только первая строка) | `runner/bankapply.go:98-111` |
|
||||
| Р5 | breaks | **Presence-vs-null дыра валидации**: только окна (`nullableInt`) различают явный `null` от отсутствия; `src/sense/dst/kind/id` — голые `*string`, так что `{"id":"X","src":null}` и decline с `"dst":null,"kind":null` проходят гейты, которые канонный `oneOf`/`not-required` велит отбивать 400. Подтвердил чтением (сам вводил `nullableInt` только для окон). Порчи данных нет — лишнее значение не читается; дыра контрактная | `httpapi/bank.go:48-51, 221-224, 251-259` |
|
||||
| Р6 | dispute | **Раскладка на ветке SIGKILL**: `ctx.Err()`-ветка (`runs/bank.go:132`) достижима ТОЛЬКО когда SIGTERM не отработал (процесс убит по `WaitDelay`) — а это единственный путь, оставляющий полу-приземлённую пару БЕЗ отчёта, т.е. адресат `bank_corrections_incomplete`, отвечаемый общим 503; мой комментарий описывает соседнюю ветку. + `bankStopGrace` 10 с КОРОЧЕ самого длинного непрерываемого участка движка (11.3 с на документе-максимуме) — грейс истекает до фазы записи. + `runBudget` — операторская ручка свипа: понижение (к чему подталкивают комментарии) делает легальный документ-максимум навсегда неприменимым (вечный 503 вместо «split it») | `runs/bank.go:132`, `runner/bankapply.go:40` |
|
||||
| Р7 | cosmetic | Утечка `bank-corrections-*.json` в StateDir при нечистой смерти демона (SIGKILL/OOM/обрыв грейса деплоя): cleanup только на defer, никто каталог не подметает; до 1 МиБ пользовательских решений на файл, бессрочно | `runs/bank.go:250` |
|
||||
| Р8 | — | **Формулировка PD-400.2 (accepted-risk) требует правки**: на мульти-реплике класс 12 может быть НАСТОЯЩИМ многочасовым прогоном соседней реплики (её Start видит свой мьютекс и чистый `runs`) — слово 503 «transient holder» и мой лог тогда ЛОЖНЫ, канонно верное слово — 409 `run_in_flight`. «Ни денег, ни порчи» держится; «ни ложного слова» — НЕТ. Риск остаётся принятым (v1 = одна реплика), но акт лендинга должен описать его честно | `runs/bank.go:27-39, 182-189` |
|
||||
|
||||
### Находки в НАСЛЕДИИ платформы, вскрытые ревью (не код P9; полоса и дверь на них стоят)
|
||||
|
||||
| # | Вес | Что | Где |
|
||||
|---|---|---|---|
|
||||
| Н1 | breaks | **Resume НЕ-последнего прогона** (Resume пропускает любой `stopped` без проверки последнести; `lastRun` = `started_at desc`; RestartRun не трогает `started_at`): (i) полоса возобновлённого открывается на ЧУЖОЙ работе — 2/2 при нуле своих пассов (клэмп режет только >1, не двойной счёт до 1); (ii) карточка, кадр статуса и КАЖДЫЙ progress-кадр живого прогона считаются по ЧУЖОМУ финишировавшему — «ready» и замороженный бар, пока живой прогон тратит деньги. 4 агента, overlay-прогоны | `reconcile.go:1216`, `readmodel.go:487`, `runs.go:843` |
|
||||
| Н2 | breaks | **`edit_wave` sticky-true × оператор убрал редактора**: прогон делает 100% купленного и закрывается ready на 50% со stage «editing»; `chaptersDone` (edit-колонка) мёрзнет → `ChaptersLeft` не падает → **шкала повторно продаёт уже переведённые главы** (симптом PD-202, записанной fixed); follow-up прогон над этой покупкой читается 0/C с первой секунды. Обе фразы комментария `sink.go:406-409` о собственном коде ложны | `sink.go:415`, `readmodel.go:388` |
|
||||
| Н3 | breaks | **Флип false→true уводит `Book.chapters_done` НАЗАД** (finishedUnits через ЖИВОЙ флаг): 40/100 → 0/100 одной транзакцией при росте ревизии — клиент обязан отрисовать спад; `ChaptersLeft` раздувается обратно → предлагает купить купленное. PD-316-класс на непокрытом триггере (пин ловит только admission). Крайний случай: книга, полностью начерченная под false, НЕдочитываема редактором никаким действием API (ChaptersLeft=0 → прогон не допускается → флип недостижим) | `sink.go:415`, `readmodel.go:388`, `books.go:981-983` |
|
||||
| Н4 | breaks | **Книга без материализованного дерева**: полоса (вся из `chapters`) читает 0/total весь прогон при исправно доезжающих progress-событиях; комментарий `sink.go:219` («счётчики придут из progress-события») ложен уже в HEAD. + dispute: `runs.draft_done/draft_total/edit_done/edit_total` — 2 писателя, 0 читателей (PD-314-класс, лишняя запись на каждом событии под блокировкой книги; обоснование `greatest()` на `sink.go:444-446` защищает несуществующую полосу) | `sink.go:219, 137, 447` |
|
||||
| Н5 | breaks | **SSE `hello` при переподключении несёт `id = state.Position` вместо предъявленного `last`** — WHATWG-клиент фиксирует новый Last-Event-ID ДО получения догона; обрыв сразу после hello теряет кадры навсегда (включая `note`, которые контракт запрещает терять) и обезоруживает дельта-ремонт (`revision` из hello выше потерянных строк). Лечение — одна строка: при resuming слать `last`. Код рядом (`stream.go:122-126`) сам знает правильное значение | `stream.go:99` |
|
||||
|
||||
### Находки зоны ДВИЖКА (чужая зона — пинг оркестратору, мне не лечить)
|
||||
|
||||
| # | Вес | Что | Где |
|
||||
|---|---|---|---|
|
||||
| Д1 | breaks | **Decline не энтити-широк на обратном пути**: отклонённая сущность возвращается в карту/auto-bank/редактору через свой АЛИАС (`reverseSectionTerms` фильтрует по одному ключу, а `mined_rejects` получает только `src`), и память предъявления глушит стоп об этом НАВСЕГДА — нарушен собственный контракт «a declined term never re-enters» (`miner_emit.go:59-63`); approve симметричной утечки не имеет (кластер через строку банка) | `mining.go:548`, `membank/decisions.go:598` |
|
||||
| Д2 | breaks | **Неидемпотентный decline** поверхности подписанного сида, имеющей строку в дельте: первый вызов ПРИНЯТ, повтор ТОГО ЖЕ документа — 409 (гейт `decisions.go:374` судит ДО-состояние, которое свёртка сама стирает); при классе 15 предписанный ре-сенд отбивается ЦЕЛИКОМ — **обещание сходимости 503-ретрая, на котором стоит синхронная дверь, ломается**. Доказано исполнением через СОБСТВЕННЫЙ оракул репозитория (оракул 4 фаззера); фаззер структурно не достаёт (фикс-книга не пересекает сид с дельтой). Лечение: судить по ПОСТ-состоянию, как соседний `refuseInertDeclines` | `membank/decisions.go:374` |
|
||||
| Д3 | breaks | **Потеря/порча маркера выхода после стопа** (совместная с платформой): рестарт/ретрай проходит границу банка НАСКВОЗЬ — память предъявления покрывает карту, движок «continuing» одним WARN себе в журнал — оплаченный `verify_bank` стоп исчезает молча и навсегда (память append-only). Гард `LiftBankStop` (`reconcile.go:1122-1126`) писан против движка ДО памяти v16 и этот путь не держит | `reconcile.go:424`, `mining.go:216-227` |
|
||||
| Д4 | dispute | **`signature` в квитанции двери** считается от карты, которую переписывает ЛЮБАЯ граница майнинга (запись карты на `mining.go:191` — ВЫШЕ решения о стопе): `signature != null` не означает состоявшегося стопа; `surfaces`/`undecided` дрейфуют между двумя вызовами владельца; `undecided: 0` достижим при непредъявленных решениях (кап top-200 вытесняет). У карты ЕСТЬ идентификатор (`signaturemap.go:25-29`) — шов его не читает | `pipeline/bankdecisions.go:549-598` |
|
||||
| Д5 | cosmetic | `spawn.go:159-165` (платформа, но про движок) цитирует упразднённый D39.144-контракт стопа («halts whenever undecided terms remain») — при памяти v16 фактически ложно и питает дырявый гард Д3 | `spawn.go:159-165` |
|
||||
|
||||
### Ось «стоимость» — мои живые замеры + вердикт агента (сошлись)
|
||||
|
||||
- **Строка 186 единого бэклога ПРОТУХЛА — рекомендация оркестратору: закрыть.** Механизм построен
|
||||
и применён: агент прогрепал ВСЕ call sites `writeJSON` (коллекции, карточка, capabilities, POST
|
||||
двери; SSE корректно НЕ сжимается). Мои замеры на стенде tmp9stand (команды и заголовки — сырьём):
|
||||
банк 437 Б / 7.2 мс холодным, повтор с `If-None-Match` → **304 / 0 Б / 2.6 мс**; юниты главы
|
||||
3008 Б → **740 Б gzip (×4.1)**; gzip ниже порога 1024 Б честно не применяется; resume SSE за
|
||||
головой → 204 / 1.3 мс. Взамен предлагаю ДВЕ новые стоимостные строки (ниже).
|
||||
- **Карточка книги — 5 коррелированных сканов `chapters` на один GET** (3 в runRow: draftChapters
|
||||
×2 + editChapters, +2 в bookColumns: chaptersDone + noteCount), и те же 2 — на КАЖДУЮ строку
|
||||
страницы библиотеки (×100). Эмпирика агента на живом PG 18.4, фикстура в форме миграции 00002,
|
||||
книга 2283 главы: как написано — 1.359 мс; те же числа одним LATERAL `count(*) filter(...)` —
|
||||
0.307 мс (×4.4). Мой контрольный EXPLAIN на реальной схеме tmp9stand: **6 SubPlan-ов в плане,
|
||||
5 исполняются** (ELSE-ветка never executed) — форма подтверждена. PG повторные текстовые
|
||||
вхождения подзапроса НЕ дедуплицирует (доказано side-effect-последовательностью), CASE-ветки
|
||||
честно short-circuit.
|
||||
- **`emitProgress` повторяет 3-скан агрегат по ВСЕЙ книге на каждое progress-событие** (движок шлёт
|
||||
его на каждый разрешённый юнит каждой волны) — ПОД блокировкой строки книги: O(глав × юнитов)
|
||||
вместо O(юнитов), на большой книге — секунды суммарного удержания блокировки за прогон.
|
||||
- cosmetic: `MkdirAll(StateDir)` на каждый вызов двери — место одному разу в конструкторе Service.
|
||||
|
||||
### Чистые оси (проверено — не опровергнуто)
|
||||
|
||||
Линза `bugs:service-and-seams` — **0 находок** (проверены: cleanup temp-файла по всем выходам;
|
||||
редакция refusals — единственная ветка с путём; вердикт-таблица против всей полосы exit.go; ключи
|
||||
только на translate; refcount lockBook; SpendBaseline из колонок ПОПЫТКИ; согласованность
|
||||
bankCountsTx; проекции без утечек словаря/путей; build/vet/тесты). Сквозные not_refuted (по многу
|
||||
агентов): взаимоисключение «дверь × спавн» в заявленную сторону ДЕРЖИТСЯ на одной реплике (все 5
|
||||
путей спавна упираются в `finished_at is null` = предикат HasLiveRun); правило одного писателя двух
|
||||
файлов решений; сходимость повтора того же/другого документа при ЦЕЛОМ гейте Д2; SIGTERM после
|
||||
записи не теряет квитанцию (`Exited=true` глотает ctx.Err — совпадает с диском); идентичность
|
||||
термов через пересборку банка (id из ключа уникальности); сериализация чеканки кадров и штамповка
|
||||
ревизий чисты; чтения на одном снимке; идемпотентный ключ Start не клинит за очередью мьютекса.
|
||||
|
||||
### Финальная батарея по дереву (после всех приёмочных правок; дерево ревью НЕ меняло)
|
||||
|
||||
`go test ./... -race -count=1 -v` с тремя гейтами → **EXIT=0, 18 пакетов ok, SKIP=0,
|
||||
`^=== RUN` = 781**; DATA RACE — нет. (На сдаче было 775 — рост на приёмочных доборах, число
|
||||
командой.)
|
||||
|
||||
### Вопросы/предложения оркестратору
|
||||
|
||||
1. Диспозиция Р1–Р8: что чиню в этом дереве до лендинга, что строками регистра. Готов завести
|
||||
пакет строк PD-40x по всем группам после твоего вердикта (не завожу до слова — рядом лендинг).
|
||||
2. PD-400.2: скорректировать формулировку accepted-risk по Р8 (снять «ни ложного слова», описать
|
||||
мульти-репличный суб-кейс) ДО акта лендинга.
|
||||
3. Строку 186 закрыть (замер выше), взамен — две стоимостные строки (5-скан карточка ×100 на
|
||||
библиотеку; emitProgress O(глав×юнитов) под блокировкой) куда решишь.
|
||||
4. Д1–Д5 — пинг бэкенду твоим каналом; Д2 ломает обещание, которое МОЯ дверь даёт на проводе
|
||||
(сходимость ретрая 503) — до его лечения в движке слова канона §applyBankCorrections о ретрае
|
||||
верны не для всех документов.
|
||||
5. Н1–Н5 — наследие: Н1 (resume не-последнего) и Н5 (hello id, лечение в одну строку) выглядят
|
||||
дешёвыми и болезненными; Н2/Н3 упираются в продуктовое решение о смене формы конвейера на
|
||||
живой книге.
|
||||
|
||||
## ПАК P9 ОТРАБОТАН — дверь правок банка смонтирована, ключи едут, полоса сквозная; цепь живого прогона ПРОБИТА живьём (сессия платформы, 27.08)
|
||||
|
||||
Дерево передаётся на лендинг. Опись: `git status --short -- platform/` → 31 изменённый + 9 новых
|
||||
файлов, все в зоне; вне `platform/` не тронуто ничего.
|
||||
|
||||
### Таблица комплектности против §3 (пункт → сделано → каким ИСПОЛНЕНИЕМ подтверждено)
|
||||
|
||||
| §3 | Что сделано | Исполнение |
|
||||
|---|---|---|
|
||||
| §3.1 дверь | `POST /v0/books/{bookId}/bank/corrections` в `contractSurface` (монтаж по `Deps.Bank`, кап тела = канонный 1 МиБ = `DefaultMaxBody`); строгий декод (`DisallowUnknownFields` + запрет хвостовых байт), вся канонная валидация формы с JSON Pointer'ами; `Capabilities.bank_corrections_enabled` = факт монтажа; словарь шва `ingest/bankdecisions.go` (запрос v1 / отчёт v2, аллоулист); канал `runner.BankApply` (прямой чайлд, SIGTERM-грейс, потолок чтения); вердикт `runs.bankVerdict`; квитанция-проекция с переводом `edit_wave→refinement` и отказом на неизвестное слово; `refusals[]` в конверте `Problem` + коды `bank_corrections_refused`/`bank_corrections_incomplete` | живой пробой — `docs/p9/door-live-probe.md` (превью → правка → ретрай `already_applied` → отказ 409 с указателем → resume → 409 `run_in_flight` при живом прогоне, тела дословно); юнит-пины `internal/httpapi/bank_test.go` (7 на сдаче; 9 после приёмочных доборов — превью и обрыв на потолке), `internal/runs/bank_test.go` (6), `internal/ingest/bankdecisions_test.go` (2); посадки M1, M2 |
|
||||
| §3.1 раскладка кодов | заказанное: 14→409 `bank_corrections_refused`+`refusals[]` · 15→503 `bank_corrections_incomplete` · 12→409 `run_in_flight` · тело>1МиБ→413 · >5000 и форма→400. Моя половина с доводом: **13→503 `service_unavailable`** (не мигрирован — оператор, транзиентно; различим от 15 по коду) · **19 и незнакомые члены полосы→503 `service_unavailable`** (рассинхрон сборок; какое из двух других ремеди — неизвестно по построению) · **10/11→500** (оба входа глагола рендерит платформа) · **exit 5 и таймаут бюджета→503 `service_unavailable`** (рестарт деплоя; SIGTERM-контракт глагола graceful). Три ремеди («пере-реши»/«повтори то же»/«позови оператора») не сливаются | пин-таблица `TestBankVerdictKeepsTheRemediesApart` + `TestTheDoorsFailuresKeepTheirRemediesApart`; посадка M1 (слияние 15 в 503-generic) поймана; опровергатель кодов: «(а) слияние ремедий — не опровергнуто по всем девяти строкам» |
|
||||
| §3.2 синхронность | вызов синхронный, бюджет = `runBudget()` (60 с — класс вызовов движка); пер-книжный мьютекс `lockBook` в `runs.Service`, его берут corrections И `Start` И `Resume` (Start — та же гонка спавна, что resume); проверка живого прогона — ПОД мьютексом; гейт готовности книги (`readyToTranslate`) — как у Start (находка опровергателя) | `TestAResumeWaitsOutALiveCorrectionCall` (resume ЖДЁТ живой вызов двери, канал-гейтед фейк); `TestCorrectionsRefuseWhileTheBookIsBeingTranslated`; живьём — шаг 11 пробоя (409 `run_in_flight` на живом прогоне); синхронность ДЕРЖИТСЯ: живой вызов двери на стенде — доли секунды, потолок глагола 5000 подобран движком под таймаут вызывающего |
|
||||
| §3.3 ключи | `TM_PLATFORM_ENGINE_KEYS_PATH` (абсолютный или отказ на буте; ⚠ суффикс `_PATH`, не `_FILE` — `*_FILE` в зоне значит «файл со значением секрета», гейт `TestEverySettingThisServiceReadsIsPrinted` это и поймал) → `runner.TranslateArgs` кладёт `--keys-file` ТОЛЬКО на `translate`; в окружение юнита ключи не кладутся; пусто = WARN на буте | `TestTheDeploymentKeyFileReachesTranslate`, `TestTheSpawnedUnitCarriesTheDeploymentKeyFile` (argv юнита + отсутствие ключей в Env); живьём: движок с несуществующим файлом падает громким «--keys-file … cannot be read», с файлом — пре-флайт пройден (лог пробоя); посадка M3 поймана |
|
||||
| §3.4 полоса | ОДНА монотонная доля через обе волны: `done = draftBar + lastBar`, `total = draftWork + ceiling` (редактор) / `ceiling` (без), где `draftWork = clamp(chapters_before + ceiling − draft_before)` — знаменатель считает работу ЭТОГО прогона (правка по находке опровергателя: continuation поверх начернённого задела кончал ready на 50%); две базы в `StartRun`, пере-базирование при снятии стопа УДАЛЕНО; подпись `progress.stage` (`drafting`/`editing`, открытый словарь); кадр `progress` и старт-квитанция несут то же; миграция `00026` (live-прогоны — обе базы пере-сняты верными предикатами, законченные — аппроксимация, названо в самой миграции) | живьём: 0/6 drafting → стоп 3/6 editing → resume 3/6 (БЕЗ обнуления) → ready 6/6 (лог пробоя); пины `TestTheBarIsOneMonotonicFractionThroughTheSigningStop`, `TestARunOverADraftedBacklogOwesOnlyTheLastPass`, `TestADraftOnlyDeploymentCountsItsOneWaveOnce`, `TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook`, `TestTheRunsBarNeverExceedsWhatItBought`; посадки M4, M5 пойманы |
|
||||
| §3.5 PD-399 | `pending_decisions`/`complete` сняты со всех трёх носителей (`BankCounts`+кадр `EventBank`, `wireBankPage`, подзапрос к мёртвой `bank_decisions` ушёл); пин ПЕРЕПИСАН на отсутствие | `TestTheBankAggregatesRideOnTheFirstPageOnly` пинит ОТСУТСТВИЕ; живьём: `GET /bank` на стопе и после прогона — полей нет (лог пробоя, шаги 5 и 13); строка PD-399 → fixed |
|
||||
| §3.6 конвенция пути | `runner.projectDB()` и парс `book.yaml` УДАЛЕНЫ; путь банк-экспорта берётся из конверта `artifacts.bank_export`, который движок публикует в `manifest --json` (движковая половина — d1eb8a9); `refreshBank` кормится манифестом той же refresh-пачки; движок без конверта = громкий отказ, долг ретраится | `TestTheBankIsReadAtThePathTheEnginePublished`, `TestAnEngineWithoutTheEnvelopeIsAFailureRatherThanAnEmptyBank`; живьём: банк пробной книги материализовался по опубликованному пути (шаг 13) |
|
||||
|
||||
### Числа сдачи (каждое — командой)
|
||||
|
||||
- Батарея с ТРЕМЯ гейтами (`TM_PLATFORM_TEST_DSN` · `_ENGINE_BIN`+`_BOOK_TEMPLATE` · живой
|
||||
пользовательский systemd): `go test ./... -race -count=1 -v` → **EXIT=0, 18 пакетов ok,
|
||||
`grep -c -- '--- SKIP'` → 0, `grep -c '^=== RUN'` → 775**; линтер `golangci-lint run` → **0 issues**;
|
||||
`gofmt -l` пусто, `go vet ./...` чисто. Лог — `~/tm-p9-work/final-battery.log` (вне репо).
|
||||
- Тест-функции зоны: `grep -rh '^func Test' platform --include='*_test.go' | wc -l` → **561 (HEAD) → 580**.
|
||||
- Регистр: `python3 docs/scripts/counts.py --check` → **401 строка, открытых 97** (на сдаче 3/27/67;
|
||||
после пере-взвеса PD-401 приёмкой — 3/28/66); было 398/96 — PD-399 закрыта, PD-400/PD-401 заведены;
|
||||
«битая форма: []», хвост чист. ⚠ Первая редакция этой строки называла «400 строк» — снято
|
||||
пере-счётом ревьюера, число выше — командой.
|
||||
- Миграции: `00026` добавлена, отпечаток в `migrations.sha256`; `pgstore.Migrate` гонялся каждой
|
||||
тестовой базой батареи (сотни накатов за прогон).
|
||||
- **Посадки мутаций — 5, пойманы 5/5, в копии с каноном** (`cp -a --parents platform
|
||||
docs/architecture/14-api-contract`, скрипт `~/tm-p9-mut/run-mutations.sh`, логи `~/tm-p9-mut/*.log`),
|
||||
вердикт по ДЕЛЬТЕ против чистой базы ТОЙ ЖЕ копии (база EXIT=0, FAILS пусто) и по ТОПИЧНОСТИ:
|
||||
|
||||
| # | механизм | что посажено | что упало (ровно топичный пин) |
|
||||
|---|---|---|---|
|
||||
| M1 | дверь | класс 15 слит в `ErrBankUnavailable` | `TestBankVerdictKeepsTheRemediesApart` |
|
||||
| M2 | провод | кортеж перестал требовать `sense` | `TestACorrectionRequestIsValidatedWholeWithPointers` |
|
||||
| M3 | ключи | `Cfg.KeysFile` не доезжает до argv | `TestTheSpawnedUnitCarriesTheDeploymentKeyFile` |
|
||||
| M4 | полоса | знаменатель снова `2×ceiling` | `TestARunOverADraftedBacklogOwesOnlyTheLastPass` |
|
||||
| M5 | полоса | возвращено пере-базирование на снятии стопа | `TestADraftOnlyDeploymentCountsItsOneWaveOnce` |
|
||||
|
||||
### Живой пробой (§4.2) — цепь срослась, и за $0
|
||||
|
||||
Полный лог — **`docs/p9/door-live-probe.md`**. Суть: книга через живой интейк → прогон до банкового
|
||||
стопа → превью → правка → ретрай (`already_applied`) → отказ 409 с указателем → resume → 409
|
||||
`run_in_flight` при живом прогоне → ready → **банк следующей границы несёт правку** (方源 →
|
||||
approved «Фан Юань-П9», задеклайненная поверхность исчезла из предложений). ⚠ Провайдер — локальная
|
||||
$0-заглушка (`local`-модель из `models.yaml`), потому что **в файле ключей деплоя нет ни одного
|
||||
живого провайдерского ключа** — движковый пре-флайт называл недостающие ключи по имени для всех
|
||||
шести облачных провайдеров (значения ключей в сессию не читались, гардрейл `.env` цел; замер — отказ
|
||||
резервации при потолке $0.000001, ДО вызова провайдера). Ось «деньги»: леджер стенда сверен ДВУМЯ
|
||||
путями на нетривиальном состоянии (3 холда/3 возврата/3 расчёта) — CLI `balance` и сырой SQL сошлись
|
||||
до цента ($25.000000).
|
||||
|
||||
### Опровергатели (§4, заказ) — 2 агента, обе панели принесли «ломает», всё абсорбировано
|
||||
|
||||
1. **Раскладка кодов.** «Ломает»: у двери не было гейта готовности книги (не-готовая книга доезжала
|
||||
до движка и возвращалась 500-ложью «наш дефект») — **починено** (`readyToTranslate` под мьютексом
|
||||
→ 409 `book_not_ready`). «Спорно»: транзиентные держатели флока вне сериализации (границная
|
||||
материализация, ручной tmctl) читаются словом `run_in_flight`; мьютекс внутрипроцессный
|
||||
(мульти-реплика теряет сериализацию) — **заведено PD-400**, лечение вне заказа. Утечка пути
|
||||
темп-файла в `refusals[].detail` на движковых капах — **починено** (редакция пути в
|
||||
`ApplyBankCorrections`). Косметика: SIGINT вместо SIGTERM — починено (`syscall.SIGTERM`); пустой
|
||||
`rejected` при exit 14 — гард добавлен; хвостовые байты за JSON — отвергаются (`dec.More()`).
|
||||
ПРИНЯТО БЕЗ ПРАВКИ с причиной: `since_chapter: 3.0` (валидный integer по JSON Schema) реализация
|
||||
400-ит — генерённые клиенты целых через точку не шлют, названная узость; item-код `missing` на
|
||||
присутствующем-но-пустом члене — словарь item-кодов открыт.
|
||||
2. **Форма полосы.** «Ломает» №1: continuation поверх начернённого задела — ready на 50% с вечным
|
||||
`drafting` — **починено** (`draftWork` в знаменателе и в stage; пин + посадка M4). «Ломает» №2:
|
||||
бэкфил замораживал полосу легаси verify-прогонов — **починено** (миграция пере-снимает обе базы
|
||||
live-прогонов верными предикатами; для ЗАКОНЧЕННЫХ легаси — названная аппроксимация в тексте
|
||||
миграции). «Спорно»: флип `edit_wave` при живом прогоне двигает знаменатель — **заведено PD-401**
|
||||
(окно секунды, лечение трогает словарь шва). ПРИНЯТО С ПРИЧИНОЙ: пере-нарезка внутри прогона
|
||||
обнуляет полосу (снос resolutions — правда о пере-резанной книге, осознанное исключение);
|
||||
`stage='editing'` на банковом стопе (статус `awaiting_bank` на экране первичен); старт-квитанция
|
||||
читает «последний прогон книги» (вставка видна в своей транзакции, гонка требует регресса часов).
|
||||
|
||||
### Диспозиции по норме §3 п.8 (греп открытых строк по ПОЛНЫМ путям моих файлов — 46 совпадений)
|
||||
|
||||
- **PD-399 — ЗАКРЫТА** этим паком (см. §3.5, пин назван в строке).
|
||||
- **PD-370 — предлагаю ЗАКРЫТЬ приёмке**: зонная половина закрыта 22.08, контрактная — минором
|
||||
0.5.0 (D39.161: ноль вхождений отменённой модели в каноне), ратифицированная замена (дверь правок)
|
||||
построена этим паком. Закрытие — акт лендинга, не зоны: лекарство контрактной половины не в моём дереве.
|
||||
- **PD-281 — остаётся open, дописка внесена**: сквозная форма сменила знаменатель, но прогон над
|
||||
книгой, полной в обоих проходах, по-прежнему невидим до ready; канонному минору полосы НЕ
|
||||
наследовать «the fraction always reaches one» без оговорки.
|
||||
- **PD-396 — остаётся** (вопрос владельца по строке); замечено: счёт нерешённости теперь едет
|
||||
квитанцией двери (`signature`), `UnsignedBankTerms` так и мёртв — снятие поля не брал (не в §3).
|
||||
- Остальные 42 совпадения (PD-6…PD-397 по списку грепа) — **оставлены: совпадение по файлу, не по
|
||||
механизму** — пак их механизмов не трогал; полный список воспроизводится:
|
||||
`python3` -скриптом по `DEFECT_REGISTER.md` (колонка «Где» × список файлов описи).
|
||||
|
||||
### Чего в паке НЕТ (по §3.7 — пропуски подписаны)
|
||||
|
||||
Читающая сторона банка (221/224/226 — на стопе провод банка ПУСТ, видно живьём в пробое, шаг 5) ·
|
||||
sqlc · строка 198 · PD-375…PD-398 кроме PD-399 · воркер решений (синхронность ДЕРЖИТСЯ — замер, не
|
||||
рассуждение: живой вызов двери — доли секунды при потолке, подобранном движком под таймаут) ·
|
||||
снятие обхода `--verify-bank` из пинга №21 (не в §3; `bank_released` остался в спавне и чтениях глав).
|
||||
|
||||
### Obstacle — что НЕ удалось и что НЕ проверено
|
||||
|
||||
- **Живой пробой на ОБЛАЧНОМ провайдере не удался: в файле ключей деплоя нет ни одного живого
|
||||
ключа** (deepseek/zai/kimi/gemini/mistral/openai — все названы движком отсутствующими; grok — упёрся
|
||||
в конфиг аддитивного биллинга раньше ключей). Цепь пробита на `local`-заглушке — она доказывает ШОВ
|
||||
(дверь → глагол → файлы → resume → пере-сбор банка), но НЕ качество и НЕ поведение под латентностью
|
||||
настоящего провайдера. Строка 202 «книга насквозь по-настоящему» упирается теперь ровно в ключи.
|
||||
- **Таймаут-ветка двери (SIGTERM по бюджету) и класс 15 живьём не воспроизводились** — юнит-пины
|
||||
есть, живого файлового отказа не строил.
|
||||
- **`bank_corrections_enabled: false ⇒ 404` живьём не гонял** (нужен второй демон без движка) —
|
||||
юнит-пин `TestAnUnmountedCorrectionDoorAnswers404AndSaysSoInCapabilities`.
|
||||
- **Стенд-эффект на батарею**: живой юнит стенда оставил `tm-runs.slice` без контроллеров памяти —
|
||||
`TestARunIsBoundedByItsOwnCgroup` падал, пока слайс не сброшен (`systemctl --user stop
|
||||
tm-runs.slice`); это среда, не регрессия — на чистом слайсе зелёный. Приёмке знать при пере-прогоне.
|
||||
- **Пробные артефакты вне репо**: `~/tm-p9-work/` (стенд, логи батарей, fake-провайдер),
|
||||
`~/tm-p9-mut/` (копия с каноном + логи посадок). БД стенда `tmp9stand` на локальном PG 5432 — можно
|
||||
сносить. В КАТАЛОГЕ КНИГ стенда остались мои крафтовые книги — репо не касаются.
|
||||
- Сырые логи двух опровергателей не сохранены в зону — их отчёты абсорбированы сюда и в PD-400/401.
|
||||
|
||||
### Вопросы оркестратору
|
||||
|
||||
1. **Канонный минор полосы** (D39.160: полоса едет паком, механика версии — за тобой): деплой этой
|
||||
сдачи шлёт `progress.done/total` в сквозной семантике (chapter-passes работы ПРОГОНА) + новый член
|
||||
`progress.stage` (открытый словарь `drafting`/`editing`) — впереди объявленного 0.5.0 по прецеденту
|
||||
`Run.stop_requested` (`v0.go`, ⚠-коммент у `wireProgress`). Канону нужны: пере-описание `Progress`
|
||||
(снять «restarts from zero», НЕ наследовать «always reaches one» — PD-281), член `stage`. Кадр
|
||||
`progress` меняется тем же минором (`EventProgress` ссылается на ту же схему).
|
||||
2. **PD-370** — закрытие строкой за лендингом (см. диспозиции).
|
||||
3. **Провайдерские ключи деплоя** — нужен хотя бы один живой ключ, чтобы строка 202 прошла на
|
||||
настоящем провайдере; какие имена ключей движок ждёт — в логе пробоя.
|
||||
|
||||
### Аддендум приёмки (27.08, вечер) — блокер ревьюера по полосе: причина починена, лендить ли — слово владельца
|
||||
|
||||
Ревьюер приёмки (`textmachine-29`) принёс блокер: прогон, стартовавший при `edit_wave = false` с
|
||||
переворотом флага ПОСЛЕ старта (движок объявляет форму волн первым progress-событием), делал всю
|
||||
купленную работу с полосой `0/N` навсегда — база снята флаг-зависимым предикатом момента старта и
|
||||
не пере-базируется. Мой пак знал механизм (текст миграции 00026 его называл) и вылечил только
|
||||
legacy-прогоны на буте, оставив генератор живым; строка `PD-401` его называла, но с заниженным
|
||||
весом и формулировкой «окно секунды».
|
||||
|
||||
**Сделано по первому заказу оркестратора (до его поправки «жди слова» — работа уже была зелёной,
|
||||
откат по слову, не молча):** причина, не следствие — обе базы снимаются на ФИКСИРОВАННЫХ колонках
|
||||
(`chapters_before` — редакторская, `draft_before` — черновая, `StartRun` без CASE по флагу), пара
|
||||
«числитель+база» выбирается ЖИВЫМ флагом в момент чтения (`runDone`: `draftBar+editBar` против
|
||||
`draftOnlyBar`); миграция 00026 переписана (live-прогоны — обе базы на фиксированных колонках);
|
||||
пин ровно на прод-порядок переворота — `TestAFlagThatFlipsAfterStartDoesNotStrandTheBar` (флаг
|
||||
false ДО `StartRun`, переворот ПОСЛЕ, вся работа → 2/2). Сценарий блокера сходится: до переворота
|
||||
0/C, после — `draftWork = 0` ⇒ total = C, редактура двигает 0→C, ready C/C.
|
||||
|
||||
**Побочно вскрыто и починено (класс PD-1):** два старых пина `recut_test.go` пережили снос
|
||||
сегментной модели с ложными словами — `TestLiftingTheSigningStopRestartsTheBarFromZero` объявлял
|
||||
«Mutation caught: dropping the re-capture from the re-open», а ре-кэпчер снесён и тест зелёный;
|
||||
переименован в `TestLiftingTheSigningStopLeavesTheBarWhereItStood` с честным свойством и живым
|
||||
mutation-catch (спутать пары «числитель×база»); комментарий
|
||||
`TestTheBarAndItsBaselineCountTheSamePass` переписан под пары-по-колонкам.
|
||||
|
||||
**PD-401 пере-формулирована и пере-взвешена** (info → minor, переезд в minor-секцию) по слову
|
||||
оркестратора: постоянная слепота платной работы, не транзиентный скачок; лечение в дереве названо
|
||||
в строке, статус open до решения владельца о составе лендинга. Регистр: 401 строка, 97 открытых
|
||||
(3/28/66), оба гейта чисты; полный `pgstore` зелёный (`go test ./internal/pgstore/ -count=1` → ok).
|
||||
Ось денег на нетривиальном состоянии (открытый холд + после расчёта) ревьюер исполнил на этом
|
||||
дереве сам — расхождений нет.
|
||||
|
||||
### Аддендум 2 (28.08, по слову владельца «техдолг в паке не держим») — PD-400 разобрана по половинам
|
||||
|
||||
**Половина 1 (слово `run_in_flight` шире правды) — ЗАКРЫТА в дереве**, и лечение оказалось точнее,
|
||||
чем строка думала: под мьютексом и ПОСЛЕ проверки строки прогона класс 12 от глагола прогоном быть
|
||||
не может по построению (Start/Resume ждут тот же мьютекс, реконсилер рестартует только живые строки,
|
||||
которые проверка видит) — значит `run_in_flight` отвечается ТОЛЬКО из проверки собственной строки, а
|
||||
класс 12 глагола едет `503 service_unavailable` «занято, повтори позже» с ERROR-строкой оператору.
|
||||
Пин — обновлённый кейс вердикт-таблицы; `-race` по четырём задетым пакетам зелёный, линтер 0 issues.
|
||||
**Половина 2 (внутрипроцессный мьютекс) — граница v1, названная с условием и ценой** в строке и в
|
||||
шапке `bank.go`: сериализация сужается до пер-репличной при второй реплике; цена — холостая попытка
|
||||
и «повтори позже», не ложь и не деньги; лечение при второй реплике — арбитр в хранилище. Предложен
|
||||
перевод половины в «Принятый риск» словом лендинга. **`supervisor.go:35` проверен по существу —
|
||||
ЧЕСТЕН для своего дев-пути** (ключи там законно едут окружением/наследованием); дописана одна
|
||||
страховочная фраза «прод передаёт ключи аргументом `--keys-file`; копировать этот канал в прод-спавн
|
||||
— ловушка паритета, которую флаг и закрыл». Сдвинутые этой правкой якоря runs.go пере-нацелены,
|
||||
оба гейта регистра чисты.
|
||||
|
||||
## ПАК P9 — ЗАПИСКА-ПЛАН (сессия платформы, 27.08, промт `docs/PLATFORM_P9_SESSION_PROMPT.md`)
|
||||
|
||||
План до правок, по §6 промта. Итоги и таблица комплектности — записью сдачи ниже по завершении.
|
||||
|
||||
1. **§3.3 Ключи движку.** Новый конфиг `TM_PLATFORM_ENGINE_KEYS_FILE` (абсолютный путь или отказ на
|
||||
буте, как `StateDir`) → `RunnerConfig.KeysFile` → `runs.Config` → `runner.TranslateArgs` получает
|
||||
`--keys-file` (флаг принимает ТОЛЬКО `translate` — `cmd/tmctl/invocation.go:151-157`). В окружение
|
||||
юнита ключи не кладутся. Пусто = флаг не передаётся (сегодняшнее поведение), с WARN на буте.
|
||||
2. **§3.4 Полоса.** Форма: `done/total` пере-определяются как «проходы-главы через ОБЕ волны»:
|
||||
`total = 2×ceiling` при редакторе (`edit_wave`), иначе `ceiling`; `done = clamp(главы-с-черновиком
|
||||
− draft_before) + clamp(главы-с-последним-проходом − chapters_before)`, каждый clamp в
|
||||
`[0, ceiling]`. Монотонно по построению (счётчики юнитов только растут, базы фиксированы на
|
||||
старте), база и кап прогона сохранены (комментарий `readmodel.go:437-445` чтится). Миграция 00026:
|
||||
`runs.draft_before` (бэкфил = `chapters_before`); `StartRun` снимает ОБЕ базы, ветвление по
|
||||
`$3=verify_bank` уходит. Подпись «что делается сейчас» — новое поле `progress.stage`
|
||||
(`drafting`/`editing`, открытый словарь, клиент рисует фразу сам) в JSON и в кадре `progress`.
|
||||
⚠ Канон 0.5.0 описывает `Progress` посегментно — канонный минор к смене едет с лендингом
|
||||
(D39.160: полоса ЗДЕСЬ, механика гейта версий — у оркестратора); прецедент поля впереди
|
||||
объявленной версии — `Run.stop_requested` (`v0.go:138`).
|
||||
3. **§3.5 PD-399.** Снять `pending_decisions`/`complete` с `BankPage` и `EventBank`: `BankCounts`,
|
||||
`payload()`, `wireBankPage`, `listBank`; пин `reading_test.go:113` ПЕРЕПИСЫВАЕТСЯ на новый
|
||||
контракт (присутствие total/signed + ОТСУТСТВИЕ снятых полей) — правка с пином по заказу.
|
||||
4. **§3.6 Дубль конвенции.** Движок публикует конверт `artifacts` (`bank_export` и др.) в
|
||||
`status --json` И `manifest --json` (`backend/internal/pipeline/status.go:110-134`, лендинг
|
||||
d1eb8a9). `ingest.Manifest` получает конверт; `refreshBank` берёт путь из ТОЛЬКО ЧТО прочитанного
|
||||
манифеста той же refresh-пачки; `runner.projectDB()` и парс `book.yaml` удаляются. Без фолбэка:
|
||||
старый движок без конверта = ошибка с именем причины, долг ретраится (порядок деплоя «движок
|
||||
первым» — норма зоны).
|
||||
5. **§3.1+§3.2 Дверь.** Маршрут `POST /books/{bookId}/bank/corrections` в таблице `contractSurface`,
|
||||
монтирование по `Deps.Bank != nil`; `Capabilities.bank_corrections_enabled` = тот же факт; false ⇒
|
||||
404. Вызов СИНХРОННЫЙ: обработчик валидирует проводную форму (строгий декод, оба потолка ДО
|
||||
спавна: >1 МиБ → 413 через кап тела маршрута, >5000 и вся форма → 400), рендерит документ движка
|
||||
(`tm-bank-decisions-v1`, null-окна → 0), кладёт во временный файл под `StateDir`, зовёт
|
||||
`tmctl bank-apply` прямым чайлдом с бюджетом `RunBudget` (60 с — класс бюджета вызовов движка),
|
||||
декодирует отчёт v2 аллоулистом, отвечает квитанцией. Сериализация: пер-книжный мьютекс в
|
||||
`runs.Service`, его берут corrections И `Resume`/`Start` (та же гонка на спавне свежего прогона);
|
||||
проверка «жив прогон» — ПОД мьютексом (`ReadBookForRun.HasLiveRun` → 409 `run_in_flight`).
|
||||
Раскладка кодов: заказанное — 14→409 `bank_corrections_refused` (+`refusals[]` в `Problem`),
|
||||
15→503 `bank_corrections_incomplete`, 12→409 `run_in_flight`, тело >1 МиБ→413. Моя половина, с
|
||||
доводом в отчёте: 13 (схема не мигрирована — оператор, транзиентно) → 503 `service_unavailable`;
|
||||
19 (класс без номера — рассинхрон сборок) → 503 `service_unavailable`; 10/11 (сломан вызывающий/
|
||||
деплой) → 500 `internal_error`; exit 5 и таймаут бюджета → 503 `service_unavailable`. Ни один не
|
||||
сливается в неразличимый 500: три ремеди («пере-реши»/«повтори то же»/«позови оператора»)
|
||||
различимы по коду. `depth: edit_wave→refinement`; неизвестный depth движка НЕ форвардится
|
||||
(утечка имени волны) — 500 с ERROR-строкой.
|
||||
6. **Самопроверка §4:** батарея с тремя гейтами на копии-с-каноном (скипы отдельным `-v`-грепом);
|
||||
живой пробой двери на стенде против настоящего движка (книга → банковый стоп → preview → правка →
|
||||
resume → следующий прогон видит правку; лог в отчёт); посадки мутаций — минимум по одной на
|
||||
дверь/ключи/прогресс, вердикт по дельте и топичности; опровергатели (2 агента): раскладка кодов и
|
||||
форма полосы.
|
||||
7. **Не делаю:** читающая сторона банка (221/224/226) · sqlc · строка 198 · PD-375…PD-398 кроме
|
||||
PD-399 · воркер решений · снятие обхода `--verify-bank` из пинга №21 (не в составе §3; трогаю
|
||||
`bank_released` только в чтении полосы).
|
||||
|
||||
## ПАК P8-REVIEW ПРИНЯТ И ЗАЛЕНДЁН (оркестратор №19, 27.08) — ратификация D39.159
|
||||
|
||||
Пак принят целиком: четыре оси, 24 новые строки, 17 дописок, каталог воспроизведения. Тело приёмки —
|
||||
|
|
@ -81,14 +509,14 @@
|
|||
2. **⛔ БЛОКЕР ЖИВОГО ПРОГОНА: движок на SaaS не получает провайдерских ключей** — строка **211**
|
||||
единого бэклога, тело там. Ваша половина: путь к файлу ключей едет из конфига платформы в
|
||||
аргументы движка, а дев-супервизор переводится на тот же механизм. Сейчас пути РАЗНЫЕ —
|
||||
`internal/ingest/supervisor.go:35-36,65-67`=`Provider keys reach the engine through it` наследует
|
||||
`internal/ingest/supervisor.go` (до-паковые строки 35-36 и 65-67 — «Provider keys reach the engine through it»; комментарий переписан лендингом P9, якорь исторический) наследует
|
||||
окружение платформы, а прод-спавн ставит юниту ровно один элемент
|
||||
(`internal/runs/spawn.go:168`=`Env: engineEnv(engineStreamID(`). Поэтому стенд зелёный, а прод
|
||||
(`internal/runs/spawn.go`, до-паковая строка 168 `Env: engineEnv(…)`; с лендингом P9 ключи едут аргументом `--keys-file`, якорь исторический). Поэтому стенд зелёный, а прод
|
||||
голодает, и ни один тест упасть не мог. Пока два пути кормят движок по-разному, следующий такой
|
||||
блокер снова пройдёт всю батарею.
|
||||
|
||||
3. **Дубль движковой конвенции у вас.** `internal/runner/artifacts.go:65-95`=`neither project_db nor book_id`
|
||||
сам вычисляет путь БД книги, повторяя `backend/internal/config/book.go:163-167`=`b.ProjectDB = filepath.Join(dir, b.BookID+".db")`;
|
||||
3. **Дубль движковой конвенции у вас.** `internal/runner/artifacts.go` (до-паковые строки 65-95 — «neither project_db nor book_id»; `projectDB` снесён лендингом P9, файл ужался, якорь исторический)
|
||||
сам вычисляет путь БД книги, повторяя `backend/internal/config/book.go` (до-паковые строки 163-167 — `b.ProjectDB = filepath.Join(…)`; конвенция ушла в конверт манифеста движковой половиной, якорь исторический);
|
||||
смена дефолта в движке тихо уведёт ваше чтение банка на несуществующий путь. Строка **213**.
|
||||
Фикс аддитивный и без ломки: движок отдаёт путь артефакта, вы выкидываете свой `projectDB()`.
|
||||
⚠ Пере-именование банк-экспорта в фикс-имя — ЛОМАЮЩЕЕ, ему место в окне строки 161, не здесь.
|
||||
|
|
|
|||
|
|
@ -115,6 +115,15 @@ type RunnerConfig struct {
|
|||
// Defaulted to the landed form (row 145, D39.122) and overridable for a build that spells the flag
|
||||
// differently; an override that does not carry {{usd}} is refused rather than sent as a literal.
|
||||
CeilingArg string
|
||||
// KeysFile is the DEPLOYMENT's provider-key file, handed to `translate` as `--keys-file`
|
||||
// (row 211). The path travels as an engine argument and the keys never pass through this
|
||||
// process or the unit's environment. Empty means the flag is not passed — the engine then
|
||||
// depends on a `.env` beside each book, which nothing on the SaaS path writes.
|
||||
//
|
||||
// ⚠ The variable is `_PATH`, not `_FILE`, and the suffix is load-bearing: in this service's own
|
||||
// convention `KEY_FILE` names a file whose CONTENT replaces KEY's value (see loader.secret) —
|
||||
// this value IS the path, read by the engine and never by this process.
|
||||
KeysFile string
|
||||
// MemoryMax and TasksMax bound ONE run's cgroup (PD-13).
|
||||
MemoryMax string
|
||||
TasksMax int
|
||||
|
|
@ -424,6 +433,7 @@ func (c *Config) loadRunner(l *loader) error {
|
|||
StateDir: l.env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform"),
|
||||
MarkerBinary: l.env("TM_PLATFORM_CTL_BIN", ""),
|
||||
CeilingArg: l.env("TM_PLATFORM_ENGINE_CEILING_ARG", "--ceiling-usd {{usd}}"),
|
||||
KeysFile: l.env("TM_PLATFORM_ENGINE_KEYS_PATH", ""),
|
||||
// A run gets a generous but finite share of the machine. The figure is a BACKSTOP, not a
|
||||
// sizing: a translation is bounded by its ceiling in dollars, and this bounds the one way a
|
||||
// single run can hurt the others regardless of money (PD-13).
|
||||
|
|
@ -444,6 +454,12 @@ func (c *Config) loadRunner(l *loader) error {
|
|||
if !filepath.IsAbs(r.StateDir) {
|
||||
return fmt.Errorf("config: TM_PLATFORM_STATE_DIR must be an absolute path, got %q", r.StateDir)
|
||||
}
|
||||
// Absolute for the same reason StateDir is: the flag is read by a systemd unit whose working
|
||||
// directory is the BOOK's, so a relative path would name a different file per book — and the
|
||||
// symptom, a paid run failing at its first provider call, points nowhere near the cause.
|
||||
if r.KeysFile != "" && !filepath.IsAbs(r.KeysFile) {
|
||||
return fmt.Errorf("config: TM_PLATFORM_ENGINE_KEYS_PATH must be an absolute path, got %q", r.KeysFile)
|
||||
}
|
||||
var err error
|
||||
if r.TasksMax, err = l.number("TM_PLATFORM_RUN_TASKS_MAX", r.TasksMax); err != nil {
|
||||
return err
|
||||
|
|
|
|||
437
platform/internal/httpapi/bank.go
Normal file
437
platform/internal/httpapi/bank.go
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
"textmachine/platform/internal/runs"
|
||||
)
|
||||
|
||||
// Bank is the correction door, as the HTTP layer needs to see it.
|
||||
type Bank interface {
|
||||
ApplyBankCorrections(ctx context.Context, in runs.BankCorrectionsInput) (runs.BankReceipt, error)
|
||||
}
|
||||
|
||||
// bank.go: the correction door, `POST /books/{bookId}/bank/corrections` (canon 0.5.0,
|
||||
// §applyBankCorrections) — the HTTP half over runs.ApplyBankCorrections.
|
||||
//
|
||||
// The handler owns the WIRE form and everything the canon orders measured before the engine is
|
||||
// spawned: the strict decode (an undeclared member refuses the request — this schema declares
|
||||
// `additionalProperties: false`, unlike the tolerant JSON routes next door), the whole-form
|
||||
// validation into `400 invalid_request`, the 1 MiB body → `413`, and the 5000-correction ceiling.
|
||||
// The count ceiling is measured HERE on the wire form; the BYTE ceiling is measured twice — the
|
||||
// body cap here, and the RENDERED document against the engine's own cap in runs (the envelope adds
|
||||
// a few bytes over the wire's) — so the engine's copies of both caps, which it classifies as its
|
||||
// refusal class 14, are never reached and the canon's 400/413 words are kept.
|
||||
|
||||
// maxCorrections is the canon's hard ceiling on one act (§BankCorrectionsRequest, mirrored from the
|
||||
// engine's own maxDecisions — chosen there so one call fits a caller's timeout).
|
||||
const maxCorrections = 5000
|
||||
|
||||
// termKinds is the wire's closed `TermKind` vocabulary (canon §TermKind). The engine accepts any
|
||||
// non-empty string through this door; the wire is deliberately narrower — a kind the reading
|
||||
// surface does not know cannot be set through it.
|
||||
var termKinds = map[string]bool{"name": true, "place": true, "title": true, "term": true, "nickname": true}
|
||||
|
||||
type wireCorrectionsRequest struct {
|
||||
BookID *string `json:"book_id"`
|
||||
// Preview is REQUIRED, not defaulted: which of the two acts this call is must be said.
|
||||
Preview *bool `json:"preview"`
|
||||
Corrections []wireCorrection `json:"corrections"`
|
||||
}
|
||||
|
||||
// wireCorrection is one correction as the wire declares it. Every member is presence-sensitive —
|
||||
// the tuple form requires ALL FOUR of its members precisely so a caller cannot omit `sense` and
|
||||
// silently name a DIFFERENT term (an unknown key legally ADDS one here, so the price of the
|
||||
// omission would be a quiet parallel row, not a refusal). Presence-sensitive means every member
|
||||
// must tell «absent» from «sent as null»: a plain pointer collapses the two, and the schema's
|
||||
// presence rules (`oneOf` on the identity, `not: required` on a decline's dst/kind) are about KEYS,
|
||||
// value irrelevant — a client that serializes every optional field as null walked straight through
|
||||
// them (workflow finding, P9). Hence nullable* for every member, not just the windows.
|
||||
type wireCorrection struct {
|
||||
Action nullableString `json:"action"`
|
||||
ID nullableString `json:"id"`
|
||||
Src nullableString `json:"src"`
|
||||
Sense nullableString `json:"sense"`
|
||||
SinceChapter nullableInt `json:"since_chapter"`
|
||||
UntilChapter nullableInt `json:"until_chapter"`
|
||||
Dst nullableString `json:"dst"`
|
||||
Kind nullableString `json:"kind"`
|
||||
Note nullableString `json:"note"`
|
||||
}
|
||||
|
||||
// nullableInt tells an ABSENT member from an explicit `null` from a number — the window members are
|
||||
// `integer|null` and required in the tuple form, so all three states carry meaning.
|
||||
type nullableInt struct {
|
||||
Given bool
|
||||
Value *int
|
||||
}
|
||||
|
||||
func (n *nullableInt) UnmarshalJSON(b []byte) error {
|
||||
n.Given = true
|
||||
if bytes.Equal(bytes.TrimSpace(b), []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
var v int
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Value = &v
|
||||
return nil
|
||||
}
|
||||
|
||||
// nullableString is nullableInt's shape for the string members. None of them is `string|null` in
|
||||
// the schema, so `Given && Value == nil` is always a type violation — but it must be SEEN to be
|
||||
// refused, which a *string cannot do.
|
||||
type nullableString struct {
|
||||
Given bool
|
||||
Value *string
|
||||
}
|
||||
|
||||
func (n *nullableString) UnmarshalJSON(b []byte) error {
|
||||
n.Given = true
|
||||
if bytes.Equal(bytes.TrimSpace(b), []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
var v string
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Value = &v
|
||||
return nil
|
||||
}
|
||||
|
||||
// str reads the member's value where validation has already established it is a present, non-null
|
||||
// string.
|
||||
func (n nullableString) str() string { return *n.Value }
|
||||
|
||||
func (h *v0) bankCorrections(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := principal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
// The canon's 1 MiB document ceiling, measured on the wire BEFORE anything is rendered
|
||||
// or spawned (§applyBankCorrections). The route's body cap is exactly the document cap.
|
||||
Fail(w, r, CodePayloadTooLarge)
|
||||
return
|
||||
}
|
||||
h.log.InfoContext(r.Context(), "the body of a correction request did not arrive", "err", err)
|
||||
Invalid(w, r)
|
||||
return
|
||||
}
|
||||
// STRICT, unlike the run request next door, and the difference is the canon's own: this schema
|
||||
// declares `additionalProperties: false` on both levels — an unknown member is a caller
|
||||
// believing it set something, and the quiet version of that is a correction half-applied.
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.DisallowUnknownFields()
|
||||
var req wireCorrectionsRequest
|
||||
if err := dec.Decode(&req); err != nil || dec.More() {
|
||||
// dec.More(): the body is ONE document; trailing bytes after it are a second one nobody
|
||||
// will read, which is the same quiet half-belief the strict decode refuses.
|
||||
Invalid(w, r)
|
||||
return
|
||||
}
|
||||
if items := validateCorrections(req, r.PathValue("bookId")); len(items) > 0 {
|
||||
Invalid(w, r, items...)
|
||||
return
|
||||
}
|
||||
receipt, err := h.bank.ApplyBankCorrections(r.Context(), runs.BankCorrectionsInput{
|
||||
UserID: user,
|
||||
BookID: r.PathValue("bookId"),
|
||||
Preview: *req.Preview,
|
||||
Decisions: renderDecisions(req.Corrections),
|
||||
})
|
||||
if err != nil {
|
||||
h.bankFail(w, r, err)
|
||||
return
|
||||
}
|
||||
out, err := projectBankReceipt(receipt)
|
||||
if err != nil {
|
||||
// A word of the engine's this build cannot map — the seam's vocabulary moved. Refused rather
|
||||
// than forwarded: an unmapped value here would put a wave name on the wire.
|
||||
h.log.ErrorContext(r.Context(), "the correction receipt could not be projected", "err", err)
|
||||
Fail(w, r, CodeInternalError)
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, r, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// bankFail maps the door's own refusals; everything else falls through to the shared table.
|
||||
func (h *v0) bankFail(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var refused *runs.ErrBankRefused
|
||||
switch {
|
||||
case errors.As(err, &refused):
|
||||
// All-or-nothing: one refused correction refuses the set, and the refused one is what the
|
||||
// user has to see (canon §applyBankCorrections). The receipt does NOT ride the refusal —
|
||||
// the envelope grows the refusal members instead.
|
||||
p := Problem{Code: CodeBankCorrectionsRefused,
|
||||
Refusals: make([]CorrectionRefusal, 0, len(refused.Refusals))}
|
||||
for _, f := range refused.Refusals {
|
||||
pointer := ""
|
||||
if f.Index >= 0 {
|
||||
pointer = "/corrections/" + strconv.Itoa(f.Index)
|
||||
}
|
||||
p.Refusals = append(p.Refusals, CorrectionRefusal{Pointer: pointer, Detail: f.Reason})
|
||||
}
|
||||
WriteProblem(w, r, p)
|
||||
case errors.Is(err, runs.ErrBankDocumentTooLarge):
|
||||
// The rendered document is over the engine's byte ceiling — the same fact the body cap
|
||||
// answers, measured on the form the engine actually reads (the envelope's few bytes above
|
||||
// the wire's). One word for one fact: 413, split the document.
|
||||
Fail(w, r, CodePayloadTooLarge)
|
||||
case errors.Is(err, runs.ErrBankIncomplete):
|
||||
// The document was ACCEPTED and did not land whole; the remedy is to re-send the SAME one.
|
||||
// Its own code, because the addressee differs from every other 503: the machine retries
|
||||
// verbatim, no one re-decides.
|
||||
Fail(w, r, CodeBankCorrectionsIncomplete)
|
||||
case errors.Is(err, runs.ErrBankUnavailable):
|
||||
Fail(w, r, CodeServiceUnavailable)
|
||||
default:
|
||||
h.fail(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// validateCorrections is the canon's whole-form validation, every finding with its JSON Pointer.
|
||||
// The rules are the schema's own (§BankCorrectionsRequest, §BankCorrection); what the schema leaves
|
||||
// to the service — an unknown id, a window that ends before it begins, contradictions the SET
|
||||
// introduces — stays the engine's and comes back as `409 bank_corrections_refused`.
|
||||
func validateCorrections(req wireCorrectionsRequest, pathBook string) []Item {
|
||||
var items []Item
|
||||
switch {
|
||||
case req.BookID == nil || *req.BookID == "":
|
||||
items = append(items, Item{Pointer: "/book_id", Code: ItemMissing})
|
||||
case *req.BookID != pathBook:
|
||||
// The deliberate second carrier of one fact: a set computed for one book landing in another
|
||||
// is not a mistake anything downstream could notice (canon §BankCorrectionsRequest.book_id).
|
||||
items = append(items, Item{Pointer: "/book_id", Code: ItemMalformed})
|
||||
}
|
||||
if req.Preview == nil {
|
||||
items = append(items, Item{Pointer: "/preview", Code: ItemMissing})
|
||||
}
|
||||
switch n := len(req.Corrections); {
|
||||
case n == 0:
|
||||
items = append(items, Item{Pointer: "/corrections", Code: ItemMissing})
|
||||
case n > maxCorrections:
|
||||
// The count ceiling, measured on the WIRE form before the engine is spawned — the engine
|
||||
// classifies its own copy of this cap as a refusal of the set, the canon says the schema
|
||||
// bound is a 400: split the document.
|
||||
//
|
||||
// And the ceiling is a POINT OF STOPPING, not only an item: walking the elements past it
|
||||
// itemized every flaw of an oversized document, and a megabyte of minimal corrections came
|
||||
// back as tens of megabytes of pointers — a ~100× amplification a caller does not even need
|
||||
// a session for more than once (reviewer finding, P9). Whoever hit the cap gets the cap.
|
||||
items = append(items, Item{Pointer: "/corrections", Code: ItemOutOfRange})
|
||||
return items
|
||||
}
|
||||
for i, c := range req.Corrections {
|
||||
items = append(items, validateCorrection(c, "/corrections/"+strconv.Itoa(i))...)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func validateCorrection(c wireCorrection, at string) []Item {
|
||||
var items []Item
|
||||
// None of the string members is nullable in the schema: an explicit `null` on any of them is a
|
||||
// type violation, refused as malformed — and refused HERE, before the presence rules below read
|
||||
// `Given`, so a null never doubles as a value.
|
||||
for _, m := range []struct {
|
||||
n nullableString
|
||||
name string
|
||||
}{{c.Action, "/action"}, {c.ID, "/id"}, {c.Src, "/src"}, {c.Sense, "/sense"},
|
||||
{c.Dst, "/dst"}, {c.Kind, "/kind"}, {c.Note, "/note"}} {
|
||||
if m.n.Given && m.n.Value == nil {
|
||||
items = append(items, Item{Pointer: at + m.name, Code: ItemMalformed})
|
||||
}
|
||||
}
|
||||
if len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
approve := false
|
||||
switch {
|
||||
case !c.Action.Given:
|
||||
items = append(items, Item{Pointer: at + "/action", Code: ItemMissing})
|
||||
case c.Action.str() == "approve":
|
||||
approve = true
|
||||
case c.Action.str() == "decline":
|
||||
default:
|
||||
items = append(items, Item{Pointer: at + "/action", Code: ItemMalformed})
|
||||
}
|
||||
// The identity: an existing row by `id` XOR a term by its FULL tuple — both at once could name
|
||||
// two different terms, and the caller would never learn which one was taken. Presence is the
|
||||
// KEY's presence: an explicit null was already refused above, so it cannot smuggle a member in
|
||||
// or out of either form.
|
||||
tupleGiven := c.Src.Given || c.Sense.Given || c.SinceChapter.Given || c.UntilChapter.Given
|
||||
switch {
|
||||
case c.ID.Given && tupleGiven:
|
||||
items = append(items, Item{Pointer: at, Code: ItemMalformed})
|
||||
case c.ID.Given:
|
||||
if c.ID.str() == "" {
|
||||
items = append(items, Item{Pointer: at + "/id", Code: ItemMalformed})
|
||||
}
|
||||
default:
|
||||
// The tuple form, all four members or nothing: a partial key is ANOTHER key, and an unknown
|
||||
// key legally ADDS a term here — so the omission's price would be a quiet parallel row.
|
||||
if !c.Src.Given || c.Src.str() == "" {
|
||||
items = append(items, Item{Pointer: at + "/src", Code: ItemMissing})
|
||||
}
|
||||
if !c.Sense.Given {
|
||||
items = append(items, Item{Pointer: at + "/sense", Code: ItemMissing})
|
||||
}
|
||||
items = append(items, requireWindow(c.SinceChapter, at+"/since_chapter")...)
|
||||
items = append(items, requireWindow(c.UntilChapter, at+"/until_chapter")...)
|
||||
}
|
||||
if approve {
|
||||
if !c.Dst.Given || c.Dst.str() == "" {
|
||||
// An approved term with no rendering is not a weak approval — it would fail the next run.
|
||||
items = append(items, Item{Pointer: at + "/dst", Code: ItemMissing})
|
||||
}
|
||||
if c.Kind.Given && !termKinds[c.Kind.str()] {
|
||||
// The wire's `TermKind` is the READING surface's closed vocabulary; a kind it does not
|
||||
// know cannot be set through this door even though the engine itself would take it.
|
||||
items = append(items, Item{Pointer: at + "/kind", Code: ItemMalformed})
|
||||
}
|
||||
} else if c.Action.Given {
|
||||
// A rendering on a decline says the caller meant to approve, and half of that is not
|
||||
// something to guess at; `kind` is forbidden with it. The rule is about the KEY: a decline
|
||||
// carrying `dst: null` believed it said something about the rendering just as loudly.
|
||||
if c.Dst.Given {
|
||||
items = append(items, Item{Pointer: at + "/dst", Code: ItemMalformed})
|
||||
}
|
||||
if c.Kind.Given {
|
||||
items = append(items, Item{Pointer: at + "/kind", Code: ItemMalformed})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func requireWindow(n nullableInt, at string) []Item {
|
||||
switch {
|
||||
case !n.Given:
|
||||
return []Item{{Pointer: at, Code: ItemMissing}}
|
||||
case n.Value != nil && *n.Value < 1:
|
||||
return []Item{{Pointer: at, Code: ItemOutOfRange}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderDecisions projects the validated wire form into the seam's vocabulary. The `null` window
|
||||
// becomes the seam's 0 and an empty `sense` stays empty — by this point the two forms mean the same
|
||||
// thing to the engine (canon: «null-семантика окна — как у BankTerm; проекция null→0 — платформа»).
|
||||
func renderDecisions(cs []wireCorrection) []ingest.BankDecision {
|
||||
out := make([]ingest.BankDecision, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
d := ingest.BankDecision{Action: c.Action.str()}
|
||||
if c.ID.Given {
|
||||
d.ID = c.ID.str()
|
||||
} else {
|
||||
d.Src, d.Sense = c.Src.str(), c.Sense.str()
|
||||
if v := c.SinceChapter.Value; v != nil {
|
||||
d.SinceChapter = *v
|
||||
}
|
||||
if v := c.UntilChapter.Value; v != nil {
|
||||
d.UntilChapter = *v
|
||||
}
|
||||
}
|
||||
if c.Dst.Given {
|
||||
d.Dst = c.Dst.str()
|
||||
}
|
||||
if c.Kind.Given {
|
||||
d.Kind = c.Kind.str()
|
||||
}
|
||||
if c.Note.Given {
|
||||
d.Note = c.Note.str()
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The receipt's wire shapes (canon §BankCorrectionsReceipt), an allowlist like every projection.
|
||||
|
||||
type wireBankReceipt struct {
|
||||
Preview bool `json:"preview"`
|
||||
Changed bool `json:"changed"`
|
||||
Depth string `json:"depth"`
|
||||
Accepted []wireAcceptedCorrection `json:"accepted"`
|
||||
PreexistingFaults int `json:"preexisting_faults"`
|
||||
// Signature is `null` when no run has reached a signing stop yet — nothing to count against.
|
||||
Signature *wireSignatureCount `json:"signature"`
|
||||
}
|
||||
|
||||
type wireAcceptedCorrection struct {
|
||||
Index int `json:"index"`
|
||||
Action string `json:"action"`
|
||||
ID string `json:"id"`
|
||||
Src string `json:"src"`
|
||||
Dst *string `json:"dst"`
|
||||
State string `json:"state"`
|
||||
// Displaced says the correction overwrote an earlier word — a fact, not an error. The
|
||||
// itemization of WHAT stays server-side: its vocabulary is the engine's free text.
|
||||
Displaced bool `json:"displaced"`
|
||||
}
|
||||
|
||||
type wireSignatureCount struct {
|
||||
Surfaces int `json:"surfaces"`
|
||||
Undecided int `json:"undecided"`
|
||||
Unreadable bool `json:"unreadable"`
|
||||
}
|
||||
|
||||
// projectBankReceipt translates the service's receipt into contract words, refusing any value it
|
||||
// cannot map: the engine's vocabulary is wave names, and forwarding an unmapped one would leak the
|
||||
// pipeline's architecture through the one field a screen quotes.
|
||||
func projectBankReceipt(rec runs.BankReceipt) (wireBankReceipt, error) {
|
||||
depth, err := contractDepth(rec.Depth)
|
||||
if err != nil {
|
||||
return wireBankReceipt{}, err
|
||||
}
|
||||
out := wireBankReceipt{
|
||||
Preview: rec.Preview, Changed: rec.Changed, Depth: depth,
|
||||
Accepted: make([]wireAcceptedCorrection, 0, len(rec.Accepted)),
|
||||
PreexistingFaults: rec.PreexistingFaults,
|
||||
}
|
||||
for _, a := range rec.Accepted {
|
||||
if a.Action != "approve" && a.Action != "decline" {
|
||||
return wireBankReceipt{}, fmt.Errorf("httpapi: an accepted correction carries the action %q", a.Action)
|
||||
}
|
||||
if a.State != "applied" && a.State != "already_applied" {
|
||||
return wireBankReceipt{}, fmt.Errorf("httpapi: an accepted correction carries the state %q", a.State)
|
||||
}
|
||||
w := wireAcceptedCorrection{
|
||||
Index: a.Index, Action: a.Action, ID: a.ID, Src: a.Src,
|
||||
State: a.State, Displaced: len(a.Replaced) > 0,
|
||||
}
|
||||
if a.Action == "approve" {
|
||||
dst := a.Dst
|
||||
w.Dst = &dst
|
||||
}
|
||||
out.Accepted = append(out.Accepted, w)
|
||||
}
|
||||
if rec.Signature != nil {
|
||||
out.Signature = &wireSignatureCount{
|
||||
Surfaces: rec.Signature.Surfaces, Undecided: rec.Signature.Undecided,
|
||||
Unreadable: rec.Signature.Unreadable,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// contractDepth translates the engine's depth into the contract's open vocabulary. `edit_wave` is
|
||||
// a WAVE name and forbidden on the wire; `refinement` is the product word with the same two halves
|
||||
// of meaning — applied when a run next refines the text, and the draft is not re-formed.
|
||||
func contractDepth(engine string) (string, error) {
|
||||
if engine == ingest.DepthEditWave {
|
||||
return "refinement", nil
|
||||
}
|
||||
return "", fmt.Errorf("httpapi: the engine's correction depth %q has no contract word", engine)
|
||||
}
|
||||
338
platform/internal/httpapi/bank_test.go
Normal file
338
platform/internal/httpapi/bank_test.go
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
"textmachine/platform/internal/pgstore"
|
||||
"textmachine/platform/internal/runs"
|
||||
)
|
||||
|
||||
// The correction door (canon 0.5.0 §applyBankCorrections): the wire form, its whole-form
|
||||
// validation, the receipt's projection and the refusal envelope.
|
||||
|
||||
type fakeBank struct {
|
||||
in runs.BankCorrectionsInput
|
||||
called bool
|
||||
receipt runs.BankReceipt
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBank) ApplyBankCorrections(_ context.Context, in runs.BankCorrectionsInput) (runs.BankReceipt, error) {
|
||||
f.in, f.called = in, true
|
||||
return f.receipt, f.err
|
||||
}
|
||||
|
||||
func bankServer(t *testing.T, f *fakeBank) http.Handler {
|
||||
t.Helper()
|
||||
return v0ServerWith(t, Deps{Bank: f, Capabilities: Capabilities{BankCorrectionsEnabled: true}})
|
||||
}
|
||||
|
||||
// A deployment that has not mounted the door says so twice with ONE fact: the capability is false
|
||||
// and the path answers 404 (canon: «declared ahead of its serving half»).
|
||||
func TestAnUnmountedCorrectionDoorAnswers404AndSaysSoInCapabilities(t *testing.T) {
|
||||
h := readingServer(t, &fakeLibrary{}) // no Bank dependency
|
||||
w := call(t, h, "POST", "/v0/books/bk_1/bank/corrections",
|
||||
`{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","sense":"","since_chapter":null,"until_chapter":null}]}`)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("an unmounted door answered %d, want 404", w.Code)
|
||||
}
|
||||
caps := decode(t, call(t, h, "GET", "/v0/capabilities", ""))
|
||||
if got, ok := caps["bank_corrections_enabled"]; !ok || got != false {
|
||||
t.Errorf("bank_corrections_enabled = %v, want false on a deployment without the door", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole-form validation: every refusal is a 400 with the offending member's JSON Pointer, and
|
||||
// nothing reaches the service. The rules are the schema's own — the tuple takes ALL FOUR members,
|
||||
// identity is id XOR tuple, `dst` belongs to `approve` alone, the wire's TermKind is closed.
|
||||
func TestACorrectionRequestIsValidatedWholeWithPointers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, body string
|
||||
pointer string
|
||||
}{
|
||||
{"no preview", `{"book_id":"bk_1","corrections":[{"action":"decline","id":"tm_1"}]}`, "/preview"},
|
||||
{"book_id mismatch", `{"book_id":"bk_OTHER","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`, "/book_id"},
|
||||
{"no corrections", `{"book_id":"bk_1","preview":true,"corrections":[]}`, "/corrections"},
|
||||
{"no action", `{"book_id":"bk_1","preview":true,"corrections":[{"id":"tm_1"}]}`, "/corrections/0/action"},
|
||||
{"both identities", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","src":"蛊"}]}`, "/corrections/0"},
|
||||
{"a partial tuple", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","since_chapter":null,"until_chapter":null}]}`, "/corrections/0/sense"},
|
||||
{"approve without dst", `{"book_id":"bk_1","preview":false,"corrections":[{"action":"approve","id":"tm_1"}]}`, "/corrections/0/dst"},
|
||||
{"dst on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","dst":"Гу"}]}`, "/corrections/0/dst"},
|
||||
{"kind on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","kind":"name"}]}`, "/corrections/0/kind"},
|
||||
{"a kind outside the wire vocabulary", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"approve","id":"tm_1","dst":"Гу","kind":"weapon"}]}`, "/corrections/0/kind"},
|
||||
{"a zero chapter", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","sense":"","since_chapter":0,"until_chapter":null}]}`, "/corrections/0/since_chapter"},
|
||||
// The presence rules are about KEYS, and a client that serializes every optional member as
|
||||
// `null` must not walk through them: an explicit null is a type violation on every string
|
||||
// member, seen and refused rather than collapsed into «absent» (workflow finding, P9).
|
||||
{"a null src smuggled into the id form", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"approve","id":"tm_1","src":null,"sense":null,"dst":"Гу"}]}`, "/corrections/0/src"},
|
||||
{"a null dst on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","dst":null,"kind":null}]}`, "/corrections/0/dst"},
|
||||
{"a null action", `{"book_id":"bk_1","preview":true,"corrections":[{"action":null,"id":"tm_1"}]}`, "/corrections/0/action"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &fakeBank{}
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", tc.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d, want 400: %s", w.Code, w.Body)
|
||||
}
|
||||
if f.called {
|
||||
t.Error("an invalid document reached the service")
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"`+tc.pointer+`"`) {
|
||||
t.Errorf("the 400 does not point at %s: %s", tc.pointer, w.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
// An UNDECLARED member refuses the request — this schema is strict, unlike the run request.
|
||||
f := &fakeBank{}
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections",
|
||||
`{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","aliases":["蛊虫"]}]}`)
|
||||
if w.Code != http.StatusBadRequest || f.called {
|
||||
t.Errorf("an undeclared member answered %d (service called: %v), want a 400 refusal", w.Code, f.called)
|
||||
}
|
||||
}
|
||||
|
||||
// A valid document reaches the service in seam vocabulary — `null` windows as the seam's 0 — and
|
||||
// the receipt comes back in contract words: `refinement` and never the engine's wave name, `dst`
|
||||
// null on a decline, `displaced` a boolean, `signature` present when a stop exists.
|
||||
func TestAValidCorrectionRoundTripsIntoTheReceipt(t *testing.T) {
|
||||
f := &fakeBank{receipt: runs.BankReceipt{
|
||||
Preview: false, Changed: true, Depth: "edit_wave",
|
||||
Accepted: []ingest.AcceptedDecision{
|
||||
{Index: 0, Action: "approve", ID: "tm_9", Src: "蛊", Dst: "гу", State: "applied", Replaced: []string{"a previous rendering"}},
|
||||
{Index: 1, Action: "decline", ID: "tm_2", Src: "方源", State: "already_applied"},
|
||||
},
|
||||
PreexistingFaults: 2,
|
||||
Signature: &ingest.SignatureState{Map: "/srv/books/bk_1/x.mined-signature.yaml", Surfaces: 200, Undecided: 40},
|
||||
}}
|
||||
body := `{"book_id":"bk_1","preview":false,"corrections":[
|
||||
{"action":"approve","src":"蛊","sense":"","since_chapter":null,"until_chapter":null,"dst":"гу","kind":"term","note":"почему"},
|
||||
{"action":"decline","id":"tm_2"}]}`
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body)
|
||||
}
|
||||
if !f.called || f.in.BookID != "bk_1" || f.in.Preview {
|
||||
t.Fatalf("the service saw %+v", f.in)
|
||||
}
|
||||
if len(f.in.Decisions) != 2 {
|
||||
t.Fatalf("decisions: %+v", f.in.Decisions)
|
||||
}
|
||||
if d := f.in.Decisions[0]; d.Src != "蛊" || d.Sense != "" || d.SinceChapter != 0 || d.UntilChapter != 0 ||
|
||||
d.Dst != "гу" || d.Kind != "term" || d.Note != "почему" || d.ID != "" {
|
||||
t.Errorf("the tuple form reached the seam as %+v", d)
|
||||
}
|
||||
if d := f.in.Decisions[1]; d.ID != "tm_2" || d.Src != "" {
|
||||
t.Errorf("the id form reached the seam as %+v", d)
|
||||
}
|
||||
got := decode(t, w)
|
||||
for _, k := range []string{"preview", "changed", "depth", "accepted", "preexisting_faults", "signature"} {
|
||||
if _, ok := got[k]; !ok {
|
||||
t.Errorf("the receipt is missing the required member %q", k)
|
||||
}
|
||||
}
|
||||
if got["depth"] != "refinement" {
|
||||
t.Errorf("depth = %v, want the contract's word", got["depth"])
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "edit_wave") {
|
||||
t.Errorf("the engine's wave name reached the wire: %s", w.Body)
|
||||
}
|
||||
rows, _ := got["accepted"].([]any)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("accepted: %v", got["accepted"])
|
||||
}
|
||||
first, _ := rows[0].(map[string]any)
|
||||
if first["displaced"] != true || first["state"] != "applied" || first["dst"] != "гу" {
|
||||
t.Errorf("accepted[0]: %v", first)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "a previous rendering") {
|
||||
t.Errorf("the engine's free-text itemization reached the wire: %s", w.Body)
|
||||
}
|
||||
second, _ := rows[1].(map[string]any)
|
||||
if second["dst"] != nil || second["displaced"] != false {
|
||||
t.Errorf("accepted[1]: %v", second)
|
||||
}
|
||||
sig, _ := got["signature"].(map[string]any)
|
||||
if sig["surfaces"] != float64(200) || sig["undecided"] != float64(40) || sig["unreadable"] != false {
|
||||
t.Errorf("signature: %v", got["signature"])
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "mined-signature") {
|
||||
t.Errorf("a server-side path reached the wire: %s", w.Body)
|
||||
}
|
||||
// No stop yet: `signature` is null, not a zeroed count that reads as "everything decided".
|
||||
f.receipt.Signature = nil
|
||||
later := decode(t, call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body))
|
||||
if v, ok := later["signature"]; !ok || v != nil {
|
||||
t.Errorf("with no stop, signature = %v, want null", v)
|
||||
}
|
||||
}
|
||||
|
||||
// `"preview": true` REACHES the service as a preview — the one bit that separates a safe look from
|
||||
// an irreversible write, pinned at the seam it crosses. The reviewer regressed exactly this with
|
||||
// one constant (`Preview: false`) and the whole battery stayed green; the engine-side halves are
|
||||
// pinned in runner (the `--dry-run` argv, and the live no-write proof).
|
||||
//
|
||||
// Mutation caught: hardcoding Preview on the way into the service, either way.
|
||||
func TestAPreviewStaysAPreviewAcrossTheWire(t *testing.T) {
|
||||
f := &fakeBank{receipt: runs.BankReceipt{Preview: true, Depth: "edit_wave"}}
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections",
|
||||
`{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body)
|
||||
}
|
||||
if !f.called || !f.in.Preview {
|
||||
t.Errorf("preview:true reached the service as preview=%v — a safe look became a write", f.in.Preview)
|
||||
}
|
||||
if got := decode(t, w)["preview"]; got != true {
|
||||
t.Errorf("the receipt echoes preview=%v, want true", got)
|
||||
}
|
||||
f = &fakeBank{receipt: runs.BankReceipt{Preview: false, Depth: "edit_wave"}}
|
||||
call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections",
|
||||
`{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"}]}`)
|
||||
if !f.called || f.in.Preview {
|
||||
t.Errorf("preview:false reached the service as preview=%v — every write would be a no-op", f.in.Preview)
|
||||
}
|
||||
}
|
||||
|
||||
// One refused correction refuses the set: the 409 carries `refusals[]` in the Problem envelope,
|
||||
// each with the pointer of its correction — or the empty string for a refusal about the whole.
|
||||
func TestARefusedSetAnswers409WithItsRefusals(t *testing.T) {
|
||||
f := &fakeBank{err: &runs.ErrBankRefused{Refusals: []ingest.RejectedDecision{
|
||||
{Index: 1, Reason: "declining it would change nothing"},
|
||||
{Index: -1, Reason: "the set contradicts itself"},
|
||||
}}}
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections",
|
||||
`{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"},{"action":"decline","id":"tm_2"}]}`)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body)
|
||||
}
|
||||
got := decode(t, w)
|
||||
if got["code"] != "bank_corrections_refused" {
|
||||
t.Errorf("code = %v", got["code"])
|
||||
}
|
||||
refusals, _ := got["refusals"].([]any)
|
||||
if len(refusals) != 2 {
|
||||
t.Fatalf("refusals: %v", got["refusals"])
|
||||
}
|
||||
first, _ := refusals[0].(map[string]any)
|
||||
whole, _ := refusals[1].(map[string]any)
|
||||
if first["pointer"] != "/corrections/1" || whole["pointer"] != "" {
|
||||
t.Errorf("pointers: %v / %v", first["pointer"], whole["pointer"])
|
||||
}
|
||||
if first["detail"] == "" {
|
||||
t.Error("a refusal without its developer-facing reason")
|
||||
}
|
||||
}
|
||||
|
||||
// The three remedies stay apart on the wire: «re-decide» (409 refused, above), «re-send the same»
|
||||
// (503 with its OWN code) and «call the operator / retry later» (503 service_unavailable) — plus
|
||||
// the shared table's run_in_flight and not_found.
|
||||
func TestTheDoorsFailuresKeepTheirRemediesApart(t *testing.T) {
|
||||
body := `{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"}]}`
|
||||
cases := []struct {
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{runs.ErrBankIncomplete, http.StatusServiceUnavailable, "bank_corrections_incomplete"},
|
||||
{runs.ErrBankUnavailable, http.StatusServiceUnavailable, "service_unavailable"},
|
||||
{pgstore.ErrRunInFlight, http.StatusConflict, "run_in_flight"},
|
||||
{pgstore.ErrNoBook, http.StatusNotFound, "not_found"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w := call(t, bankServer(t, &fakeBank{err: tc.err}), "POST", "/v0/books/bk_1/bank/corrections", body)
|
||||
if w.Code != tc.status {
|
||||
t.Errorf("%v answered %d, want %d", tc.err, w.Code, tc.status)
|
||||
}
|
||||
if got := decode(t, w)["code"]; got != tc.code {
|
||||
t.Errorf("%v carried code %v, want %q", tc.err, got, tc.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A receipt whose vocabulary this build cannot map is refused, not forwarded: the depth field is
|
||||
// where a wave name would leak through to the one string a screen quotes.
|
||||
func TestAnUnmappableReceiptIsRefusedNotForwarded(t *testing.T) {
|
||||
f := &fakeBank{receipt: runs.BankReceipt{Depth: "draft_wave_v2"}}
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections",
|
||||
`{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status %d, want 500", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "draft_wave_v2") {
|
||||
t.Errorf("the unmapped engine word reached the wire: %s", w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The 5000-correction ceiling is a POINT OF STOPPING: the 400 carries the cap's own item and
|
||||
// nothing per element, so an oversized document cannot buy an itemization of its every flaw — a
|
||||
// megabyte of minimal corrections used to come back as tens of megabytes of pointers (~100×).
|
||||
//
|
||||
// Mutation caught: falling through the cap into the per-item walk.
|
||||
func TestTheCorrectionCountCeilingStopsTheValidation(t *testing.T) {
|
||||
f := &fakeBank{}
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"book_id":"bk_1","preview":true,"corrections":[`)
|
||||
for i := 0; i < maxCorrections+1; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
// Deliberately FLAWED elements: without the stop each would add its items to the answer.
|
||||
b.WriteString(`{"action":"decline"}`)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", b.String())
|
||||
if w.Code != http.StatusBadRequest || f.called {
|
||||
t.Fatalf("status %d (service called: %v), want a 400 refusal", w.Code, f.called)
|
||||
}
|
||||
if w.Body.Len() > 1<<10 {
|
||||
t.Errorf("the refusal of an oversized document is %d bytes — the cap did not stop the walk", w.Body.Len())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"/corrections"`) || strings.Contains(w.Body.String(), `"/corrections/0`) {
|
||||
t.Errorf("the 400 itemizes past the cap: %s", firstOf(w.Body.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// The canon's 1 MiB document ceiling, measured on the wire: over it is 413 `payload_too_large`, and
|
||||
// the service is never called.
|
||||
func TestAnOversizedCorrectionDocumentAnswers413(t *testing.T) {
|
||||
f := &fakeBank{}
|
||||
body := `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","note":"` +
|
||||
strings.Repeat("х", 1<<20) + `"}]}`
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body)
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status %d, want 413: %s", w.Code, firstOf(w.Body.String()))
|
||||
}
|
||||
if got := decode(t, w)["code"]; got != "payload_too_large" {
|
||||
t.Errorf("code = %v", got)
|
||||
}
|
||||
if f.called {
|
||||
t.Error("an oversized document reached the service")
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's byte cap is measured on the RENDERED document in runs; when it refuses, the wire
|
||||
// word is the same 413 the body cap answers — one fact, one word — never the engine's «re-decide»
|
||||
// (workflow finding, P9).
|
||||
func TestARenderedDocumentOverTheEngineCapAnswers413(t *testing.T) {
|
||||
f := &fakeBank{err: runs.ErrBankDocumentTooLarge}
|
||||
body := `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`
|
||||
w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body)
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status %d, want 413: %s", w.Code, firstOf(w.Body.String()))
|
||||
}
|
||||
if got := decode(t, w)["code"]; got != "payload_too_large" {
|
||||
t.Errorf("code = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func firstOf(s string) string {
|
||||
if len(s) > 200 {
|
||||
return s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import "net/http"
|
|||
// client generated against another one refuses to work and says so — which is why this must be
|
||||
// raised in the same commit as the code that implements a new minor, and never as a courtesy
|
||||
// afterwards.
|
||||
const ContractVersion = "0.5.0"
|
||||
const ContractVersion = "0.6.0"
|
||||
|
||||
// Capabilities is what this deployment can do: one flat document, the same for every account.
|
||||
type Capabilities struct {
|
||||
|
|
@ -25,6 +25,11 @@ type Capabilities struct {
|
|||
// ExportFormats is what `POST /books/{bookId}/exports` accepts. Empty means none are built here,
|
||||
// which is the honest answer while the export path is not built (deferred to P8).
|
||||
ExportFormats []string
|
||||
// BankCorrectionsEnabled is whether this deployment serves the correction door,
|
||||
// `POST /books/{bookId}/bank/corrections`. False means the path answers 404 and a client does
|
||||
// not offer the correction UI — machine-readable so nobody learns it by failing a user's save
|
||||
// (canon §Capabilities; the same fact that decides the route's mount).
|
||||
BankCorrectionsEnabled bool
|
||||
// PageSizeDefault is how many rows a collection returns when `limit` is omitted. It is the
|
||||
// DEPLOYMENT's number rather than a constant of the contract, and every read path takes its
|
||||
// default from the same place this answers from.
|
||||
|
|
@ -39,12 +44,13 @@ type LanguagePair struct {
|
|||
}
|
||||
|
||||
type wireCapabilities struct {
|
||||
ContractVersion string `json:"contract_version"`
|
||||
LanguagePairs []wireLanguagePair `json:"language_pairs"`
|
||||
IntakeEnabled bool `json:"intake_enabled"`
|
||||
IntakeMaxBytes int64 `json:"intake_max_bytes"`
|
||||
ExportFormats []string `json:"export_formats"`
|
||||
PageSizeDefault int `json:"page_size_default"`
|
||||
ContractVersion string `json:"contract_version"`
|
||||
LanguagePairs []wireLanguagePair `json:"language_pairs"`
|
||||
IntakeEnabled bool `json:"intake_enabled"`
|
||||
IntakeMaxBytes int64 `json:"intake_max_bytes"`
|
||||
ExportFormats []string `json:"export_formats"`
|
||||
BankCorrectionsEnabled bool `json:"bank_corrections_enabled"`
|
||||
PageSizeDefault int `json:"page_size_default"`
|
||||
}
|
||||
|
||||
type wireLanguagePair struct {
|
||||
|
|
@ -58,12 +64,13 @@ func (h *v0) capabilities(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
out := wireCapabilities{
|
||||
ContractVersion: ContractVersion,
|
||||
LanguagePairs: make([]wireLanguagePair, 0, len(h.caps.Pairs)),
|
||||
IntakeEnabled: h.caps.IntakeEnabled,
|
||||
IntakeMaxBytes: h.caps.IntakeMaxBytes,
|
||||
ExportFormats: h.caps.ExportFormats,
|
||||
PageSizeDefault: h.caps.PageSizeDefault,
|
||||
ContractVersion: ContractVersion,
|
||||
LanguagePairs: make([]wireLanguagePair, 0, len(h.caps.Pairs)),
|
||||
IntakeEnabled: h.caps.IntakeEnabled,
|
||||
IntakeMaxBytes: h.caps.IntakeMaxBytes,
|
||||
ExportFormats: h.caps.ExportFormats,
|
||||
BankCorrectionsEnabled: h.caps.BankCorrectionsEnabled,
|
||||
PageSizeDefault: h.caps.PageSizeDefault,
|
||||
}
|
||||
if out.ExportFormats == nil {
|
||||
out.ExportFormats = []string{} // an empty collection is an empty array, never null
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ const (
|
|||
CodeContentRefused Code = "content_refused"
|
||||
CodeServiceUnavailable Code = "service_unavailable"
|
||||
CodeInternalError Code = "internal_error"
|
||||
// The two bank codes of 0.5.0. They exist because their remedies have different ADDRESSEES: a
|
||||
// refused set is re-decided by the person, an incomplete write is re-sent verbatim by the
|
||||
// machine (canon §applyBankCorrections).
|
||||
CodeBankCorrectionsRefused Code = "bank_corrections_refused"
|
||||
CodeBankCorrectionsIncomplete Code = "bank_corrections_incomplete"
|
||||
)
|
||||
|
||||
// Second-level causes this deployment gives. NOT a closed vocabulary — a client that does not know
|
||||
|
|
@ -113,6 +118,9 @@ var codes = map[Code]struct {
|
|||
CodeIdempotencyConflict: {http.StatusConflict, "Idempotency conflict"},
|
||||
CodeServiceUnavailable: {http.StatusServiceUnavailable, "Service unavailable"},
|
||||
CodeInternalError: {http.StatusInternalServerError, "Internal error"},
|
||||
|
||||
CodeBankCorrectionsRefused: {http.StatusConflict, "Bank corrections refused"},
|
||||
CodeBankCorrectionsIncomplete: {http.StatusServiceUnavailable, "Bank corrections incomplete"},
|
||||
}
|
||||
|
||||
func statusOf(c Code) int {
|
||||
|
|
@ -152,6 +160,9 @@ type Problem struct {
|
|||
Errors []Item `json:"errors,omitempty"`
|
||||
// Blocked is carried by `ceiling_unavailable` when another book of the account holds the credit.
|
||||
Blocked *Blocked `json:"blocked,omitempty"`
|
||||
// Refusals is carried by `bank_corrections_refused`: one entry per refused correction, or one
|
||||
// about the would-be result as a whole (canon §CorrectionRefusal).
|
||||
Refusals []CorrectionRefusal `json:"refusals,omitempty"`
|
||||
// RetryAfter is not a member: it is the header of the same name, and it is here so one writer
|
||||
// sets it (canon §Conflict).
|
||||
RetryAfter time.Duration `json:"-"`
|
||||
|
|
@ -176,6 +187,14 @@ type Blocked struct {
|
|||
BookID string `json:"book_id"`
|
||||
}
|
||||
|
||||
// CorrectionRefusal is one refused correction: a JSON Pointer at it (`/corrections/3`), or the
|
||||
// EMPTY string for a refusal about the result as a whole. `detail` is developer-facing free text,
|
||||
// like Problem's own — a client marks the row and draws a neutral phrase (canon §CorrectionRefusal).
|
||||
type CorrectionRefusal struct {
|
||||
Pointer string `json:"pointer"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// Fail writes an error identified by its root code.
|
||||
func Fail(w http.ResponseWriter, r *http.Request, c Code) {
|
||||
WriteProblem(w, r, Problem{Code: c})
|
||||
|
|
|
|||
|
|
@ -108,10 +108,12 @@ func TestTheTwoRefusalsBeforeAuthorizationShareARootCodeAndDifferInTheirCause(t
|
|||
// divergence a reviewer cannot see, because both halves look right on their own.
|
||||
//
|
||||
// Driven from the production table, so a code added there is covered the moment it exists; the count
|
||||
// is what pins the table itself to the canon's closed vocabulary of SIXTEEN root codes at 0.3.0.
|
||||
// is what pins the table itself to the canon's closed vocabulary — SIXTEEN root codes at 0.3.0 plus
|
||||
// the TWO bank codes 0.5.0 added (§2.19: refused is re-decided by the person, incomplete is re-sent
|
||||
// verbatim by the machine).
|
||||
func TestEveryCodeNamesExactlyOneStatus(t *testing.T) {
|
||||
if len(codes) != 16 {
|
||||
t.Fatalf("the root vocabulary has %d codes; 0.3.0 closes it at 16", len(codes))
|
||||
if len(codes) != 18 {
|
||||
t.Fatalf("the root vocabulary has %d codes; 0.5.0 closes it at 18", len(codes))
|
||||
}
|
||||
// ⚠ Transcribed from the CANON's own list (§ErrorCode), not read back from the table above.
|
||||
// Comparing `statusOf(c)` with `codes[c].status` compares the table with itself: it is the same
|
||||
|
|
@ -133,6 +135,9 @@ func TestEveryCodeNamesExactlyOneStatus(t *testing.T) {
|
|||
CodeIdempotencyConflict: 409,
|
||||
CodeServiceUnavailable: 503,
|
||||
CodeInternalError: 500,
|
||||
|
||||
CodeBankCorrectionsRefused: 409,
|
||||
CodeBankCorrectionsIncomplete: 503,
|
||||
}
|
||||
unknown := Code("a code that does not exist")
|
||||
for code := range codes {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ func projectRun(r pgstore.Run) wireRun {
|
|||
StopRequested: r.StopRequested,
|
||||
CeilingChapters: r.CeilingChapters,
|
||||
Progress: wireProgress{
|
||||
Done: r.Progress.Done, Total: r.Progress.Total, ETASeconds: r.Progress.ETASeconds,
|
||||
Done: r.Progress.Done, Total: r.Progress.Total, Stage: r.Progress.Stage,
|
||||
ETASeconds: r.Progress.ETASeconds,
|
||||
},
|
||||
StartedAt: r.StartedAt, FinishedAt: r.FinishedAt,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,14 +84,17 @@ type wireTerm struct {
|
|||
// with no cursor, a delta read included. They are `omitempty` for that reason and for no other:
|
||||
// absence here means "does not apply to this page", one of the two places on this surface where it
|
||||
// does (canon §BankPage).
|
||||
//
|
||||
// ⚠ `pending_decisions` and `complete` are gone WITH the canon (0.5.0, PD-399): they counted for
|
||||
// the abolished per-term model and had degraded into a live count of `proposed` rows. "How many are
|
||||
// still undecided" is the engine's answer and rides the correction receipt (`signature`), not a
|
||||
// read of this table.
|
||||
type wireBankPage struct {
|
||||
Revision int64 `json:"revision"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
StructureVersion int `json:"structure_version"`
|
||||
Total *int `json:"total,omitempty"`
|
||||
Signed *int `json:"signed,omitempty"`
|
||||
PendingDecisions *int `json:"pending_decisions,omitempty"`
|
||||
Complete *bool `json:"complete,omitempty"`
|
||||
Terms []wireTerm `json:"terms"`
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +218,6 @@ func (h *v0) listBank(w http.ResponseWriter, r *http.Request) {
|
|||
if page.First {
|
||||
c := page.Counts
|
||||
out.Total, out.Signed = &c.Total, &c.Signed
|
||||
out.PendingDecisions, out.Complete = &c.PendingDecisions, &c.Complete
|
||||
}
|
||||
for _, t := range page.Terms {
|
||||
out.Terms = append(out.Terms, projectTerm(t))
|
||||
|
|
@ -223,17 +225,8 @@ func (h *v0) listBank(w http.ResponseWriter, r *http.Request) {
|
|||
h.writeJSON(w, r, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ⚠ THE WRITE HALF OF THE BANK IS GONE, AND WHAT REPLACES IT IS NOT BUILT — for a future session.
|
||||
//
|
||||
// What stood here: `POST /books/{bookId}/bank/decisions`, taking a per-term verb `approve|decline`.
|
||||
// D39.144 (16.08) abolished that model — the bank is signed as ONE act over the whole of it, and a
|
||||
// per-term verb was never the unit of signing. What the same note DID keep per-term is an EDIT:
|
||||
// correct a term's rendering, or add one, before the signing resume. That handle does not exist on
|
||||
// any surface, here or in the canon, and this removal does not create it.
|
||||
//
|
||||
// For whoever builds it: `bank_decisions` already holds (term_id, dst), but not for free — `action`
|
||||
// is NOT NULL with a check constraint on the abolished verb (migrations 00002/00016, both released),
|
||||
// so an edit needs either a migration or a filler value. The engine's side is a file the platform
|
||||
// does not write yet: `mined_delta` (a seed YAML, backend/internal/seed) carries corrected and added
|
||||
// terms, and the engine reads it only if `book.yaml` declares the path — which is the ownership
|
||||
// fork of unified backlog row 199(a), unratified. Signing itself needs nothing: it is `resume`.
|
||||
// The write half of the bank lives at `POST /books/{bookId}/bank/corrections` (bank.go): the
|
||||
// per-term act is a CORRECTION applied by the engine's own verb, never a signature — signing stayed
|
||||
// ONE act over the whole bank, `resume` (D39.144, D39.156). What stood here before it — the
|
||||
// abolished per-term decisions route — went on the owner's word of 22.08; the `bank_decisions`
|
||||
// table it fed is dead and nothing here reads it.
|
||||
|
|
|
|||
|
|
@ -98,11 +98,15 @@ func TestAPairCarriesItsNotesAsCodesAndNotAsEngineReasons(t *testing.T) {
|
|||
// The aggregates describe the WHOLE bank and ride on the FIRST page only — any response to a
|
||||
// request with no cursor. On a later page they are ABSENT, which is the one place on this surface
|
||||
// where absence means "does not apply".
|
||||
//
|
||||
// The aggregate pair is `total`/`signed` and NOTHING ELSE: canon 0.5.0 removed the per-term-model
|
||||
// counters `pending_decisions`/`complete` (PD-399), which had degraded into a live count of
|
||||
// `proposed` rows — so their ABSENCE is pinned on the first page too, where they used to ride.
|
||||
func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) {
|
||||
full := pgstore.BankPage{
|
||||
Page: pgstore.Page{Revision: 1841, StructureVersion: 3},
|
||||
First: true,
|
||||
Counts: pgstore.BankCounts{Total: 300, Signed: 40, PendingDecisions: 17},
|
||||
Counts: pgstore.BankCounts{Total: 300, Signed: 40},
|
||||
Terms: []pgstore.BankTerm{{
|
||||
ID: "tm_1", Src: "方源", Dst: "Фан Юань", Kind: "name",
|
||||
Status: "proposed", Origin: "found", Sense: "",
|
||||
|
|
@ -110,11 +114,16 @@ func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) {
|
|||
}
|
||||
lib := &fakeLibrary{bank: full}
|
||||
got := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank", ""))
|
||||
for _, k := range []string{"total", "signed", "pending_decisions", "complete", "structure_version"} {
|
||||
for _, k := range []string{"total", "signed", "structure_version"} {
|
||||
if _, ok := got[k]; !ok {
|
||||
t.Errorf("the first page is missing %q", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"pending_decisions", "complete"} {
|
||||
if _, ok := got[k]; ok {
|
||||
t.Errorf("the removed counter %q is still on the wire (canon 0.5.0, PD-399)", k)
|
||||
}
|
||||
}
|
||||
term, _ := got["terms"].([]any)[0].(map[string]any)
|
||||
for _, k := range []string{"id", "src", "dst", "kind", "status", "origin", "sense",
|
||||
"since_chapter", "until_chapter"} {
|
||||
|
|
@ -127,7 +136,7 @@ func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) {
|
|||
}
|
||||
lib.bank.First = false
|
||||
later := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank?cursor=x", ""))
|
||||
for _, k := range []string{"total", "signed", "pending_decisions", "complete"} {
|
||||
for _, k := range []string{"total", "signed"} {
|
||||
if _, ok := later[k]; ok {
|
||||
t.Errorf("a later page carries the whole-bank aggregate %q", k)
|
||||
}
|
||||
|
|
@ -141,11 +150,13 @@ func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) {
|
|||
// the one source of mounted routes and every route in it is walked below.
|
||||
|
||||
// `GET /capabilities` is the only place a non-streaming client learns which contract it is talking
|
||||
// to, and the six fields are what a client is entitled to decide from before it spends anything.
|
||||
func TestCapabilitiesCarriesTheSixFacts(t *testing.T) {
|
||||
// to, and the seven fields are what a client is entitled to decide from before it spends anything
|
||||
// (0.5.0 added `bank_corrections_enabled`: the correction door is declared ahead of its serving
|
||||
// half, and a client must learn a deployment has not mounted it here, not by failing a save).
|
||||
func TestCapabilitiesCarriesTheSevenFacts(t *testing.T) {
|
||||
got := decode(t, call(t, readingServer(t, &fakeLibrary{}), "GET", "/v0/capabilities", ""))
|
||||
for _, k := range []string{"contract_version", "language_pairs", "intake_enabled",
|
||||
"intake_max_bytes", "export_formats", "page_size_default"} {
|
||||
"intake_max_bytes", "export_formats", "bank_corrections_enabled", "page_size_default"} {
|
||||
if _, ok := got[k]; !ok {
|
||||
t.Errorf("Capabilities is missing the required field %q", k)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ type Deps struct {
|
|||
// cut it with, and then POST /books is a guarded 404 like every other unbuilt route: an instance
|
||||
// that accepted uploads it could only reject would be worse than one that says it takes none.
|
||||
Intake Intake
|
||||
// Bank is the correction door. Nil where this deployment has no engine to apply corrections
|
||||
// with, and then the path answers the guarded 404 the canon promises for
|
||||
// `bank_corrections_enabled: false` — the capability and the mount are one fact.
|
||||
Bank Bank
|
||||
// Upload bounds the one route that carries a file.
|
||||
Upload UploadLimits
|
||||
// Capabilities is what `GET /capabilities` answers: what this deployment can do.
|
||||
|
|
|
|||
|
|
@ -93,10 +93,19 @@ func (h *v0) streamEvents(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
s := &stream{w: w, rc: rc}
|
||||
// The first frame is ALWAYS hello, and it carries the id of the last history frame — `0` on a
|
||||
// book that has produced none. A hello without an id would leave the client with nothing to
|
||||
// present on the reconnect a browser makes by itself, and the stream would reopen forever.
|
||||
if !s.send("hello", state.Position, map[string]any{
|
||||
// The first frame is ALWAYS hello, and its id is what the browser will present on the reconnect
|
||||
// it makes by itself — the WHATWG last event ID buffer takes it the moment the frame is
|
||||
// dispatched. So on a RESUME the id must be the one the client just presented, not the head of
|
||||
// the book's history: a drop right after a head-stamped hello moved the client's watermark
|
||||
// FORWARD across every frame this connection was about to replay — notes included, silently,
|
||||
// with no resync_required, and the delta remedy disarmed with it (workflow finding, P9;
|
||||
// PD-406). A fresh connection has no replay to protect and presents the head, exactly as
|
||||
// `from` is chosen below.
|
||||
helloID := state.Position
|
||||
if resuming {
|
||||
helloID = last
|
||||
}
|
||||
if !s.send("hello", helloID, map[string]any{
|
||||
"contract": ContractVersion, "revision": state.Revision,
|
||||
"structure_version": state.StructureVersion,
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -346,3 +346,31 @@ func TestEveryConnectionFrameCarriesTheBooksRevisionAndStructureVersion(t *testi
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// On a RESUME the hello's id is the id the client PRESENTED, never the head of the book's history:
|
||||
// the browser stores hello's id the moment the frame is dispatched, so a head-stamped hello
|
||||
// followed by a drop moved the client's watermark forward across the whole pending replay — notes
|
||||
// lost silently, no resync_required, and the delta remedy disarmed with them (workflow finding,
|
||||
// P9; PD-406). A fresh connection has no replay to protect and presents the head.
|
||||
//
|
||||
// Mutation caught: sending `hello` with state.Position on a resuming connection.
|
||||
func TestAResumingHelloCarriesTheClientsOwnWatermark(t *testing.T) {
|
||||
lib := &fakeLibrary{
|
||||
stream: pgstore.StreamState{Position: 5, Revision: 9, StructureVersion: 1, AtRest: true},
|
||||
frames: []pgstore.Frame{
|
||||
{Position: 2, Event: pgstore.FrameNote, Data: json.RawMessage(`{"note":{"id":"nt_1"}}`)},
|
||||
{Position: 5, Event: pgstore.FrameProgress, Data: json.RawMessage(`{"progress":{"done":3}}`)},
|
||||
},
|
||||
}
|
||||
got := frames(t, streamOf(t, lib, "1").Body.String())
|
||||
if len(got) == 0 || got[0].Event != "hello" {
|
||||
t.Fatalf("no hello: %+v", got)
|
||||
}
|
||||
if got[0].ID != "1" {
|
||||
t.Fatalf("a resuming hello carries id %q, want the client's own 1", got[0].ID)
|
||||
}
|
||||
fresh := frames(t, streamOf(t, lib, "").Body.String())
|
||||
if len(fresh) == 0 || fresh[0].ID != "5" {
|
||||
t.Fatalf("a fresh hello must present the head: %+v", fresh)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ var contractSurface = []struct {
|
|||
{method: "GET", path: "/books/{bookId}/chapters/{chapterId}/units", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listUnits }},
|
||||
{method: "GET", path: "/books/{bookId}/notes", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listNotes }},
|
||||
{method: "GET", path: "/books/{bookId}/bank", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBank }},
|
||||
// The correction door (canon 0.5.0). Unmounted it answers the guarded 404 the canon promises for
|
||||
// `Capabilities.bank_corrections_enabled: false` — the same fact decides both. Its body cap IS
|
||||
// the canon's 1 MiB document ceiling, so it rides the default deliberately.
|
||||
{method: "POST", path: "/books/{bookId}/bank/corrections", mounts: hasBank, handler: func(h *v0) http.HandlerFunc { return h.bankCorrections }},
|
||||
// The stream is registered outside the compression layer — compressing a stream buffers it, which
|
||||
// is the one thing the contract forbids of this route — and that holds structurally: it does not
|
||||
// go through writeJSON, which is where compression lives.
|
||||
|
|
@ -96,6 +100,7 @@ var contractSurface = []struct {
|
|||
func hasLibrary(d Deps) bool { return d.Library != nil }
|
||||
func hasRuns(d Deps) bool { return d.Runs != nil }
|
||||
func hasIntake(d Deps) bool { return d.Intake != nil }
|
||||
func hasBank(d Deps) bool { return d.Bank != nil }
|
||||
|
||||
// contractRoutes registers the /v0 surface.
|
||||
//
|
||||
|
|
@ -103,7 +108,7 @@ func hasIntake(d Deps) bool { return d.Intake != nil }
|
|||
// ops endpoints: a nested mux behind StripPrefix hands the inner handler a copy of the request and
|
||||
// the pattern never comes back, which would put raw paths carrying book ids into the access log.
|
||||
func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) http.Handler) {
|
||||
h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, upload: d.Upload,
|
||||
h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, bank: d.Bank, upload: d.Upload,
|
||||
caps: d.Capabilities, keys: d.Keys, log: d.Log}
|
||||
for _, r := range contractSurface {
|
||||
if r.mounts != nil && !r.mounts(d) {
|
||||
|
|
@ -121,6 +126,7 @@ type v0 struct {
|
|||
lib Library
|
||||
runs Runs
|
||||
intake Intake
|
||||
bank Bank
|
||||
upload UploadLimits
|
||||
caps Capabilities
|
||||
keys IdempotencyKeys
|
||||
|
|
@ -135,15 +141,19 @@ type v0 struct {
|
|||
// a word from inside the translation machinery — which is why the projections below translate
|
||||
// vocabularies instead of forwarding them.
|
||||
//
|
||||
// ⚠ `Run.stop_requested` is the ONE member ahead of the version this deployment announces: 0.3.0's
|
||||
// Run does not declare it and 0.4.0 requires it. It is sent because the fact has no other carrier —
|
||||
// no `status` answers "is this what I asked for" — and an unknown member is what a 0.x minor is for;
|
||||
// the dependency is named here rather than left for a reader to find by diffing.
|
||||
// As of 0.6.0 no member here is ahead of the announced canon: `Run.stop_requested` landed in
|
||||
// 0.4.0's Run (0.5.0 keeps it required), and the through-run bar with its `stage` member is what
|
||||
// 0.6.0's Progress declares.
|
||||
|
||||
// `Progress` is the canon's through-run bar (0.6.0, D39.160): one monotonic fraction across both
|
||||
// waves, and the `stage` caption derived by the PLATFORM from the same counters as the bar —
|
||||
// never a forwarded engine string. `stage` is an OPEN vocabulary by the canon's own second
|
||||
// exception: a client renders an unknown value neutrally, and new values do not move the version.
|
||||
type wireProgress struct {
|
||||
Done int `json:"done"`
|
||||
Total int `json:"total"`
|
||||
ETASeconds *int `json:"eta_seconds"`
|
||||
Done int `json:"done"`
|
||||
Total int `json:"total"`
|
||||
Stage string `json:"stage"`
|
||||
ETASeconds *int `json:"eta_seconds"`
|
||||
}
|
||||
|
||||
type wireBook struct {
|
||||
|
|
|
|||
|
|
@ -212,7 +212,9 @@ func TestTheLibraryResponseCarriesEveryRequiredField(t *testing.T) {
|
|||
if book["chapters_done"] != float64(7) || book["structure_version"] != float64(3) {
|
||||
t.Errorf("book row: %v", book)
|
||||
}
|
||||
// Not a stage name, not a wave name, not an engine word anywhere on the wire.
|
||||
// Not a wave name, not an engine word anywhere on THIS wire — the library row, which carries no
|
||||
// bar by construction. (`Progress.stage` on the RUN legally says drafting/editing since 0.6.0;
|
||||
// this list guards the surface that must stay free of all of it.)
|
||||
for _, leak := range []string{"draft", "edit", "wave", "stage", "mined", "ruby", "genre"} {
|
||||
if bytes.Contains(w.Body.Bytes(), []byte(leak)) {
|
||||
t.Errorf("the pipeline's vocabulary reached the wire: %q in %s", leak, w.Body)
|
||||
|
|
|
|||
136
platform/internal/ingest/bankdecisions.go
Normal file
136
platform/internal/ingest/bankdecisions.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package ingest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// bankdecisions.go: the two documents of the engine's bank-correction door, `tmctl bank-apply`
|
||||
// (D39.158) — the request the platform RENDERS and the report it READS BACK. Both live here because
|
||||
// this package is where the seam's vocabulary lives; the HTTP shapes of the same facts are the
|
||||
// contract's and stay in httpapi.
|
||||
|
||||
// DecisionsVersion and DecisionsReportVersion are the SHAPES of the two documents, as the engine
|
||||
// declares them (backend/internal/membank/decisions.go). Two constants because they are two
|
||||
// documents, free to move at different times.
|
||||
const (
|
||||
DecisionsVersion = "tm-bank-decisions-v1"
|
||||
DecisionsReportVersion = "tm-bank-decisions-report-v2"
|
||||
)
|
||||
|
||||
// DepthEditWave is the engine's word for how far an accepted correction reaches: the edit wave —
|
||||
// the next run refines the produced text rather than re-forming the draft (membank.DecisionDepth).
|
||||
// A WAVE name, so it must never be forwarded to the wire as it stands.
|
||||
const DepthEditWave = "edit_wave"
|
||||
|
||||
// BankDecision is one correction, in the engine's own request vocabulary (membank.Decision). The
|
||||
// wire's strictness — a tuple must carry all four members, `null` windows and an empty `sense`
|
||||
// meaning "none" — is the HANDLER's validation; by the time a value gets here the two forms mean
|
||||
// the same thing to the engine, which defaults an omitted member (`omitempty` is therefore safe).
|
||||
// Wire `null` windows are projected to 0 here, the seam's own null (canon §BankCorrection).
|
||||
type BankDecision struct {
|
||||
Action string `json:"action"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Src string `json:"src,omitempty"`
|
||||
Sense string `json:"sense,omitempty"`
|
||||
SinceChapter int `json:"since_chapter,omitempty"`
|
||||
UntilChapter int `json:"until_chapter,omitempty"`
|
||||
Dst string `json:"dst,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// MaxDecisionsDocument mirrors the engine's own byte ceiling on the rendered document
|
||||
// (pipeline.maxDecisionsBytes) — the figure the platform measures BEFORE spawning the verb, so the
|
||||
// engine's copy of the cap answers as the canon's 413 instead of its refusal class.
|
||||
const MaxDecisionsDocument = 1 << 20
|
||||
|
||||
// EncodeDecisions renders the request document the verb reads (`--decisions`).
|
||||
//
|
||||
// HTML escaping is OFF: the document is read by the engine, never by a browser, and Go's default
|
||||
// escape turns one `&`/`<`/`>` byte into six — enough for a body inside the wire's 1 MiB cap to
|
||||
// render past the engine's identical cap and come back as «re-decide» instead of «too large»
|
||||
// (workflow finding, P9). Unescaped, the rendered form differs from the wire's by the envelope
|
||||
// alone, and the residual band is those few bytes, gated by the caller against MaxDecisionsDocument.
|
||||
func EncodeDecisions(bookID string, decisions []BankDecision) ([]byte, error) {
|
||||
doc := struct {
|
||||
Version string `json:"decisions_version"`
|
||||
BookID string `json:"book_id"`
|
||||
Decisions []BankDecision `json:"decisions"`
|
||||
}{Version: DecisionsVersion, BookID: bookID, Decisions: decisions}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(doc); err != nil {
|
||||
return nil, fmt.Errorf("ingest: encode decisions: %w", err)
|
||||
}
|
||||
return bytes.TrimSpace(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// BankReport is the ALLOWLISTED subset of the verb's report (pipeline.BankDecisionsReport). Absent
|
||||
// on purpose, like everywhere on this seam: the two file paths and the per-file write truth are
|
||||
// server topology, the canonical-rewrite warnings address an operator of files, and the TEXTS of
|
||||
// pre-existing faults are the engine's free vocabulary — the wire carries their COUNT.
|
||||
type BankReport struct {
|
||||
Version string `json:"report_version"`
|
||||
BookID string `json:"book_id"`
|
||||
// Mode names the outcome: `apply` · `projection` · `refused` · `stopped` · `write_incomplete`.
|
||||
Mode string `json:"mode"`
|
||||
Depth string `json:"depth"`
|
||||
Changed bool `json:"changed"`
|
||||
// PreexistingProblems is decoded for its LENGTH; the texts never cross the next boundary.
|
||||
PreexistingProblems []string `json:"preexisting_problems"`
|
||||
Accepted []AcceptedDecision `json:"accepted"`
|
||||
Rejected []RejectedDecision `json:"rejected"`
|
||||
Signature SignatureState `json:"signature"`
|
||||
}
|
||||
|
||||
// AcceptedDecision is what one accepted correction did.
|
||||
type AcceptedDecision struct {
|
||||
Index int `json:"index"`
|
||||
Action string `json:"action"`
|
||||
ID string `json:"id"`
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
// State is `applied` or `already_applied` — the whole of idempotency as a caller sees it.
|
||||
State string `json:"state"`
|
||||
// Replaced is decoded for its PRESENCE: the wire carries the boolean `displaced`, never the
|
||||
// engine's free-text itemization.
|
||||
Replaced []string `json:"replaced"`
|
||||
}
|
||||
|
||||
// RejectedDecision is one refusal. Index is the decision's position in the request, or -1 for a
|
||||
// refusal about the result as a whole.
|
||||
type RejectedDecision struct {
|
||||
Index int `json:"index"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// SignatureState counts the last signing stop's surfaces against the recorded decisions.
|
||||
// INFORMATIONAL, never a gate (D39.144). `Map` is a server-side path and is read here only for
|
||||
// "does a stop exist at all": empty means no run has reached one, which the wire says as `null`.
|
||||
type SignatureState struct {
|
||||
Map string `json:"map"`
|
||||
Surfaces int `json:"surfaces"`
|
||||
Undecided int `json:"undecided"`
|
||||
Unreadable bool `json:"unreadable"`
|
||||
}
|
||||
|
||||
// DecodeBankReport parses the verb's report and refuses a shape this build does not speak.
|
||||
//
|
||||
// The version is matched EXACTLY, unlike the manifest's presence-only rule, and the asymmetry is
|
||||
// deliberate: the manifest feeds a read model that a stale field set degrades, while this report is
|
||||
// the receipt of a WRITE — a half-read receipt would tell a user their correction did something
|
||||
// other than what it did. An engine that moved the shape is a deployment skew to refuse loudly.
|
||||
func DecodeBankReport(b []byte) (BankReport, error) {
|
||||
var r BankReport
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return BankReport{}, fmt.Errorf("ingest: decode bank report: %w", err)
|
||||
}
|
||||
if r.Version != DecisionsReportVersion {
|
||||
return BankReport{}, fmt.Errorf("ingest: decode bank report: report_version is %q, this build speaks %q",
|
||||
r.Version, DecisionsReportVersion)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
82
platform/internal/ingest/bankdecisions_test.go
Normal file
82
platform/internal/ingest/bankdecisions_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package ingest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The two documents of the correction seam: what the platform renders is the engine's own request
|
||||
// shape, and what it reads back refuses a shape this build does not speak.
|
||||
|
||||
// The rendered document IS the engine's vocabulary: versioned, book-bound, with the two forms of
|
||||
// identity and nothing the engine does not declare (DisallowUnknownFields on its side would refuse
|
||||
// an invented member loudly, and half-applied quietly is what that strictness exists against).
|
||||
func TestEncodeDecisionsRendersTheEngineRequest(t *testing.T) {
|
||||
doc, err := EncodeDecisions("bk_1", []BankDecision{
|
||||
{Action: "approve", Src: "蛊", Sense: "", SinceChapter: 0, UntilChapter: 0, Dst: "гу", Note: "why"},
|
||||
{Action: "decline", ID: "tm_2"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(doc)
|
||||
for _, want := range []string{
|
||||
`"decisions_version":"tm-bank-decisions-v1"`,
|
||||
`"book_id":"bk_1"`,
|
||||
`"action":"approve"`, `"src":"蛊"`, `"dst":"гу"`, `"note":"why"`,
|
||||
`"action":"decline"`, `"id":"tm_2"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("the document does not carry %s: %s", want, s)
|
||||
}
|
||||
}
|
||||
// An omitted member is OMITTED, not zero-valued: the engine defaults absences, and a literal
|
||||
// `"since_chapter":0` beside `"id"` would put tuple members on an id-form decision.
|
||||
if strings.Contains(s, `"since_chapter"`) || strings.Contains(s, `"sense"`) {
|
||||
t.Errorf("zero members were rendered instead of omitted: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The report decode is version-EXACT, unlike the manifest's presence-only rule: this is the receipt
|
||||
// of a write, and a half-read receipt would tell a user their correction did something it did not.
|
||||
func TestDecodeBankReportRefusesAShapeThisBuildDoesNotSpeak(t *testing.T) {
|
||||
good := `{"report_version":"tm-bank-decisions-report-v2","book_id":"bk_1","mode":"apply",
|
||||
"depth":"edit_wave","changed":true,
|
||||
"accepted":[{"index":0,"action":"approve","id":"tm_9","src":"蛊","dst":"гу","state":"applied",
|
||||
"replaced":["previous"]}],
|
||||
"rejected":[],"preexisting_problems":["a delta that would not load"],
|
||||
"signature":{"map":"/x/y.yaml","surfaces":3,"undecided":1,"unreadable":false}}`
|
||||
rep, err := DecodeBankReport([]byte(good))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Mode != "apply" || !rep.Changed || rep.Depth != DepthEditWave ||
|
||||
len(rep.Accepted) != 1 || len(rep.Accepted[0].Replaced) != 1 ||
|
||||
len(rep.PreexistingProblems) != 1 || rep.Signature.Surfaces != 3 {
|
||||
t.Errorf("decoded: %+v", rep)
|
||||
}
|
||||
if _, err := DecodeBankReport([]byte(`{"report_version":"tm-bank-decisions-report-v3","mode":"apply"}`)); err == nil {
|
||||
t.Error("a report shape from another engine build was believed")
|
||||
}
|
||||
if _, err := DecodeBankReport([]byte(`not a report`)); err == nil {
|
||||
t.Error("bytes that are not a report were believed")
|
||||
}
|
||||
}
|
||||
|
||||
// The rendered document is read by the engine, never a browser: Go's default HTML escape turned
|
||||
// one `&`/`<`/`>` byte into six, enough for a body inside the wire's 1 MiB cap to render past the
|
||||
// engine's identical cap (workflow finding, P9). The render must carry those bytes verbatim.
|
||||
func TestTheRenderedDocumentDoesNotInflateEscapableBytes(t *testing.T) {
|
||||
doc, err := EncodeDecisions("bk", []BankDecision{{Action: "decline", ID: "tm_1", Note: "A&B<C>D"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, esc := range []string{`\u0026`, `\u003c`, `\u003e`} {
|
||||
if strings.Contains(string(doc), esc) {
|
||||
t.Fatalf("the render HTML-escapes (%s): %s", esc, doc)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(doc), `"A&B<C>D"`) {
|
||||
t.Fatalf("the escapable bytes did not survive verbatim: %s", doc)
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,15 @@ const (
|
|||
// safe in both directions either way, because an unrecognised code in the band already falls to
|
||||
// the non-destructive answer (books.intakeReason).
|
||||
ExitSchemaMismatch = 13
|
||||
// ExitDecisionsRejected — the correction document was read and declined WHOLE (bank-apply,
|
||||
// D39.158): all-or-nothing, nothing written, and the USER is who re-decides. The class always
|
||||
// prints its report beside the code, the caps included — the per-decision reasons ARE its
|
||||
// product (pipeline.ApplyBankDecisions).
|
||||
ExitDecisionsRejected = 14
|
||||
// ExitWriteIncomplete — the correction document was ACCEPTED and the write did not complete
|
||||
// (bank-apply). The remedy is to re-send the SAME document: the retry converges on the engine's
|
||||
// byte no-op, whether nothing landed or half did.
|
||||
ExitWriteIncomplete = 15
|
||||
// ExitRefusedOther — a refusal class this build of the engine had no number for.
|
||||
ExitRefusedOther = 19
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,22 @@ type Manifest struct {
|
|||
// chapters, so a stored tree is only comparable within one of these (register row PD-166).
|
||||
ChunkerVersion string `json:"chunker_version"`
|
||||
Chapters []ManifestChapter `json:"chapters"`
|
||||
// Artifacts is the engine's own answer to where its file channels live (row 213). Taking the
|
||||
// paths from here is what lets the platform stop re-deriving them from the book's configuration
|
||||
// — knowing the `<project_db>.bank.json` spelling was a copy of the engine's convention that a
|
||||
// changed default would have silently walked away from.
|
||||
Artifacts StatusArtifacts `json:"artifacts"`
|
||||
}
|
||||
|
||||
// StatusArtifacts is the allowlisted half of the engine's artifact envelope, published by both
|
||||
// `manifest --json` and `status --json` (backend/internal/pipeline/status.go, StatusArtifacts).
|
||||
// Only the channel the platform reads is taken: the project database is the engine's own and must
|
||||
// not be opened here (D39.85), and the two decision files have exactly one writer, `tmctl
|
||||
// bank-apply`.
|
||||
type StatusArtifacts struct {
|
||||
// BankExport is the whole-bank read-out — the one channel through which the bank leaves the
|
||||
// engine. The path names a PLACE, not a presence: a book that has never run has no file there.
|
||||
BankExport string `json:"bank_export"`
|
||||
}
|
||||
|
||||
// ManifestChapter is one chapter of the tree.
|
||||
|
|
|
|||
|
|
@ -32,8 +32,10 @@ type Supervisor struct {
|
|||
// Workdir is the book's project directory: book.yaml, the source and the engine's private
|
||||
// SQLite live there. That SQLite is never opened by us (D39.85 §4).
|
||||
Workdir string
|
||||
// Env is the child's environment. Provider keys reach the engine through it and must never be
|
||||
// logged; nil means the parent's environment.
|
||||
// Env is the child's environment. On THIS dev path provider keys may reach the engine through
|
||||
// it (or through the inherited parent environment — nil means exactly that) and must never be
|
||||
// logged. Production hands keys as the `--keys-file` argument instead (row 211): copying this
|
||||
// channel into a prod spawn is the dev/prod parity trap that flag exists to close.
|
||||
Env []string
|
||||
// EngineLog receives the child's stderr verbatim. It is a FILE, not our structured logger: the
|
||||
// engine logs per-call cost estimates at INFO, and money must not enter the platform's INFO
|
||||
|
|
|
|||
|
|
@ -755,20 +755,22 @@ func (s *Store) StuckIntake(ctx context.Context, uploadingBefore, claimedBefore
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Progress is how far the current SEGMENT of a run has got, in CHAPTERS (canon §Progress).
|
||||
// Progress is how far a run's WHOLE work has got, as one monotonic fraction (owner's word of 20.08,
|
||||
// row 200): `done` counts chapter-passes through both waves and `total` is what the run bought,
|
||||
// doubled where the pipeline has an editor. The counting itself is pgstore.runDone/runTotal.
|
||||
//
|
||||
// ONE counter and not two, and in chapters and not units, and both halves are the same decision:
|
||||
// the numerator and the denominator have to be in one unit, the denominator is what the run BOUGHT,
|
||||
// and what a run buys is declared in chapters (`ceiling_chapters`). The per-phase split stays INSIDE
|
||||
// the platform — the wire carried it until 0.3.0, which pinned the engine's architecture to a React
|
||||
// component through six files.
|
||||
//
|
||||
// A segment is the work between two stops. In the first segment of a run that stops for signing, a
|
||||
// chapter counts when THAT segment's work on it is done; otherwise when it is finished end to end.
|
||||
// Counted the second way throughout, the bar would read zero for the whole first pass.
|
||||
// ONE counter and not two per wave, and in chapter-passes and not units, and both halves are the
|
||||
// same decision: the numerator and the denominator have to be in one unit, the denominator derives
|
||||
// from what the run BOUGHT, and what a run buys is declared in chapters (`ceiling_chapters`). The
|
||||
// per-phase split stays INSIDE the platform — the wire carried it until 0.3.0, which pinned the
|
||||
// engine's architecture to a React component through six files. What crosses the wire beside the
|
||||
// numbers is `stage`, the caption's machine value — the client draws its own phrase from it.
|
||||
type Progress struct {
|
||||
Done int
|
||||
Total int
|
||||
// Stage is what the run is doing now: `drafting` or `editing` today, an open vocabulary by
|
||||
// design (pgstore.runStage).
|
||||
Stage string
|
||||
// ETASeconds is absent when there is nothing to estimate from.
|
||||
ETASeconds *int
|
||||
}
|
||||
|
|
@ -803,15 +805,15 @@ type Run struct {
|
|||
// run and `b` its book. Written once because the three that used it wrote the same fourteen columns
|
||||
// three times, in three orders that had to stay in step by hand.
|
||||
const runRow = `r.id, b.id, b.revision, r.status, r.verify_bank, r.stop_requested_at is not null,
|
||||
r.ceiling_chapters, ` + runProgress + `, r.ceiling_chapters, r.eta_seconds,
|
||||
r.ceiling_chapters, ` + runDone + `, ` + runTotal + `, ` + runStage + `, r.eta_seconds,
|
||||
coalesce(r.paused_reason, ''), coalesce(r.failure_reason, ''), r.started_at, r.finished_at`
|
||||
|
||||
// scanRun reads runRow into a Run, plus whatever the caller selected after it.
|
||||
func scanRun(row pgx.Row, out *Run, extra ...any) error {
|
||||
return row.Scan(append([]any{&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank,
|
||||
&out.StopRequested, &out.CeilingChapters, &out.Progress.Done, &out.Progress.Total,
|
||||
&out.Progress.ETASeconds, &out.PausedReason, &out.FailureReason, &out.StartedAt,
|
||||
&out.FinishedAt}, extra...)...)
|
||||
&out.Progress.Stage, &out.Progress.ETASeconds, &out.PausedReason, &out.FailureReason,
|
||||
&out.StartedAt, &out.FinishedAt}, extra...)...)
|
||||
}
|
||||
|
||||
// Library is one page of a user's books plus the revision of the library scope itself.
|
||||
|
|
@ -970,6 +972,13 @@ type BookRunContext struct {
|
|||
// whole length — which is right for a book that has never run.
|
||||
ChaptersLeft int
|
||||
HasLiveRun bool
|
||||
// LiveRunAwaitingBank narrows HasLiveRun for the correction door: a bank stop moves the run's
|
||||
// status to `awaiting_bank` from the journal BEFORE the reconciler reads the exit marker and
|
||||
// closes the row (sink TypeBankStop; reconcile.finish) — so for up to a sweep the row is live
|
||||
// while the book already shows its signing screen. In that window the door must open, not
|
||||
// answer run_in_flight; Start keeps refusing on HasLiveRun alone (the row IS still live, and a
|
||||
// second one would break runs_one_live_per_book).
|
||||
LiveRunAwaitingBank bool
|
||||
}
|
||||
|
||||
// ReadBookForRun gathers the facts a run start is judged on.
|
||||
|
|
@ -979,10 +988,13 @@ func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (Book
|
|||
b.chapter_count - (select count(*) from chapters c
|
||||
where c.book_id = b.id and c.units_total > 0
|
||||
and ` + finishedUnits + ` >= c.units_total),
|
||||
exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null)
|
||||
exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null),
|
||||
exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null
|
||||
and lr.status = 'awaiting_bank')
|
||||
from books b ` + lastRun + ` where b.id = $1 and b.owner_id = $2`
|
||||
var out BookRunContext
|
||||
err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.Status, &out.ChaptersLeft, &out.HasLiveRun)
|
||||
err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.Status, &out.ChaptersLeft,
|
||||
&out.HasLiveRun, &out.LiveRunAwaitingBank)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return BookRunContext{}, ErrNoBook
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,3 +41,4 @@ c21877113b5966bc8a200ba69ce752d4ac295bdfd34e681afbd887523edb6ceb 00019_read_mod
|
|||
735a18878fba70bb156df81a1c097239463d0eb976d4fede4cb4a106d99e2b2b 00023_idempotency_content_digest.sql
|
||||
0213ea7636b536d1aad46b617ee399cccbf8efd87b3eb282340cdc9430a96b0e 00024_book_edit_wave.sql
|
||||
1ad95e77c78d556a83e35c2fceac09475240c7a919fd3554e34cb5bf62ef52d1 00025_stalled_work.sql
|
||||
14de53c75746c2b9ab872dcf9c9e0c4bd1ea2eb9e9778fe6a6e2cbe390d8961d 00026_run_draft_baseline.sql
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
-- +goose Up
|
||||
|
||||
-- The second baseline of a run's bar. The bar now measures the run's WHOLE work through both waves
|
||||
-- (owner's word of 20.08, backlog row 200): the draft pass and the edit pass each contribute their
|
||||
-- finished chapters against what the run bought, so each needs its own "what the book already had"
|
||||
-- figure — `chapters_before` is re-purposed as the EDIT-column baseline, this column is the DRAFT
|
||||
-- one. Both are captured in the statement that creates the run (pgstore.StartRun), on FIXED
|
||||
-- predicates: `edit_wave` legitimately flips false→true after a run starts (the engine announces
|
||||
-- its wave shape with its first progress event), and a baseline captured through the flag sat on
|
||||
-- the draft column while the numerator moved to the edit one — the bar then read 0/N forever. Each
|
||||
-- baseline pairs only with the numerator of its own column (pgstore.runDone), chosen by the live
|
||||
-- flag at read time.
|
||||
alter table runs add column draft_before integer not null default 0;
|
||||
|
||||
-- Every pre-split run — finished or still in flight — gets the same approximation: the one figure
|
||||
-- its start DID record. A baseline is only honest when captured BEFORE the run produced any of its
|
||||
-- own output; re-deriving one mid-run from the counters as they stand NOW would bake the run's own
|
||||
-- finished passes into its zero point, and such a run could then finish all of its bought work with
|
||||
-- a bar that never reaches one (workflow finding, P9: the shortfall is exactly twice the edits it
|
||||
-- had already made). Copying `chapters_before` cannot do that — it predates the run's work by
|
||||
-- construction. The residual imprecision is inherited from the epochs of the old single baseline
|
||||
-- (verify_bank runs recorded it off the DRAFT column): such a run's edit half may read low against
|
||||
-- the re-purposed pairing. Named, not hidden — and gone with the last pre-split run.
|
||||
update runs set draft_before = chapters_before;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
alter table runs drop column draft_before;
|
||||
|
|
@ -317,37 +317,30 @@ func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.Bank
|
|||
// BankCounts are the whole-bank aggregates: the header of a signing screen, and INFORMATIONAL —
|
||||
// never a gate. Signing is one act over the whole bank and `resume` lifts the stop with the
|
||||
// decisions as they stand (D39.144), so nothing here decides whether continuing may be offered.
|
||||
//
|
||||
// ⚠ `pending_decisions` and `complete` are GONE (canon 0.5.0, PD-399), not trimmed for taste: they
|
||||
// were fed by the `bank_decisions` table whose write path went with the per-term model, which left
|
||||
// them a live count of `proposed` rows — a frozen-plausible number teaching the abolished model.
|
||||
// The honest count of undecidedness is the ENGINE's (`SignatureState`, served on the correction
|
||||
// receipt); this read model cannot compute it without re-implementing the engine's law, which the
|
||||
// seam forbids (17-seam-inbound-law п.6).
|
||||
type BankCounts struct {
|
||||
Total int
|
||||
Signed int
|
||||
PendingDecisions int
|
||||
Complete bool
|
||||
Total int
|
||||
Signed int
|
||||
}
|
||||
|
||||
func (c BankCounts) payload() map[string]any {
|
||||
return map[string]any{
|
||||
"total": c.Total, "signed": c.Signed,
|
||||
"pending_decisions": c.PendingDecisions, "complete": c.Complete,
|
||||
}
|
||||
return map[string]any{"total": c.Total, "signed": c.Signed}
|
||||
}
|
||||
|
||||
func bankCountsTx(ctx context.Context, tx pgx.Tx, bookID string) (BankCounts, error) {
|
||||
// ⚠ `pending_decisions` reads "proposed rows nobody has touched", and since the write path was
|
||||
// removed (D39.144) nothing ever touches one — so it is the count of `proposed` rows, and
|
||||
// `complete` says the engine's read-out carries none. The subquery is kept rather than folded
|
||||
// away: it is the shape the EDIT handle will need, and the wire fields are the canon's.
|
||||
const q = `
|
||||
select count(*),
|
||||
count(*) filter (where t.status = 'approved'),
|
||||
count(*) filter (where t.status = 'proposed'
|
||||
and not exists (select 1 from bank_decisions d
|
||||
where d.book_id = t.book_id and d.term_id = t.id))
|
||||
select count(*), count(*) filter (where t.status = 'approved')
|
||||
from bank_terms t where t.book_id = $1`
|
||||
var c BankCounts
|
||||
if err := tx.QueryRow(ctx, q, bookID).Scan(&c.Total, &c.Signed, &c.PendingDecisions); err != nil {
|
||||
if err := tx.QueryRow(ctx, q, bookID).Scan(&c.Total, &c.Signed); err != nil {
|
||||
return BankCounts{}, fmt.Errorf("pgstore: count the bank: %w", err)
|
||||
}
|
||||
c.Complete = c.PendingDecisions == 0
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
|
@ -434,23 +427,63 @@ func readBookTx(ctx context.Context, tx pgx.Tx, bookID string) (Book, error) {
|
|||
`select `+bookColumns+` from books b `+lastRun+` where b.id = $1`, bookID))
|
||||
}
|
||||
|
||||
// runProgress is the run's bar: chapters finished in the CURRENT segment, against what the run
|
||||
// bought. The segment is chosen by the same rule the chapter counters are read by (scope.wave).
|
||||
// draftChapters / editChapters are how many of the book's chapters each pass has fully resolved —
|
||||
// each on its OWN column, never through the flag. The bar's baselines are captured on these same
|
||||
// fixed predicates (StartRun), so a capture can never disagree with the numerator it will later be
|
||||
// subtracted from, however `edit_wave` moves in between.
|
||||
const draftChapters = `(select count(*) from chapters c
|
||||
where c.book_id = b.id and c.units_total > 0 and c.units_draft_done >= c.units_total)`
|
||||
|
||||
const editChapters = `(select count(*) from chapters c
|
||||
where c.book_id = b.id and c.units_total > 0 and c.units_edit_done >= c.units_total)`
|
||||
|
||||
// The run's bar measures the run's WHOLE work through both waves as ONE monotonic fraction (owner's
|
||||
// word of 20.08, row 200): the draft pass and the last pass each contribute the chapters they
|
||||
// finished, against what the run bought — so the bar no longer restarts from zero when the signing
|
||||
// stop is lifted and the counting switches waves, which is exactly what it used to do.
|
||||
//
|
||||
// ⚠ Measured from the run's own BASELINE and clamped into its bought range. Resolutions persist
|
||||
// across runs — a resumed run re-walks finished chapters at $0 and must not move them — so counting
|
||||
// the book's finished chapters would open a continuation run's bar at everything the previous run
|
||||
// did, against a denominator of what THIS one bought: a fraction starting above zero and able to
|
||||
// exceed one. `least(…, ceiling_chapters)` closes the other end, where a run that bought 5 chapters
|
||||
// watches a neighbouring run finish more of the same book.
|
||||
const runProgress = `least(greatest((select count(*) from chapters c
|
||||
where c.book_id = b.id and c.units_total > 0
|
||||
and ` + segmentUnits + ` >= c.units_total) - r.chapters_before, 0), r.ceiling_chapters)`
|
||||
// ⚠ Each half is measured from the run's own BASELINE and clamped into its bought range, and that
|
||||
// part is unchanged deliberately. Resolutions persist across runs — a resumed run re-walks finished
|
||||
// chapters at $0 and must not move them — so counting the book's totals would open a continuation
|
||||
// run's bar at everything the previous runs did, against a denominator of what THIS one bought: a
|
||||
// fraction starting above zero and able to exceed one. `least(…, ceiling_chapters)` closes the
|
||||
// other end, where a run that bought 5 chapters watches a neighbouring run finish more of the same
|
||||
// book. Two baselines because there are two halves: `chapters_before` for the last pass,
|
||||
// `draft_before` for the draft one, both captured when the run row is created (StartRun) and never
|
||||
// re-based after — monotonicity is exactly "the counters only grow and nothing under them moves".
|
||||
const (
|
||||
// draftWork is how many DRAFT passes this run actually has to do: the bought range runs from the
|
||||
// edit baseline to `chapters_before + ceiling`, and whatever of it was already drafted when the
|
||||
// run started (`draft_before`) is not this run's work. Without this term the denominator
|
||||
// tariffed a draft wave the run never performs, and a continuation run over a drafted backlog
|
||||
// finished a clean `ready` at 50% with the caption stuck on drafting (refuter finding, P9).
|
||||
draftWork = `least(greatest(r.chapters_before + r.ceiling_chapters - r.draft_before, 0), r.ceiling_chapters)`
|
||||
draftBar = `least(greatest(` + draftChapters + ` - r.draft_before, 0), ` + draftWork + `)`
|
||||
// ⚠ EACH numerator is paired with the baseline captured on ITS OWN column, and the pair is chosen
|
||||
// by the LIVE flag at read time — never by the flag as it stood at capture. The alternative was
|
||||
// the reviewer's blocker (P9): `edit_wave` legitimately flips false→true AFTER a run starts (the
|
||||
// engine announces its wave shape with its first progress event — sink.recordWaveShape, STACK §35),
|
||||
// and a baseline captured through the flag sat on the draft column while the numerator moved to
|
||||
// the edit one, so the bar read 0/N after all the bought work was done and never caught up.
|
||||
editBar = `least(greatest(` + editChapters + ` - r.chapters_before, 0), r.ceiling_chapters)`
|
||||
draftOnlyBar = `least(greatest(` + draftChapters + ` - r.draft_before, 0), r.ceiling_chapters)`
|
||||
// A pipeline with no editor has ONE wave, and its draft column IS the last pass: counting both
|
||||
// halves there would count every chapter twice against a doubled total that one wave can never
|
||||
// reach.
|
||||
runDone = `(case when ` + editWave + ` then ` + draftBar + ` + ` + editBar + ` else ` + draftOnlyBar + ` end)`
|
||||
runTotal = `(case when ` + editWave + ` then ` + draftWork + ` + r.ceiling_chapters else r.ceiling_chapters end)`
|
||||
// runStage is the caption's machine value — what the run is doing NOW, for the client to phrase
|
||||
// (open vocabulary, like the correction receipt's `depth`). Derived from the same counters as the
|
||||
// bar so the two cannot disagree: the run is `editing` once the draft passes IT owed are done —
|
||||
// at once, when it owed none — and always `drafting` where drafting is the only pass there is.
|
||||
runStage = `(case when ` + editWave + ` and ` + draftBar + ` >= ` + draftWork + `
|
||||
then 'editing' else 'drafting' end)`
|
||||
)
|
||||
|
||||
// lastRun is the join every book-scoped read uses: the current or last run of the book.
|
||||
const lastRun = `left join lateral (
|
||||
select id, status, paused_reason, failure_reason, verify_bank, bank_released, ceiling_chapters,
|
||||
chapters_before, eta_seconds, started_at, finished_at, stop_requested_at
|
||||
chapters_before, draft_before, eta_seconds, started_at, finished_at, stop_requested_at
|
||||
from runs where book_id = b.id order by started_at desc, id desc limit 1) r on true`
|
||||
|
||||
func readBookStatusTx(ctx context.Context, tx pgx.Tx, bookID string) (bookStatus, error) {
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Counts.Total != 2 || page.Counts.PendingDecisions != 2 || page.Counts.Complete {
|
||||
if page.Counts.Total != 2 || page.Counts.Signed != 0 {
|
||||
t.Fatalf("counts: %+v", page.Counts)
|
||||
}
|
||||
// A rebuild of the same read-out answers the same bank: the identities are derived from the term
|
||||
|
|
@ -665,17 +665,22 @@ func TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Progress.Total != 1 {
|
||||
t.Errorf("the start receipt carries total %d, want what the run bought", run.Progress.Total)
|
||||
// The denominator is in chapter-passes through BOTH waves: one bought chapter is two passes on a
|
||||
// pipeline whose editor is not known to be absent (row 200 — the through-bar).
|
||||
if run.Progress.Total != 2 {
|
||||
t.Errorf("the start receipt carries total %d, want both passes of what the run bought", run.Progress.Total)
|
||||
}
|
||||
_, card, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card.Progress.Done != 0 || card.Progress.Total != 1 {
|
||||
t.Errorf("a second run over a half-finished book opens at %d/%d, want 0/1",
|
||||
if card.Progress.Done != 0 || card.Progress.Total != 2 {
|
||||
t.Errorf("a second run over a half-finished book opens at %d/%d, want 0/2",
|
||||
card.Progress.Done, card.Progress.Total)
|
||||
}
|
||||
if card.Progress.Stage != "drafting" {
|
||||
t.Errorf("a fresh run's stage is %q, want drafting", card.Progress.Stage)
|
||||
}
|
||||
// And the BOOK's own figure is the lifetime one, which is a different question.
|
||||
b, _, err := s.GetBook(ctx, "u1", book)
|
||||
if err != nil {
|
||||
|
|
@ -715,8 +720,10 @@ func TestTheRunsBarNeverExceedsWhatItBought(t *testing.T) {
|
|||
if b.ChaptersDone != 2 {
|
||||
t.Fatalf("the book finished %d chapters: this fixture cannot bind a clamp at 1", b.ChaptersDone)
|
||||
}
|
||||
if card.Progress.Done != 1 || card.Progress.Total != 1 {
|
||||
t.Errorf("the bar reads %d/%d, want it clamped to what the run bought",
|
||||
// Two chapters finished the last pass against ONE bought: the last-pass half of the bar clamps
|
||||
// at 1, over the two passes the purchase covers (the draft half is honestly still at zero).
|
||||
if card.Progress.Done != 1 || card.Progress.Total != 2 {
|
||||
t.Errorf("the bar reads %d/%d, want the last-pass half clamped to what the run bought",
|
||||
card.Progress.Done, card.Progress.Total)
|
||||
}
|
||||
}
|
||||
|
|
@ -829,13 +836,146 @@ func TestASecondRunOnADraftOnlyDeploymentStillOpensAtZero(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// The same rule one segment later: lifting the signing stop RE-TAKES the baseline, and it must
|
||||
// re-take it on the pass the numerator will count from then on. On a deployment with no editor that
|
||||
// is the draft column, and a baseline re-taken on the edit one opens the second segment at its
|
||||
// ceiling.
|
||||
// THE rule of the through-bar (owner's word of 20.08, row 200): the bar is ONE monotonic fraction
|
||||
// over the run's whole work, so lifting the signing stop MOVES NOTHING — the re-basing that used to
|
||||
// restart it from zero is gone, and «100%, then zero» with it. Through the stop the draft half
|
||||
// stands, the edit half continues, and the caption follows the counters.
|
||||
//
|
||||
// Mutation caught: naming units_edit_done outright in RestartRun's re-capture.
|
||||
func TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline(t *testing.T) {
|
||||
// Mutation caught: re-taking either baseline in RestartRun; switching the numerator's wave on
|
||||
// `bank_released` (the old segment counting).
|
||||
func TestTheBarIsOneMonotonicFractionThroughTheSigningStop(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true,
|
||||
CeilingChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The first segment drafts the whole purchase and stops for the signature: half the work.
|
||||
exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book)
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book)
|
||||
first, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Progress.Done != 2 || first.Progress.Total != 4 {
|
||||
t.Fatalf("a fully drafted purchase reads %d/%d, want 2/4 — half the run's work",
|
||||
first.Progress.Done, first.Progress.Total)
|
||||
}
|
||||
if first.Progress.Stage != "editing" {
|
||||
t.Errorf("with the draft half done the stage is %q, want editing", first.Progress.Stage)
|
||||
}
|
||||
if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true,
|
||||
Now: at.Add(time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lifted, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lifted.Progress.Done != 2 || lifted.Progress.Total != 4 {
|
||||
t.Errorf("lifting the stop moved the bar to %d/%d, want the 2/4 it stood at",
|
||||
lifted.Progress.Done, lifted.Progress.Total)
|
||||
}
|
||||
// The edit wave finishes what was bought: the same bar reaches its own end.
|
||||
exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book)
|
||||
done, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done.Progress.Done != 4 || done.Progress.Total != 4 {
|
||||
t.Errorf("the finished work reads %d/%d, want 4/4", done.Progress.Done, done.Progress.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation run over a DRAFTED backlog owes only the last pass, and its denominator says so:
|
||||
// a previous run drafted the chapters, stopped at the signature and was abandoned — the new run's
|
||||
// work is the edit wave alone. Counting the draft wave it never performs left such a run finishing
|
||||
// a clean `ready` at 50% with the caption stuck on drafting (refuter finding, P9).
|
||||
//
|
||||
// Mutation caught: runTotal reading `2 * ceiling` instead of folding in draftWork.
|
||||
func TestARunOverADraftedBacklogOwesOnlyTheLastPass(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
// The whole book is drafted and none of it is edited — the state an abandoned signing run leaves.
|
||||
exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book)
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book)
|
||||
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 2,
|
||||
Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Progress.Done != 0 || run.Progress.Total != 2 {
|
||||
t.Errorf("the receipt reads %d/%d, want 0/2 — the draft passes are not this run's work",
|
||||
run.Progress.Done, run.Progress.Total)
|
||||
}
|
||||
if run.Progress.Stage != "editing" {
|
||||
t.Errorf("stage is %q, want editing: the run owes no draft pass", run.Progress.Stage)
|
||||
}
|
||||
exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book)
|
||||
got, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Progress.Done != 2 || got.Progress.Total != 2 {
|
||||
t.Errorf("the finished work reads %d/%d, want 2/2", got.Progress.Done, got.Progress.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// THE FLIP ORDER, pinned exactly as it happens in production (reviewer's blocker, P9): a book
|
||||
// drafted on a no-editor pipeline (`edit_wave = false`), the operator deploys an editor, a
|
||||
// continuation run starts — and the flag flips to true only AFTER the start, when the engine
|
||||
// announces its wave shape with its first progress event. The baselines are captured before the
|
||||
// flip; captured through the flag they sat on the draft column while the numerator moved to the
|
||||
// edit one, and the bar read 0/N after ALL the bought work was done, forever.
|
||||
//
|
||||
// Mutation caught: taking `chapters_before` on the flag-following predicate at StartRun (the exact
|
||||
// defect); pairing either numerator with the other column's baseline in runDone.
|
||||
func TestAFlagThatFlipsAfterStartDoesNotStrandTheBar(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
// The book's draft era: everything drafted, nothing edited, and the flag SAYS no editor.
|
||||
exec(t, s, ctx, `update books set edit_wave = false where id = $1`, book)
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book)
|
||||
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 2,
|
||||
Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The engine's first progress event announces an editor: the flag grows AFTER the start.
|
||||
exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book)
|
||||
// The run does the whole of what it bought — the edit pass over the drafted chapters.
|
||||
exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book)
|
||||
got, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Progress.Done != got.Progress.Total || got.Progress.Total != 2 {
|
||||
t.Errorf("after all the bought work the bar reads %d/%d, want 2/2 — a baseline captured "+
|
||||
"through the flag never catches the numerator", got.Progress.Done, got.Progress.Total)
|
||||
}
|
||||
if got.Progress.Stage != "editing" {
|
||||
t.Errorf("stage is %q, want editing: the run owed no draft pass", got.Progress.Stage)
|
||||
}
|
||||
}
|
||||
|
||||
// A pipeline with no editor has ONE wave: the bar is what the draft pass finished over what was
|
||||
// bought — never doubled, never re-counted — and lifting a signing stop moves nothing there either:
|
||||
// the run's work was done when the draft was.
|
||||
func TestADraftOnlyDeploymentCountsItsOneWaveOnce(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
|
|
@ -847,16 +987,14 @@ func TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline(t *testing.
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A pipeline with no editor drafts the whole book in the first segment.
|
||||
exec(t, s, ctx, `update books set edit_wave = false where id = $1`, book)
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book)
|
||||
first, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Progress.Done != 2 {
|
||||
t.Fatalf("the first segment reads %d/%d: this fixture never drafted anything",
|
||||
first.Progress.Done, first.Progress.Total)
|
||||
if first.Progress.Done != 2 || first.Progress.Total != 2 {
|
||||
t.Fatalf("a drafted draft-only purchase reads %d/%d, want 2/2", first.Progress.Done, first.Progress.Total)
|
||||
}
|
||||
if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID,
|
||||
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true,
|
||||
|
|
@ -867,8 +1005,8 @@ func TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline(t *testing.
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Progress.Done != 0 {
|
||||
t.Errorf("the second segment opens at %d/%d, want 0 of the work it still has to do",
|
||||
if got.Progress.Done != 2 || got.Progress.Total != 2 {
|
||||
t.Errorf("lifting the stop moved a finished bar to %d/%d, want 2/2 — the work was done",
|
||||
got.Progress.Done, got.Progress.Total)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,22 +186,30 @@ func TestAPageSizeIsClampedAndNeverRefused(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// The run's bar and its BASELINE count the same pass.
|
||||
// The run's bar and its BASELINES count the same passes: each numerator is subtracted only from
|
||||
// the baseline captured on ITS OWN column (draft against draft_before, edit against
|
||||
// chapters_before), so a signing run over an already-drafted book opens at 0 done — through the
|
||||
// old segment counting the mismatch opened it at its full ceiling.
|
||||
//
|
||||
// `chapters_before` was captured on the edit column while the first segment of a signing run counts
|
||||
// the draft one, so a new run over an already-drafted book opened at its full ceiling — the very
|
||||
// defect the column was added to prevent.
|
||||
// ⚠ The fixture keeps the two columns APART on purpose (one chapter edited, both drafted): with
|
||||
// `units_edit_done = 0` everywhere both pairings answered the same 0, and this test's earlier
|
||||
// claim named a mutation it could not see — the PD-1 class, in the pin rewritten to cure one.
|
||||
//
|
||||
// Mutation caught: taking the baseline with `units_done` regardless of the run's own verify_bank.
|
||||
// Mutation caught: subtracting the EDIT baseline (`chapters_before`) from the draft numerator in
|
||||
// draftBar. The mirror pairing — the edit numerator against `draft_before` — needs edits ahead of
|
||||
// drafts, which no real state produces; on its artificial fixture it is held by
|
||||
// TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook.
|
||||
func TestTheBarAndItsBaselineCountTheSamePass(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The book has been DRAFTED end to end and edited nowhere — the state a signing run resumes from.
|
||||
// Drafted end to end, ONE chapter already edited — the columns differ, so a swapped pairing has
|
||||
// something to be wrong about.
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`update chapters set units_draft_done = units_total, units_edit_done = 0
|
||||
`update chapters set units_draft_done = units_total,
|
||||
units_edit_done = case when number = 1 then units_total else 0 end
|
||||
where book_id = $1`, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -220,14 +228,18 @@ func TestTheBarAndItsBaselineCountTheSamePass(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// The SECOND segment measures from what the edit pass had finished when the stop was lifted.
|
||||
// Lifting the signing stop leaves the bar where it stood, and the edit pass then moves it chapter
|
||||
// by chapter — through the through-bar (row 200) there is no second segment and nothing re-bases.
|
||||
//
|
||||
// Lifting the stop switches the numerator from the draft column to the edit one, so a baseline taken
|
||||
// against the draft column at run start would be subtracted from a count of a different pass: the
|
||||
// bar of a fully drafted book opened at its ceiling the moment signing was released.
|
||||
// ⚠ REWRITTEN by pack P9 with the semantics it pins: the earlier edition was named
|
||||
// «RestartsTheBarFromZero» and claimed to catch «dropping the re-capture from the re-open» — that
|
||||
// re-capture was REMOVED with the segment model, and the test stayed green, so its words proved
|
||||
// less than they said (the PD-1 class). What its fixture still pins is real and distinct: a fully
|
||||
// drafted, unedited book under a signing run — the bar opens at 0 done, the lift moves nothing,
|
||||
// and one edited chapter moves it by exactly one.
|
||||
//
|
||||
// Mutation caught: dropping the re-capture from the re-open, or leaving bank_released alone.
|
||||
func TestLiftingTheSigningStopRestartsTheBarFromZero(t *testing.T) {
|
||||
// Mutation caught: pairing the edit numerator with the draft baseline in runDone.
|
||||
func TestLiftingTheSigningStopLeavesTheBarWhereItStood(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
book := readingBook(t, s, ctx, "u1")
|
||||
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
|
||||
|
|
@ -254,7 +266,7 @@ func TestLiftingTheSigningStopRestartsTheBarFromZero(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
if got.Progress.Done != 0 {
|
||||
t.Fatalf("the second segment opens at %d/%d, want 0 done", got.Progress.Done, got.Progress.Total)
|
||||
t.Fatalf("after the lift the bar reads %d/%d, want 0 done — the run has done nothing yet", got.Progress.Done, got.Progress.Total)
|
||||
}
|
||||
// One chapter finishes its EDIT pass: the bar moves by exactly one.
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
|
|
|
|||
|
|
@ -76,34 +76,32 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in
|
|||
return err
|
||||
}
|
||||
runID := newID("run")
|
||||
// The baseline is captured HERE, in the same statement that creates the run: what the book had
|
||||
// already finished is what this run's bar is measured from (see runProgress).
|
||||
// BOTH baselines are captured HERE, in the same statement that creates the run: what the book
|
||||
// had already finished is what each half of this run's bar is measured from (see runDone) —
|
||||
// `chapters_before` on the EDIT column, `draft_before` on the DRAFT one. Neither is re-based
|
||||
// afterwards: the bar is one monotonic fraction through both waves, and a base that moved
|
||||
// mid-run is how it used to restart from zero at the signing stop.
|
||||
//
|
||||
// ⚠ Counted with the SAME predicate the numerator uses — BOTH halves of it. `$3` is the signing
|
||||
// half (a signing run's first segment counts the draft wave); `finishedUnits` is the pipeline
|
||||
// half, and leaving it out is the same defect wearing the other hat: on a deployment with no
|
||||
// editor the numerator counts the draft column while a baseline taken on the edit one stays at
|
||||
// zero, so a new run over an already-drafted book opens at its full ceiling.
|
||||
//
|
||||
// The shape is the BOOK's (finishedUnits), so it is already settled by whatever ran before —
|
||||
// this run has announced nothing yet, and a book that has never run has no resolutions either.
|
||||
// ⚠ FIXED predicates, deliberately NOT the flag-following finishedUnits: `edit_wave` can flip
|
||||
// false→true AFTER this insert (the engine announces its wave shape with its first progress
|
||||
// event), and a baseline captured through the flag would sit on the draft column while the
|
||||
// numerator moved to the edit one — the bar then read 0/N forever (reviewer's blocker, P9).
|
||||
// Captured flag-free, each baseline is subtracted only from the numerator of its own column
|
||||
// (runDone pairs them at READ time), so no flip can strand the bar.
|
||||
const insertRun = `
|
||||
insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at, revision,
|
||||
chapters_before)
|
||||
chapters_before, draft_before)
|
||||
select $1, $2, 'translating', $3, $4, $5, b.revision + 1,
|
||||
(select count(*) from chapters c
|
||||
where c.book_id = b.id and c.units_total > 0
|
||||
and (case when $3 then c.units_draft_done else ` + finishedUnits + ` end)
|
||||
>= c.units_total)
|
||||
where c.book_id = b.id and c.units_total > 0 and c.units_edit_done >= c.units_total),
|
||||
(select count(*) from chapters c
|
||||
where c.book_id = b.id and c.units_total > 0 and c.units_draft_done >= c.units_total)
|
||||
from books b where b.id = $2 and b.owner_id = $6
|
||||
returning id, book_id, revision, status, verify_bank, ceiling_chapters,
|
||||
coalesce(paused_reason, ''), started_at, finished_at`
|
||||
err := tx.QueryRow(ctx, insertRun, runID, in.BookID, in.VerifyBank, in.CeilingChapters, in.Now, in.UserID).
|
||||
Scan(&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank, &out.CeilingChapters,
|
||||
&out.PausedReason, &out.StartedAt, &out.FinishedAt)
|
||||
// A run that has just started has done none of what it bought, and the DENOMINATOR is what the
|
||||
// screen reads its own scale against — so the receipt carries it rather than a zeroed pair.
|
||||
out.Progress = Progress{Total: in.CeilingChapters}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNoBook // the book is missing, or it is not this account's
|
||||
}
|
||||
|
|
@ -113,6 +111,15 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in
|
|||
if err != nil {
|
||||
return fmt.Errorf("pgstore: insert run: %w", err)
|
||||
}
|
||||
// The receipt's bar is READ rather than zeroed: the numerator opens at 0 by construction
|
||||
// (both halves measure THIS run's work from the baselines the same statement just took), but
|
||||
// the DENOMINATOR is the run's own — a continuation run over a drafted backlog owes fewer
|
||||
// draft passes (runTotal folds draftWork in), and the screen reads its scale against it.
|
||||
if err := tx.QueryRow(ctx, `select `+runDone+`, `+runTotal+`, `+runStage+`
|
||||
from books b `+lastRun+` where b.id = $1`, in.BookID).
|
||||
Scan(&out.Progress.Done, &out.Progress.Total, &out.Progress.Stage); err != nil {
|
||||
return fmt.Errorf("pgstore: read the new run's bar: %w", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
insert into run_attempts (run_id, attempt_no, started_at, last_offset, engine_run_id)
|
||||
values ($1, 1, $2, $3, $4) returning id`,
|
||||
|
|
@ -663,6 +670,25 @@ func (s *Store) whyNotLive(ctx context.Context, userID, runID string) error {
|
|||
// The reconciler's own list is deliberately not reusable here: it selects the attempt that has not
|
||||
// ended, and every run this call is about has ended. What resume needs is the state the run stopped
|
||||
// in and the attempt whose money and journal position it stopped at.
|
||||
// LatestRun answers which run of the book every book-scoped read resolves — the newest by the
|
||||
// same ordering `lastRun` uses — and whether that row is still live. The resume gate compares
|
||||
// against it: re-opening any OTHER run leaves the card and every progress frame quoting the wrong
|
||||
// row (PD-402 keeps the read half), and the two reasons carry different words — a LIVE newer run
|
||||
// is «the book is being translated», a finished one is «resume the latest».
|
||||
func (s *Store) LatestRun(ctx context.Context, bookID string) (id string, live bool, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
select id, finished_at is null from runs
|
||||
where book_id = $1 order by started_at desc, id desc limit 1`,
|
||||
bookID).Scan(&id, &live)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, ErrNoRun
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("pgstore: latest run: %w", err)
|
||||
}
|
||||
return id, live, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReadRunForResume(ctx context.Context, userID, runID string) (LiveRun, error) {
|
||||
rows, err := s.queryRuns(ctx, `
|
||||
join run_attempts a on a.run_id = r.id
|
||||
|
|
@ -833,20 +859,15 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error
|
|||
// one-live-per-book index does not see, money marked resolved that this attempt has not spent
|
||||
// yet, and a stop request from the previous life that would classify this attempt's ending as
|
||||
// a stop nobody asked for.
|
||||
// The bar's baseline is re-taken with the pass it will COUNT: lifting the stop moves the
|
||||
// numerator off the draft column, and a baseline left on the other pass made the second segment
|
||||
// open at its ceiling. WHICH pass it moves to comes from `finishedUnits` and not from the edit
|
||||
// column outright — on a deployment with no editor those are different columns, and naming one
|
||||
// of them here re-opens the same defect one segment later.
|
||||
// ⚠ The bar's baselines are NOT re-taken here, and that is the through-bar's one rule (row
|
||||
// 200): the bar counts both waves against the baselines of the run's START, so lifting the
|
||||
// stop moves nothing — the draft half stands at what the first segment did and the last-pass
|
||||
// half continues from where the run began. The re-basing that used to live here is what made
|
||||
// the bar restart from zero at the signing stop.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
update runs r set status = 'translating', paused_reason = null, finished_at = null,
|
||||
settled_at = null, stop_requested_at = null,
|
||||
bank_released = bank_released or $2,
|
||||
chapters_before = case when $2
|
||||
then (select count(*) from chapters c
|
||||
where c.book_id = b.id and c.units_total > 0
|
||||
and `+finishedUnits+` >= c.units_total)
|
||||
else chapters_before end,
|
||||
revision = b.revision + 1
|
||||
from books b
|
||||
where r.id = $1 and b.id = r.book_id`, in.RunID, in.LiftBankStop); err != nil {
|
||||
|
|
|
|||
|
|
@ -287,15 +287,16 @@ func (r *RunSink) unitDone(ctx context.Context, tx pgx.Tx, ev ingest.Envelope, u
|
|||
return r.emitChapter(ctx, tx, u, ev.Time)
|
||||
}
|
||||
|
||||
// emitProgress announces the run's bar as the WIRE counts it: one number in chapters, against what
|
||||
// the run bought. The event that triggers it is in units and per phase — that split stays inside the
|
||||
// platform.
|
||||
// emitProgress announces the run's bar as the WIRE counts it: one monotonic number in chapter-passes
|
||||
// through both waves, against what the run bought, plus the stage caption. The event that triggers
|
||||
// it is in units and per phase — that split stays inside the platform.
|
||||
func (r *RunSink) emitProgress(ctx context.Context, tx pgx.Tx) error {
|
||||
var done, total int
|
||||
var stage string
|
||||
var eta *int
|
||||
err := tx.QueryRow(ctx, `
|
||||
select `+runProgress+`, coalesce(r.ceiling_chapters, 0), r.eta_seconds
|
||||
from books b `+lastRun+` where b.id = $1`, r.bookID).Scan(&done, &total, &eta)
|
||||
select `+runDone+`, `+runTotal+`, `+runStage+`, r.eta_seconds
|
||||
from books b `+lastRun+` where b.id = $1`, r.bookID).Scan(&done, &total, &stage, &eta)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -303,7 +304,7 @@ func (r *RunSink) emitProgress(ctx context.Context, tx pgx.Tx) error {
|
|||
return fmt.Errorf("pgstore: read the run bar: %w", err)
|
||||
}
|
||||
return emitFrame(ctx, tx, r.bookID, FrameProgress, map[string]any{
|
||||
"progress": map[string]any{"done": done, "total": total, "eta_seconds": eta},
|
||||
"progress": map[string]any{"done": done, "total": total, "stage": stage, "eta_seconds": eta},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -679,3 +679,84 @@ func TestAFlaggedUnitMovesTheChaptersNoteCounter(t *testing.T) {
|
|||
t.Errorf("a clean unit moved the note counter to %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The progress FRAME and the run's card answer ONE bar — same done, same total, same stage — and
|
||||
// the pin rides the flipped-flag fixture on purpose: the frame is minted inside the transaction
|
||||
// that flips `edit_wave` (recordWaveShape), which is exactly where a frame computed from different
|
||||
// fragments than the card's would diverge first. This is the wire half of the through-bar (row
|
||||
// 200): `stage` had no value pin at all, and a planted done/total swap in the payload outlived
|
||||
// both batteries before it.
|
||||
//
|
||||
// Mutation caught: swapping done/total (or dropping stage) in emitProgress's payload; computing
|
||||
// the frame from fragments other than the card's runDone/runTotal/runStage.
|
||||
func TestTheProgressFrameMatchesTheCardOnAFlippedFlag(t *testing.T) {
|
||||
s, ctx := testDB(t)
|
||||
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||
seedBook(t, s, ctx, "bk1", "u1", 500)
|
||||
if err := s.SaveStructure(ctx, "bk1", twoChapters("k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The book's draft era: drafted end to end, the flag SAYS no editor, one chapter already edited
|
||||
// by the time the engine speaks — so the bar is non-trivial in the very frame that flips.
|
||||
exec(t, s, ctx, `update books set edit_wave = false where id = 'bk1'`)
|
||||
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = 'bk1'`)
|
||||
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 2,
|
||||
Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec(t, s, ctx, `update chapters set units_edit_done = units_total
|
||||
where book_id = 'bk1' and number = 1`)
|
||||
sink := s.NewRunSink(run.AttemptID, run.ID, "bk1")
|
||||
if err := sink.Begin(ctx, ingest.Hello{EngineRunID: EngineStreamID(run.ID, 1)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The engine's first progress event announces the editor: the flag flips INSIDE this apply, and
|
||||
// the frame is minted right after it.
|
||||
if err := apply(t, sink, 2, ingest.TypeProgress, ingest.Progress{
|
||||
Draft: ingest.Counter{Done: 3, Total: 3}, Edit: ingest.Counter{Done: 2, Total: 3},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card, err := s.ReadRun(ctx, "u1", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card.Progress.Done != 1 || card.Progress.Total != 2 || card.Progress.Stage != "editing" {
|
||||
t.Fatalf("the fixture's card reads %d/%d %q: it cannot tell a swap from the truth",
|
||||
card.Progress.Done, card.Progress.Total, card.Progress.Stage)
|
||||
}
|
||||
frames, err := s.ReadFrames(ctx, "bk1", 0, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var last json.RawMessage
|
||||
for _, f := range frames {
|
||||
if f.Event == FrameProgress {
|
||||
last = f.Data
|
||||
}
|
||||
}
|
||||
if last == nil {
|
||||
t.Fatal("the progress event minted no frame")
|
||||
}
|
||||
var frame struct {
|
||||
Progress struct {
|
||||
Done int `json:"done"`
|
||||
Total int `json:"total"`
|
||||
Stage string `json:"stage"`
|
||||
ETASeconds *int `json:"eta_seconds"`
|
||||
} `json:"progress"`
|
||||
}
|
||||
if err := json.Unmarshal(last, &frame); err != nil {
|
||||
t.Fatalf("the frame's payload: %v (%s)", err, last)
|
||||
}
|
||||
if frame.Progress.Done != card.Progress.Done || frame.Progress.Total != card.Progress.Total ||
|
||||
frame.Progress.Stage != card.Progress.Stage {
|
||||
t.Errorf("the frame reads %d/%d %q and the card %d/%d %q — one bar, two answers",
|
||||
frame.Progress.Done, frame.Progress.Total, frame.Progress.Stage,
|
||||
card.Progress.Done, card.Progress.Total, card.Progress.Stage)
|
||||
}
|
||||
if (frame.Progress.ETASeconds == nil) != (card.Progress.ETASeconds == nil) {
|
||||
t.Errorf("eta: frame %v, card %v", frame.Progress.ETASeconds, card.Progress.ETASeconds)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,13 +146,28 @@ func (s *Service) refresh(ctx context.Context, b pgstore.OwedBook, cut *ingest.M
|
|||
ctx, cancel := context.WithTimeout(ctx, MaterializeBudget)
|
||||
defer cancel()
|
||||
var failed error
|
||||
if err := s.refreshStructure(ctx, b.ID, b.Workdir, cut); err != nil {
|
||||
failed = err
|
||||
s.log().ErrorContext(ctx, "the chapter tree could not be refreshed", "err", err)
|
||||
// ONE manifest read feeds both channels below: the structure is built from it, and the bank
|
||||
// read-out's PATH now comes from its artifacts envelope (row 213) instead of from a copy of the
|
||||
// engine's path convention. A manifest that cannot be read therefore fails both — which it
|
||||
// always did for the tree, and for the bank changes only whose words the error carries.
|
||||
manifest := ingest.Manifest{}
|
||||
if cut != nil {
|
||||
manifest = *cut
|
||||
} else if read, err := s.Engine.Manifest(ctx, s.Binary, b.Workdir); err != nil {
|
||||
failed = fmt.Errorf("readmodel: read the manifest: %w", err)
|
||||
s.log().ErrorContext(ctx, "the manifest could not be read", "err", failed)
|
||||
} else {
|
||||
manifest = read
|
||||
}
|
||||
if err := s.refreshBank(ctx, b.ID, b.Workdir); err != nil {
|
||||
failed = errors.Join(failed, err)
|
||||
s.log().ErrorContext(ctx, "the bank could not be refreshed", "err", err)
|
||||
if failed == nil {
|
||||
if err := s.refreshStructure(ctx, b.ID, b.Workdir, manifest); err != nil {
|
||||
failed = err
|
||||
s.log().ErrorContext(ctx, "the chapter tree could not be refreshed", "err", err)
|
||||
}
|
||||
if err := s.refreshBank(ctx, b.ID, manifest.Artifacts.BankExport); err != nil {
|
||||
failed = errors.Join(failed, err)
|
||||
s.log().ErrorContext(ctx, "the bank could not be refreshed", "err", err)
|
||||
}
|
||||
}
|
||||
if failed != nil {
|
||||
return failed
|
||||
|
|
@ -251,17 +266,7 @@ func (s *Service) defer_(ctx context.Context, b pgstore.OwedBook, cause error, c
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string, cut *ingest.Manifest) error {
|
||||
manifest := ingest.Manifest{}
|
||||
if cut != nil {
|
||||
manifest = *cut
|
||||
} else {
|
||||
read, err := s.Engine.Manifest(ctx, s.Binary, workdir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readmodel: read the manifest: %w", err)
|
||||
}
|
||||
manifest = read
|
||||
}
|
||||
func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string, manifest ingest.Manifest) error {
|
||||
// ⚠ THE FLOOR, and it guards the one write in this zone that can lose a whole book's text.
|
||||
// `SaveStructure` writes the tree by REPLACEMENT — every chapter outside the list it is given is
|
||||
// deleted and the cascade takes the pairs with it — so an empty or short list is not a small
|
||||
|
|
@ -336,8 +341,8 @@ func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string,
|
|||
return textErr
|
||||
}
|
||||
|
||||
func (s *Service) refreshBank(ctx context.Context, bookID, workdir string) error {
|
||||
bank, err := runner.ReadBank(workdir)
|
||||
func (s *Service) refreshBank(ctx context.Context, bookID, bankExport string) error {
|
||||
bank, err := runner.ReadBank(bankExport)
|
||||
if errors.Is(err, runner.ErrNoBank) {
|
||||
// A book that has never produced terms. Not an empty bank written over a full one: nothing is
|
||||
// saved at all, so a read-out that has simply not been made yet cannot erase one that was.
|
||||
|
|
|
|||
|
|
@ -21,10 +21,18 @@ type fakeEngine struct {
|
|||
export ingest.Export
|
||||
manifestErr error
|
||||
exportErr error
|
||||
// noEnvelope models an engine build from before the artifacts envelope (D39.158): its manifest
|
||||
// names no bank read-out place at all.
|
||||
noEnvelope bool
|
||||
}
|
||||
|
||||
func (f *fakeEngine) Manifest(context.Context, string, string) (ingest.Manifest, error) {
|
||||
return f.manifest, f.manifestErr
|
||||
func (f *fakeEngine) Manifest(_ context.Context, _, workdir string) (ingest.Manifest, error) {
|
||||
m := f.manifest
|
||||
if !f.noEnvelope {
|
||||
// Derived from the workdir the way the real engine derives it — beside the project database.
|
||||
m.Artifacts = ingest.StatusArtifacts{BankExport: filepath.Join(workdir, "bk_1.db.bank.json")}
|
||||
}
|
||||
return m, f.manifestErr
|
||||
}
|
||||
|
||||
func (f *fakeEngine) Export(context.Context, string, string) (ingest.Export, error) {
|
||||
|
|
@ -132,15 +140,16 @@ func tree() ingest.Manifest {
|
|||
func TestTheTextIsJoinedOntoTheTreeByTheEnginesOwnKey(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{
|
||||
manifest: tree(),
|
||||
manifest: tree(),
|
||||
noEnvelope: true,
|
||||
export: ingest.Export{TotalUnits: 3, Units: []ingest.UnitText{
|
||||
{Chapter: 1, Unit: 4, Source: "第二节", Target: "Второй", State: ingest.StateTranslated},
|
||||
{Chapter: 2, Unit: 0, Source: "第三节", State: ingest.StateWithheld},
|
||||
}},
|
||||
}}
|
||||
// ⚠ The BANK half fails here — this workdir has no engine artifacts — and the tree lands anyway:
|
||||
// each channel is a different question about the same book, and answering two of three is
|
||||
// strictly better than answering none.
|
||||
// ⚠ The BANK half fails here — this engine publishes no artifacts envelope — and the tree lands
|
||||
// anyway: each channel is a different question about the same book, and answering two of three
|
||||
// is strictly better than answering none.
|
||||
if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err == nil {
|
||||
t.Error("a workdir with no bank read-out reported no failure at all")
|
||||
}
|
||||
|
|
@ -205,7 +214,6 @@ func TestAPairTheExportDidNotCarryDoesNotSpeakAboutItsText(t *testing.T) {
|
|||
{Chapter: 1, Unit: 0, Source: "第一节", Target: "Первый", State: ingest.StateTranslated},
|
||||
}},
|
||||
}}
|
||||
// The bank half fails on an empty workdir, which is not what this pins.
|
||||
_ = svc.Refresh(t.Context(), owedBook(t.TempDir()))
|
||||
var known, unknown int
|
||||
for _, c := range store.structure.Chapters {
|
||||
|
|
@ -227,18 +235,12 @@ func TestAPairTheExportDidNotCarryDoesNotSpeakAboutItsText(t *testing.T) {
|
|||
//
|
||||
// Mutation caught: treating ErrNoBank as an empty read-out.
|
||||
func TestAMissingBankReadOutSavesNothing(t *testing.T) {
|
||||
// ⚠ The book IS configured and simply has no bank sidecar yet — which is the branch this pins.
|
||||
// Without the configuration the read failed one step earlier, on the missing book.yaml, and the
|
||||
// declared property was never reached: "a read-out that has not been made must not erase one that
|
||||
// was" was asserted by a test that could not tell the two failures apart.
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "book.yaml"),
|
||||
[]byte("book_id: bk_1\nproject_db: bk_1.db\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ⚠ The engine PUBLISHED the read-out's place (the artifacts envelope, row 213) and no file is
|
||||
// there yet — which is the branch this pins. An engine that published no place at all is the
|
||||
// loud failure next door, and telling the two apart is what the envelope is for.
|
||||
store := &fakeStore{}
|
||||
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}}
|
||||
if err := svc.Refresh(t.Context(), owedBook(dir)); err != nil {
|
||||
if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err != nil {
|
||||
t.Fatalf("a book that has never produced terms is not a failure: %v", err)
|
||||
}
|
||||
if store.bankSaved {
|
||||
|
|
@ -249,26 +251,26 @@ func TestAMissingBankReadOutSavesNothing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// A workdir with no configuration at all is a different fact, and it IS a failure: nothing about
|
||||
// this book can be located, so the refresh says so instead of reporting an empty book.
|
||||
func TestAWorkdirWithNoConfigurationIsAFailureRatherThanAnEmptyBank(t *testing.T) {
|
||||
// An engine that publishes no artifacts envelope — a build from before D39.158 — is a different
|
||||
// fact, and it IS a failure: the bank's place cannot be located, and reading that as "no bank"
|
||||
// would silently stop every bank refresh on such a deployment.
|
||||
func TestAnEngineWithoutTheEnvelopeIsAFailureRatherThanAnEmptyBank(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}}
|
||||
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree(), noEnvelope: true}}
|
||||
if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err == nil {
|
||||
t.Fatal("a workdir with no configuration was read as a book with no bank")
|
||||
t.Fatal("a manifest naming no bank read-out place was read as a book with no bank")
|
||||
}
|
||||
if store.bankSaved {
|
||||
t.Error("an unreadable configuration saved a bank anyway")
|
||||
t.Error("a missing envelope saved a bank anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// configured is a workdir the bank channel can answer about: the book exists and has simply never
|
||||
// produced terms, so the read-out is absent rather than unreadable.
|
||||
func configured(t *testing.T) string {
|
||||
// garbageBank is a workdir whose published bank read-out exists and is not a bank — the one way a
|
||||
// book's bank channel fails while its neighbours' answer fine.
|
||||
func garbageBank(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "book.yaml"),
|
||||
[]byte("book_id: bk_1\nproject_db: bk_1.db\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "bk_1.db.bank.json"), []byte("not a bank"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
|
|
@ -284,7 +286,7 @@ func TestAPartialMaterializationDoesNotDischargeTheDebt(t *testing.T) {
|
|||
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{
|
||||
manifest: tree(), exportErr: errors.New("the engine refused"),
|
||||
}}
|
||||
if err := svc.Refresh(t.Context(), owedBook(configured(t))); err == nil {
|
||||
if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err == nil {
|
||||
t.Fatal("a materialization with no text reported success")
|
||||
}
|
||||
if !store.saved {
|
||||
|
|
@ -308,7 +310,7 @@ func TestACompleteMaterializationDischargesTheBoundaryItRead(t *testing.T) {
|
|||
{Chapter: 1, Unit: 0, Source: "第一节", Target: "Первый", State: ingest.StateTranslated},
|
||||
}},
|
||||
}}
|
||||
if err := svc.Refresh(t.Context(), owedBook(configured(t))); err != nil {
|
||||
if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.cleared) != 1 || store.cleared[0].ID != "bk_1" || !store.cleared[0].OwedAt.Equal(boundary) {
|
||||
|
|
@ -332,7 +334,7 @@ func TestTheDischargeSurvivesAContextTheReadsUsedUp(t *testing.T) {
|
|||
}}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
store.beforeClear = cancel // the budget runs out exactly between the last read and the record
|
||||
if err := svc.Refresh(ctx, owedBook(configured(t))); err != nil {
|
||||
if err := svc.Refresh(ctx, owedBook(t.TempDir())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.cleared) != 1 {
|
||||
|
|
@ -349,8 +351,8 @@ func TestTheDischargeSurvivesAContextTheReadsUsedUp(t *testing.T) {
|
|||
// Mutation caught: returning on the first error.
|
||||
func TestTheDrainMaterializesEveryOwedBookAndKeepsWhatFailed(t *testing.T) {
|
||||
store := &fakeStore{owed: []pgstore.OwedBook{
|
||||
{ID: "bk_1", Workdir: t.TempDir(), OwedAt: boundary}, // no configuration: the bank read fails
|
||||
{ID: "bk_2", Workdir: configured(t), OwedAt: boundary},
|
||||
{ID: "bk_1", Workdir: garbageBank(t), OwedAt: boundary}, // its read-out is not a bank: the read fails
|
||||
{ID: "bk_2", Workdir: t.TempDir(), OwedAt: boundary},
|
||||
}}
|
||||
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}}
|
||||
if err := svc.Drain(t.Context()); err != nil {
|
||||
|
|
@ -414,7 +416,7 @@ func TestTheManifestsRenderedHeadingHasNowhereToLand(t *testing.T) {
|
|||
//
|
||||
// Mutation caught: draining without claiming; ignoring a claim somebody else holds.
|
||||
func TestTheDrainSkipsABookSomebodyElseIsAlreadyMaterializing(t *testing.T) {
|
||||
store := &fakeStore{owed: []pgstore.OwedBook{{ID: "bk_1", Workdir: configured(t), OwedAt: boundary}}}
|
||||
store := &fakeStore{owed: []pgstore.OwedBook{{ID: "bk_1", Workdir: t.TempDir(), OwedAt: boundary}}}
|
||||
svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}}
|
||||
store.unclaimable = true
|
||||
if err := svc.Drain(t.Context()); err != nil {
|
||||
|
|
@ -591,9 +593,9 @@ func exitError(t *testing.T, code int) error {
|
|||
// Mutation caught: deferring to `now()`; not counting the attempt; never abandoning; abandoning on
|
||||
// the first failure.
|
||||
func TestAnUnpayableDebtBacksOffAndIsEventuallyGivenUpOn(t *testing.T) {
|
||||
// A workdir with no engine configuration: the bank read fails, so the materialization never
|
||||
// completes — the shape of a project directory an operator moved.
|
||||
broken := t.TempDir()
|
||||
// A book whose published bank read-out exists and is not a bank: the read fails on every pass, so
|
||||
// the materialization never completes — the shape of a project directory an operator broke.
|
||||
broken := garbageBank(t)
|
||||
for attempts := range maxAttempts {
|
||||
store := &fakeStore{owed: []pgstore.OwedBook{
|
||||
{ID: "bk_1", Workdir: broken, OwedAt: boundary, Attempts: attempts},
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ import (
|
|||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
)
|
||||
|
|
@ -28,13 +25,20 @@ var ErrNoBank = errors.New("runner: this book has no bank read-out")
|
|||
// that is not one rather than sizing one that is.
|
||||
const maxBank = 256 << 20
|
||||
|
||||
// ReadBank decodes a book's whole-bank read-out.
|
||||
func ReadBank(workdir string) (ingest.Bank, error) {
|
||||
db, err := projectDB(workdir)
|
||||
if err != nil {
|
||||
return ingest.Bank{}, err
|
||||
// ReadBank decodes a book's whole-bank read-out at the path the ENGINE published for it — the
|
||||
// `artifacts.bank_export` of its manifest and status documents (row 213).
|
||||
//
|
||||
// The path is taken rather than derived: this function used to parse `book.yaml` and re-derive
|
||||
// `<project_db>.bank.json` by copying the engine's default from backend/internal/config/book.go,
|
||||
// which is another zone's convention — a changed default there would have silently walked this read
|
||||
// off to a path where no file ever appears.
|
||||
func ReadBank(path string) (ingest.Bank, error) {
|
||||
if path == "" {
|
||||
// An engine build from before the artifact envelope (D39.158). Loud, because the quiet
|
||||
// alternative is a bank that silently never refreshes on such a deployment.
|
||||
return ingest.Bank{}, errors.New("runner: the engine published no bank read-out path: its manifest carries no artifacts envelope")
|
||||
}
|
||||
f, err := os.Open(db + ".bank.json")
|
||||
f, err := os.Open(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return ingest.Bank{}, ErrNoBank
|
||||
}
|
||||
|
|
@ -51,48 +55,3 @@ func ReadBank(workdir string) (ingest.Bank, error) {
|
|||
}
|
||||
return ingest.DecodeBank(raw)
|
||||
}
|
||||
|
||||
// projectDB asks the book's own configuration where its project database is.
|
||||
//
|
||||
// ⚠ This READS `book.yaml` and does not contradict D39.110 §2b: that decision is about who OWNS the
|
||||
// file — the platform renders one starting copy and never edits it again — not about whether the
|
||||
// path it declares may be followed. The sidecars are named after `project_db` by the engine, so
|
||||
// there is no second place to learn the name from, and the alternative (globbing the directory for
|
||||
// `*.bank.json`) would guess where a configuration states.
|
||||
//
|
||||
// The path is resolved against the BOOK's directory, which is how the engine resolves every relative
|
||||
// path in that file.
|
||||
func projectDB(workdir string) (string, error) {
|
||||
f, err := os.Open(filepath.Join(workdir, ConfigFile))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("runner: open book configuration: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(f, maxBookConfig+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("runner: read book configuration: %w", err)
|
||||
}
|
||||
var cfg struct {
|
||||
ProjectDB string `yaml:"project_db"`
|
||||
BookID string `yaml:"book_id"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
return "", fmt.Errorf("runner: read book configuration: %w", err)
|
||||
}
|
||||
if cfg.ProjectDB == "" {
|
||||
// ⚠ The key is OPTIONAL to the engine, which defaults to `<book_id>.db` beside the file
|
||||
// (backend/internal/config/book.go). Refusing here read the bank as missing on every
|
||||
// deployment whose operator used the template as written, since it carries no `project_db`.
|
||||
if cfg.BookID == "" {
|
||||
return "", errors.New("runner: the book configuration names neither project_db nor book_id")
|
||||
}
|
||||
return filepath.Join(workdir, cfg.BookID+".db"), nil
|
||||
}
|
||||
if filepath.IsAbs(cfg.ProjectDB) {
|
||||
return cfg.ProjectDB, nil
|
||||
}
|
||||
return filepath.Join(workdir, cfg.ProjectDB), nil
|
||||
}
|
||||
|
||||
// maxBookConfig bounds what will be read as a book configuration — a page of keys.
|
||||
const maxBookConfig = 1 << 20
|
||||
|
|
|
|||
|
|
@ -1,41 +1,31 @@
|
|||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// `project_db` is OPTIONAL to the engine, which defaults to `<book_id>.db` beside the file — and the
|
||||
// operator's own template carries no such key. Refusing here read the bank as missing on every
|
||||
// deployment that used the template as written.
|
||||
// The bank read-out is opened at the path the ENGINE published (`artifacts.bank_export`, row 213) —
|
||||
// the derivation from `book.yaml` that used to live here copied another zone's path convention and
|
||||
// is gone with this pin's previous edition.
|
||||
//
|
||||
// Mutation caught: requiring the key.
|
||||
func TestTheProjectDatabaseDefaultsTheWayTheEngineDefaultsIt(t *testing.T) {
|
||||
// Mutation caught: treating an empty published path as "no bank yet" — on an engine build from
|
||||
// before the envelope that would silently stop every bank refresh, so it must refuse loudly instead.
|
||||
func TestTheBankIsReadAtThePathTheEnginePublished(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ConfigFile), []byte("book_id: sample-zh\n"), 0o644); err != nil {
|
||||
path := filepath.Join(dir, "sample-zh.db.bank.json")
|
||||
if _, err := ReadBank(path); !errors.Is(err, ErrNoBank) {
|
||||
t.Fatalf("a published place with no file yet answered %v, want ErrNoBank", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"bank_version":"tm-bank-v1","book_id":"sample-zh","terms":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := projectDB(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a configuration without project_db: %v", err)
|
||||
if _, err := ReadBank(path); err != nil {
|
||||
t.Fatalf("the read-out at the published path: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "sample-zh.db"); got != want {
|
||||
t.Errorf("project database %q, want %q", got, want)
|
||||
}
|
||||
// Declared explicitly, it still wins.
|
||||
if err := os.WriteFile(filepath.Join(dir, ConfigFile),
|
||||
[]byte("book_id: sample-zh\nproject_db: other.db\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err = projectDB(dir); err != nil || got != filepath.Join(dir, "other.db") {
|
||||
t.Errorf("a declared project_db answered %q (%v)", got, err)
|
||||
}
|
||||
// Neither key: there is nothing to derive from, and that IS a failure.
|
||||
if err := os.WriteFile(filepath.Join(dir, ConfigFile), []byte("title: x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := projectDB(dir); err == nil {
|
||||
t.Error("a configuration naming neither key was accepted")
|
||||
if _, err := ReadBank(""); err == nil || errors.Is(err, ErrNoBank) {
|
||||
t.Errorf("an empty published path answered %v, want a loud refusal naming the missing envelope", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
124
platform/internal/runner/bankapply.go
Normal file
124
platform/internal/runner/bankapply.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
)
|
||||
|
||||
// bankapply.go: the correction channel — `tmctl bank-apply` run as a direct child, like the other
|
||||
// $0 commands (engine.go). It is NOT readEngine, because the exit code is DATA here and not only a
|
||||
// verdict: the refusal band tells the caller's remedy apart (re-decide / re-send the same document /
|
||||
// call the operator), and several classes still print the whole report on stdout on their way out.
|
||||
|
||||
// BankApplyArgs is the verb's argv. `--dry-run` is the projection mode the contract's `preview`
|
||||
// maps to (17-seam-inbound-law п.7).
|
||||
func BankApplyArgs(workdir, decisionsPath string, preview bool) []string {
|
||||
args := []string{"bank-apply", "--config", filepath.Join(workdir, ConfigFile),
|
||||
"--decisions", decisionsPath}
|
||||
if preview {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// maxBankReport bounds what is read back as a report. A report itemizes at most the 5000 decisions
|
||||
// of one act; the cap refuses a process at the configured path that is not the engine.
|
||||
const maxBankReport = 64 << 20
|
||||
|
||||
// bankStopGrace is how long the verb gets after SIGTERM before it is killed outright. Short next to
|
||||
// the run supervisor's: the verb's own SIGTERM contract is "written nothing yet — drop the work"
|
||||
// or "the writes are last and atomic", so what the grace protects is a write already in flight.
|
||||
//
|
||||
// 30 s, not a token 10: the engine's own measurement puts a maximum document (5000 minimal
|
||||
// declines) at ~11.3 s of uninterruptible fold before the pre-write context check (backend
|
||||
// bankdecisions.go, maxDecisions) — a 10 s grace expired BEFORE the phase it exists to protect
|
||||
// could even begin, and the SIGKILL it fired mid-fold is what produced half-landed pairs with no
|
||||
// report (workflow finding, P9). 30 s clears the measured worst case with a ~2.5× margin for a
|
||||
// slower host; the cost of the width is only how long a wedged verb can outlive its budget.
|
||||
const bankStopGrace = 30 * time.Second
|
||||
|
||||
// BankApplyOutcome is what running the verb produced, exit code and report together — the caller
|
||||
// maps the pair onto the contract, this layer only runs and reads.
|
||||
type BankApplyOutcome struct {
|
||||
// Report is the decoded report; Decoded says one arrived on stdout and parsed. Classes that
|
||||
// speak on stderr alone (config, a held project, an unmigrated schema) legitimately leave it
|
||||
// false.
|
||||
Report ingest.BankReport
|
||||
Decoded bool
|
||||
// DecodeErr is why stdout did not parse as a report, when it carried bytes that did not.
|
||||
DecodeErr error
|
||||
// ExitCode is the engine's own exit, valid when Exited. A process that did not exit on its own
|
||||
// — killed on the caller's deadline, or never started — leaves Exited false.
|
||||
ExitCode int
|
||||
Exited bool
|
||||
// Stderr is the first line of the engine's stderr — for the operator's log, never for the wire.
|
||||
Stderr string
|
||||
}
|
||||
|
||||
// BankApply runs the verb against a book and reads its report back.
|
||||
//
|
||||
// The returned error is "the verb could not be run or read at all" — a start failure, or the
|
||||
// caller's context ending. An ordinary non-zero exit is NOT an error here: it comes back as the
|
||||
// outcome's ExitCode, usually with the report beside it.
|
||||
func (r *Runner) BankApply(ctx context.Context, binary, workdir, decisionsPath string, preview bool) (BankApplyOutcome, error) {
|
||||
cmd := exec.CommandContext(ctx, binary, BankApplyArgs(workdir, decisionsPath, preview)...)
|
||||
cmd.Dir = workdir
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.Stdout = &limitedBuffer{buf: &out, limit: maxBankReport}
|
||||
cmd.Stderr = &errOut
|
||||
// SIGTERM, not the default SIGKILL: the engine's contract for it is graceful — nothing written
|
||||
// before the pre-write check, atomic renames after — and a SIGKILL mid-rename is exactly the
|
||||
// half-landed state class 15 exists to report, produced by us instead of by the host. The same
|
||||
// signal systemd's KillSignal sends the run units.
|
||||
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
|
||||
cmd.WaitDelay = bankStopGrace
|
||||
err := cmd.Run()
|
||||
res := BankApplyOutcome{Stderr: string(firstLine(errOut.Bytes()))}
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||
res.ExitCode, res.Exited = cmd.ProcessState.ExitCode(), true
|
||||
}
|
||||
if body := bytes.TrimSpace(out.Bytes()); len(body) > 0 {
|
||||
if rep, derr := ingest.DecodeBankReport(body); derr != nil {
|
||||
res.DecodeErr = derr
|
||||
} else {
|
||||
res.Report, res.Decoded = rep, true
|
||||
}
|
||||
}
|
||||
if err != nil && !res.Exited {
|
||||
// Killed, or never a process: the exit code carries nothing, so the error is the answer.
|
||||
// The caller's own deadline is folded in — an *exec.ExitError from a SIGKILL says less than
|
||||
// "the budget ran out".
|
||||
return res, errors.Join(fmt.Errorf("runner: tmctl bank-apply: %w: %s", err, res.Stderr), ctx.Err())
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// limitedBuffer refuses growth past its limit instead of buffering whatever a process feels like
|
||||
// printing. Refusing the WRITE is what kills the child through the exec pipe machinery — exec's
|
||||
// copy goroutine closes the READ end the moment a Write errors (os/exec writerDescriptor: «in case
|
||||
// io.Copy stopped due to write error»), and the child dies on EPIPE at its next print. drain()
|
||||
// carries its own Kill because the streamed commands read through StdoutPipe, where no goroutine
|
||||
// closes anything for the caller — a workflow review read this file as if it were that one, and
|
||||
// the refutation is pinned (TestAnEndlessBankApplyIsRefusedRatherThanRead).
|
||||
type limitedBuffer struct {
|
||||
buf *bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func (l *limitedBuffer) Write(p []byte) (int, error) {
|
||||
if l.buf.Len()+len(p) > l.limit {
|
||||
return 0, errors.New("the engine printed more than this platform will read, which no report produces")
|
||||
}
|
||||
return l.buf.Write(p)
|
||||
}
|
||||
|
||||
var _ io.Writer = (*limitedBuffer)(nil)
|
||||
141
platform/internal/runner/bankapply_live_test.go
Normal file
141
platform/internal/runner/bankapply_live_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package runner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
)
|
||||
|
||||
// `--dry-run` is the whole of what separates a preview from a write, so its presence is pinned at
|
||||
// the argv — the one place a dropped flag is a one-token diff.
|
||||
//
|
||||
// Mutation caught: passing preview through without the flag (every call becomes a write), or
|
||||
// passing the flag unconditionally (every write becomes a no-op).
|
||||
func TestAPreviewIsTheDryRunFlagAndNothingElse(t *testing.T) {
|
||||
if !slices.Contains(BankApplyArgs("/w", "/d.json", true), "--dry-run") {
|
||||
t.Error("a preview call carries no --dry-run: it would WRITE")
|
||||
}
|
||||
if slices.Contains(BankApplyArgs("/w", "/d.json", false), "--dry-run") {
|
||||
t.Error("an apply call carries --dry-run: it would never write")
|
||||
}
|
||||
}
|
||||
|
||||
// The canon's central promise about the preview, proven against the REAL engine: «"preview": true
|
||||
// answers the same receipt without changing anything» (§applyBankCorrections). A regress of one
|
||||
// constant turns the safe look into an irreversible write, and no unit test can make this claim —
|
||||
// what writes is the engine, so the engine is what must be seen not writing.
|
||||
//
|
||||
// Gated like every live-engine test: a bare clone stays green and says why.
|
||||
func TestALivePreviewWritesNothingAndALiveApplyWrites(t *testing.T) {
|
||||
bin := os.Getenv("TM_PLATFORM_TEST_ENGINE_BIN")
|
||||
tpl := os.Getenv("TM_PLATFORM_TEST_BOOK_TEMPLATE")
|
||||
if bin == "" || tpl == "" {
|
||||
t.Skip("TM_PLATFORM_TEST_ENGINE_BIN and TM_PLATFORM_TEST_BOOK_TEMPLATE not set: " +
|
||||
"the preview's no-write promise is not checked against a real engine")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
writeProbeBook(t, tpl, dir, "bk_PREVIEWPROBE")
|
||||
doc, err := ingest.EncodeDecisions("bk_PREVIEWPROBE", []ingest.BankDecision{
|
||||
{Action: "approve", Src: "测试", Sense: "", Dst: "проба", Note: "preview pin"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decisions := filepath.Join(t.TempDir(), "decisions.json")
|
||||
if err := os.WriteFile(decisions, doc, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := dirListing(t, dir)
|
||||
rn := New(nil)
|
||||
out, err := rn.BankApply(t.Context(), bin, dir, decisions, true)
|
||||
if err != nil || !out.Exited || out.ExitCode != 0 || !out.Decoded {
|
||||
t.Fatalf("the live preview did not answer: %+v (%v; stderr %q)", out, err, out.Stderr)
|
||||
}
|
||||
if out.Report.Mode != "projection" || !out.Report.Changed {
|
||||
t.Fatalf("the preview's report: mode %q changed %v, want a projection that WOULD change",
|
||||
out.Report.Mode, out.Report.Changed)
|
||||
}
|
||||
// Nothing appeared and nothing moved — except the lock file, which the verb's own contract
|
||||
// names: a projection computed without the arbiter is a projection of nothing.
|
||||
after := dirListing(t, dir)
|
||||
for name, sum := range after {
|
||||
if filepath.Ext(name) == ".lock" {
|
||||
continue
|
||||
}
|
||||
if prev, ok := before[name]; !ok {
|
||||
t.Errorf("the preview CREATED %s", name)
|
||||
} else if prev != sum {
|
||||
t.Errorf("the preview CHANGED %s", name)
|
||||
}
|
||||
}
|
||||
// The same document applied for real is the counter-proof: the engine both can and does write
|
||||
// here, so the silence above was the preview's doing and not a fixture that cannot write.
|
||||
out, err = rn.BankApply(t.Context(), bin, dir, decisions, false)
|
||||
if err != nil || out.ExitCode != 0 || !out.Decoded || out.Report.Mode != "apply" || !out.Report.Changed {
|
||||
t.Fatalf("the live apply did not land: %+v (%v; stderr %q)", out, err, out.Stderr)
|
||||
}
|
||||
applied := dirListing(t, dir)
|
||||
grew := false
|
||||
for name := range applied {
|
||||
if _, ok := after[name]; !ok && filepath.Ext(name) != ".lock" {
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
if !grew {
|
||||
t.Error("the apply wrote no decision file: this fixture proves nothing about the preview")
|
||||
}
|
||||
}
|
||||
|
||||
// writeProbeBook renders a minimal live book: the deployment template with the identity, languages
|
||||
// and source the intake would have filled in (the four keys books.Service.provision sets).
|
||||
func writeProbeBook(t *testing.T, tpl, dir, bookID string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(tpl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg["book_id"] = bookID
|
||||
cfg["source_lang"] = "zh"
|
||||
cfg["target_lang"] = "ru"
|
||||
cfg["source_file"] = "source.txt"
|
||||
out, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ConfigFile), out, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("第1章 测试\n这是一个测试文本。\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// dirListing is the book directory as facts: every file name with a digest of its bytes.
|
||||
func dirListing(t *testing.T, dir string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[e.Name()] = string(rune(len(b))) + "-" + string(b[:min(len(b), 64)])
|
||||
}
|
||||
return out
|
||||
}
|
||||
25
platform/internal/runner/bankapply_overflow_test.go
Normal file
25
platform/internal/runner/bankapply_overflow_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package runner
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The stdout cap must END an endless writer, not merely stop reading it. The mechanism is exec's
|
||||
// own: when limitedBuffer refuses a Write, the copy goroutine closes the pipe's read end («in case
|
||||
// io.Copy stopped due to write error», os/exec writerDescriptor) and the child dies on EPIPE at
|
||||
// its next print — no explicit Kill here, unlike drain(), whose StdoutPipe reader gets no such
|
||||
// help. A workflow review claimed the child blocks on a full pipe until the caller's deadline;
|
||||
// this pin is the refutation, and it is what fails if the cap ever stops refusing the Write.
|
||||
func TestAnEndlessBankApplyIsRefusedRatherThanRead(t *testing.T) {
|
||||
bin := fakeEngine(t, `exec yes '{"report_version":"tm-bank-decisions-report-v2"}'`)
|
||||
start := time.Now()
|
||||
_, err := New(nil).BankApply(t.Context(), bin, t.TempDir(), filepath.Join(t.TempDir(), "d.json"), false)
|
||||
if err == nil {
|
||||
t.Fatal("an endless report was read to the end")
|
||||
}
|
||||
if took := time.Since(start); took > 30*time.Second {
|
||||
t.Fatalf("the overflow did not end the child: the call took %v", took)
|
||||
}
|
||||
}
|
||||
|
|
@ -80,11 +80,21 @@ func (t CeilingTemplate) Args(ceiling money.MicroUSD) ([]string, error) {
|
|||
//
|
||||
// `--config` is not optional and its absence is not tolerated: tmctl requires it on every book
|
||||
// command, so a call without it fails at argument parsing and never reaches the book.
|
||||
func TranslateArgs(workdir string, verifyBank bool, ceiling []string) []string {
|
||||
//
|
||||
// keysFile is the DEPLOYMENT's provider-key file, passed as `--keys-file` (row 211). It goes on
|
||||
// `translate` alone because the engine refuses the flag on every other command (D20.4: the $0 read
|
||||
// verbs must not demand keys), and it goes as an ARGUMENT rather than into the unit's environment:
|
||||
// keys never pass through this process. Empty means the flag is not passed and the engine falls back
|
||||
// to its conventional `.env` files — which on the SaaS path nothing writes, so a deployment that
|
||||
// leaves this unset is one whose runs fail at the first provider call.
|
||||
func TranslateArgs(workdir string, verifyBank bool, keysFile string, ceiling []string) []string {
|
||||
args := []string{"translate", "--config", filepath.Join(workdir, ConfigFile)}
|
||||
if verifyBank {
|
||||
args = append(args, "--verify-bank")
|
||||
}
|
||||
if keysFile != "" {
|
||||
args = append(args, "--keys-file", keysFile)
|
||||
}
|
||||
return append(args, ceiling...)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,8 +70,9 @@ type Spec struct {
|
|||
Args []string
|
||||
// Workdir is the book's project directory.
|
||||
Workdir string
|
||||
// Env is the child's environment as KEY=VALUE. Provider keys arrive through it and are never
|
||||
// logged.
|
||||
// Env is the child's environment as KEY=VALUE. It carries the run's identity and never provider
|
||||
// keys — those reach the engine as the `--keys-file` argument in Args (row 211) — and, like
|
||||
// Args, it is never logged.
|
||||
Env []string
|
||||
// ExitMarker is the file ExecStopPost fills with what systemd saw. It is platform state and
|
||||
// lives in the platform's state directory, not in the book's directory — the engine owns that
|
||||
|
|
|
|||
|
|
@ -174,14 +174,14 @@ func TestTheCeilingIsRefusedUntilTheEngineCanBeToldIt(t *testing.T) {
|
|||
// tmctl requires --config on every command that touches a book, so an invocation without it never
|
||||
// reaches the book at all — measured on a binary built from HEAD.
|
||||
func TestEveryEngineInvocationNamesTheBookConfig(t *testing.T) {
|
||||
tr := TranslateArgs("/srv/books/a", true, []string{"--ceiling-usd", "1.000000"})
|
||||
tr := TranslateArgs("/srv/books/a", true, "", []string{"--ceiling-usd", "1.000000"})
|
||||
if !slices.Contains(tr, "--config") || !slices.Contains(tr, "/srv/books/a/book.yaml") {
|
||||
t.Errorf("translate argv without --config: %v", tr)
|
||||
}
|
||||
if !slices.Contains(tr, "--verify-bank") {
|
||||
t.Errorf("verify_bank did not reach the engine: %v", tr)
|
||||
}
|
||||
if slices.Contains(TranslateArgs("/w", false, nil), "--verify-bank") {
|
||||
if slices.Contains(TranslateArgs("/w", false, "", nil), "--verify-bank") {
|
||||
t.Error("verify-bank was sent for a run that did not ask for it")
|
||||
}
|
||||
st := StatusArgs("/srv/books/a")
|
||||
|
|
@ -190,6 +190,22 @@ func TestEveryEngineInvocationNamesTheBookConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// The deployment's key file reaches the engine as `--keys-file` on `translate` — and ONLY when one
|
||||
// is configured: an empty path passed as a flag would be refused by the engine's own parser
|
||||
// («--keys-file was given with no path», cmd/tmctl/invocation.go).
|
||||
//
|
||||
// Mutation caught: dropping the argument, or passing the flag with an empty value.
|
||||
func TestTheDeploymentKeyFileReachesTranslate(t *testing.T) {
|
||||
tr := TranslateArgs("/srv/books/a", false, "/etc/tm/keys.env", nil)
|
||||
i := slices.Index(tr, "--keys-file")
|
||||
if i < 0 || i+1 >= len(tr) || tr[i+1] != "/etc/tm/keys.env" {
|
||||
t.Errorf("the key file did not reach the engine: %v", tr)
|
||||
}
|
||||
if slices.Contains(TranslateArgs("/srv/books/a", false, "", nil), "--keys-file") {
|
||||
t.Error("an unset key file was still passed as a flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMarkerSurvivesAndIsReadBack(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "runs", "X-1.exit")
|
||||
if _, err := ReadMarker(path); !errors.Is(err, ErrNoMarker) {
|
||||
|
|
|
|||
360
platform/internal/runs/bank.go
Normal file
360
platform/internal/runs/bank.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package runs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
"textmachine/platform/internal/pgstore"
|
||||
"textmachine/platform/internal/runner"
|
||||
)
|
||||
|
||||
// bank.go: the platform half of the bank-correction door (D39.156 pack 2в) — the service behind
|
||||
// `POST /books/{bookId}/bank/corrections`. The call is SYNCHRONOUS by design: the engine's verb is
|
||||
// $0, exits rather than waits on the signing stop, and its 5000-decision cap was chosen so one call
|
||||
// fits a caller's timeout (backend/internal/pipeline/bankdecisions.go, maxDecisions) — so the
|
||||
// handler accepts the document, runs the verb, and answers its report as the receipt.
|
||||
//
|
||||
// What makes the synchronous form safe is one per-book rule, held HERE and shared with Start and
|
||||
// Resume: a corrections call and the spawn of a fresh run must not interleave on one book. The
|
||||
// engine's flock already makes the interleaving harmless — the loser refuses with class 12 — but a
|
||||
// spawned `translate` that dies on the flock is a wasted attempt with money machinery around it.
|
||||
//
|
||||
// The rule also decides which WORD each refusal carries, and the split is exact: `run_in_flight`
|
||||
// is answered from the platform's own run row (below, under the mutex), and ONLY from it; a class
|
||||
// 12 from the verb itself is by construction NOT a run — Start and Resume wait on this same mutex,
|
||||
// the reconciler only restarts rows the check below already sees — so it is a transient holder of
|
||||
// the project (a boundary materialization reading the book, an operator's manual tmctl) and is
|
||||
// answered «busy, retry later», never with a run that does not exist (PD-400, первая половина).
|
||||
//
|
||||
// ⚠ NAMED BOUNDARY OF v1: the mutex is in-process. A second platform replica over one store would
|
||||
// not see this one's calls, and the serialization narrows to per-replica: the cost is bounded and
|
||||
// is exactly the noise the mutex prevents — a translate spawned into the verb's flock dies with a
|
||||
// wasted attempt, a sibling verb answers «retry later». The boundary stops holding the day a second
|
||||
// replica is deployed (PD-400, вторая половина); the cure then is an arbiter in the store, not a
|
||||
// bigger mutex.
|
||||
|
||||
// BankCorrectionsInput is one validated correction document, in seam vocabulary. The HANDLER owns
|
||||
// the wire form and its validation; by this point the decisions are renderable as they stand.
|
||||
type BankCorrectionsInput struct {
|
||||
UserID string
|
||||
BookID string
|
||||
Preview bool
|
||||
Decisions []ingest.BankDecision
|
||||
}
|
||||
|
||||
// BankReceipt is the whole answer of an accepted call, still in the engine's vocabulary where the
|
||||
// two overlap — the projection into contract words (`depth`, pointers) is the HTTP layer's.
|
||||
type BankReceipt struct {
|
||||
Preview bool
|
||||
Changed bool
|
||||
Depth string
|
||||
Accepted []ingest.AcceptedDecision
|
||||
PreexistingFaults int
|
||||
// Signature is nil when no run has reached a signing stop yet — there is nothing to count
|
||||
// against (the report's empty `map`).
|
||||
Signature *ingest.SignatureState
|
||||
}
|
||||
|
||||
// ErrBankRefused is the all-or-nothing refusal: the document was read and declined whole, nothing
|
||||
// was written, and the USER re-decides. The per-decision reasons ride the error.
|
||||
type ErrBankRefused struct {
|
||||
Refusals []ingest.RejectedDecision
|
||||
}
|
||||
|
||||
func (e *ErrBankRefused) Error() string {
|
||||
return fmt.Sprintf("runs: the correction document was refused whole (%d reasons)", len(e.Refusals))
|
||||
}
|
||||
|
||||
// ErrBankIncomplete — the document was ACCEPTED and did not land whole (the engine's class 15). The
|
||||
// remedy is to re-send the SAME document: the retry converges on the engine's byte no-op.
|
||||
var ErrBankIncomplete = errors.New("runs: the correction write did not complete; re-sending the same document converges")
|
||||
|
||||
// ErrBankUnavailable — the deployment cannot serve this call RIGHT NOW and the remedy is neither a
|
||||
// re-decision nor a blind resend: an unmigrated project schema, a refusal class this build has no
|
||||
// number for, a stop that landed mid-call. Temporary from the caller's seat; the operator has the
|
||||
// log line.
|
||||
var ErrBankUnavailable = errors.New("runs: the correction door cannot answer right now")
|
||||
|
||||
// ErrBankDocumentTooLarge — the RENDERED decision document is over the engine's byte ceiling
|
||||
// (ingest.MaxDecisionsDocument). The canon's word for the size bound is 413 «split the document»,
|
||||
// and it must be said before the verb is spawned: the engine's own copy of this cap answers as a
|
||||
// refusal, which reads «re-decide».
|
||||
var ErrBankDocumentTooLarge = errors.New("runs: the rendered decision document is over the engine's byte ceiling")
|
||||
|
||||
// ApplyBankCorrections runs one correction document against a book, synchronously.
|
||||
func (s *Service) ApplyBankCorrections(ctx context.Context, in BankCorrectionsInput) (BankReceipt, error) {
|
||||
if s.Cfg.EngineBinary == "" {
|
||||
// Unreachable through the mounted route — the door is not mounted without an engine — and
|
||||
// still answered rather than assumed: a read replica reached directly must not panic.
|
||||
return BankReceipt{}, errors.New("runs: this deployment has no engine to apply corrections with")
|
||||
}
|
||||
// The budget covers the WAIT as well as the verb: it is set before the queue, so K callers piled
|
||||
// on one book cost each of them at most one budget, not K of them — and a caller whose client or
|
||||
// deadline is gone leaves the queue instead of holding a place in it to do work for a dead
|
||||
// request (workflow finding, P9).
|
||||
//
|
||||
// ⚠ The budget is the sweep's own TM_PLATFORM_RUN_BUDGET knob, and the engine sized its
|
||||
// 5000-decision cap against the SHIPPED 60 s — a maximum document measures ~11.3 s of
|
||||
// uninterruptible fold (backend bankdecisions.go, maxDecisions). An operator lowering the knob
|
||||
// toward that figure makes a legal maximum document permanently unservable: every retry answers
|
||||
// 503 and the «split it» word never comes (PD-408 carries this as the knob's named cost).
|
||||
ctx, cancel := context.WithTimeout(ctx, s.runBudget())
|
||||
defer cancel()
|
||||
// The book lock FIRST, the live-run check UNDER it: read before the lock, the answer could be
|
||||
// a moment from wrong — a resume admitted while this call waited would spawn into the verb's
|
||||
// flock. Serialized, either this call finishes before the spawn happens, or the run row exists
|
||||
// and the refusal below names it truthfully.
|
||||
unlock, err := s.lockBook(ctx, in.BookID)
|
||||
if err != nil {
|
||||
// The queue outlived the caller's budget or the caller itself: «busy, retry later» is the
|
||||
// honest word — nothing was read, spawned or written.
|
||||
s.log().InfoContext(ctx, "a correction call left the book queue before its turn", "err", err)
|
||||
return BankReceipt{}, ErrBankUnavailable
|
||||
}
|
||||
defer unlock()
|
||||
book, err := s.Store.ReadBookForRun(ctx, in.UserID, in.BookID)
|
||||
if err != nil {
|
||||
return BankReceipt{}, err
|
||||
}
|
||||
if !readyToTranslate(book.Status) {
|
||||
// The same gate Start holds, for the same reason wearing this door's hat: a book still
|
||||
// arriving or being cut has no configuration the verb could open — reaching the engine
|
||||
// anyway came back as its config class, which the caller saw as a 500 about our own wiring,
|
||||
// and the parse worker's transient hold on the project could even dress it as a run
|
||||
// (refuter finding, P9). The remedy is «wait for the intake», which is what book_not_ready
|
||||
// says and internal_error does not.
|
||||
return BankReceipt{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status)
|
||||
}
|
||||
if book.HasLiveRun && !book.LiveRunAwaitingBank {
|
||||
// The book is being translated (admitted, queued or running). A signing stop is NOT this —
|
||||
// but its row closes one sweep LATER than its screen opens: the journal's bank_stop event
|
||||
// moves the status to `awaiting_bank` while `finished_at` waits for the exit marker
|
||||
// (reconcile.finish), so for that window the row is live and the flock is already free or
|
||||
// about to be. Refusing run_in_flight there refused the very screen the platform had just
|
||||
// announced (workflow finding, P9) — the door opens on it instead, and if the engine is
|
||||
// still winding down, its flock answers class 12: an honest transient «retry later».
|
||||
return BankReceipt{}, pgstore.ErrRunInFlight
|
||||
}
|
||||
doc, err := ingest.EncodeDecisions(in.BookID, in.Decisions)
|
||||
if err != nil {
|
||||
return BankReceipt{}, err
|
||||
}
|
||||
if len(doc) > ingest.MaxDecisionsDocument {
|
||||
// The engine's byte ceiling, measured HERE on the RENDERED document — the only form the
|
||||
// engine will see — so its cap is never reached and the canon's 413 word is kept: the
|
||||
// engine's own copy answers as a refusal of the set, which reads «re-decide» (workflow
|
||||
// finding, P9). With HTML escaping off the band above the wire cap is the envelope's few
|
||||
// bytes, and this gate is what keeps that band from answering with the wrong remedy.
|
||||
return BankReceipt{}, ErrBankDocumentTooLarge
|
||||
}
|
||||
path, cleanup, err := s.decisionsFile(doc)
|
||||
if err != nil {
|
||||
return BankReceipt{}, err
|
||||
}
|
||||
defer cleanup()
|
||||
out, err := s.Bank.BankApply(ctx, s.Cfg.EngineBinary, book.Workdir, path, in.Preview)
|
||||
if err != nil {
|
||||
s.log().ErrorContext(ctx, "the correction verb could not be run", "err", err)
|
||||
if ctx.Err() != nil {
|
||||
// The budget or the caller ended the call — and reaching THIS branch means the verb did
|
||||
// NOT exit on its own (a SIGTERM the engine catches comes back as exit 5 through the
|
||||
// verdict, with res.Exited true): the process was SIGKILLed after bankStopGrace, or
|
||||
// never started. A kill can land between the verb's two renames — exactly the
|
||||
// half-landed pair class 15 exists to report — but there is no report to carry that
|
||||
// word, so the honest remainder is «retry later»: a re-send converges on the engine's
|
||||
// byte no-op from either state (PD-407 keeps the imprecision).
|
||||
return BankReceipt{}, ErrBankUnavailable
|
||||
}
|
||||
return BankReceipt{}, err
|
||||
}
|
||||
rec, err := s.bankVerdict(ctx, in, out)
|
||||
var refused *ErrBankRefused
|
||||
if errors.As(err, &refused) {
|
||||
// The engine's cap refusals echo the document's PATH — this platform's own temp file. File
|
||||
// paths are server topology and do not cross the wire (the same rule that keeps the report's
|
||||
// `files` off it); the reason survives, the address does not.
|
||||
for i := range refused.Refusals {
|
||||
refused.Refusals[i].Reason = strings.ReplaceAll(refused.Refusals[i].Reason, path, "the decision document")
|
||||
}
|
||||
}
|
||||
return rec, err
|
||||
}
|
||||
|
||||
// bankVerdict maps the verb's exit and report onto the door's answers. The mapping of each class is
|
||||
// the contract's or argued in the zone journal (pack P9); what this function must never do is let
|
||||
// two different remedies reach the caller as one word.
|
||||
func (s *Service) bankVerdict(ctx context.Context, in BankCorrectionsInput, out runner.BankApplyOutcome) (BankReceipt, error) {
|
||||
fail := func(what string) (BankReceipt, error) {
|
||||
s.log().ErrorContext(ctx, "the correction verb answered outside its contract",
|
||||
"what", what, "exit", out.ExitCode, "stderr", out.Stderr, "decode_err", out.DecodeErr)
|
||||
return BankReceipt{}, fmt.Errorf("runs: bank-apply: %s (exit %d)", what, out.ExitCode)
|
||||
}
|
||||
switch out.ExitCode {
|
||||
case ingest.ExitClean:
|
||||
if !out.Decoded {
|
||||
// Exit 0 always prints the report; a clean exit without one is not the engine.
|
||||
return fail("a clean exit carried no report")
|
||||
}
|
||||
return s.bankReceipt(in, out.Report)
|
||||
case ingest.ExitDecisionsRejected:
|
||||
if !out.Decoded || len(out.Report.Rejected) == 0 {
|
||||
// The DECISIONS class prints a report in every spelling, the caps included, and a refusal
|
||||
// names its reasons — that is the class's own contract (pipeline.ApplyBankDecisions). A
|
||||
// 409 whose `refusals` came out empty would violate the canon's own minItems.
|
||||
return fail("a refusal carried no report or no reasons")
|
||||
}
|
||||
return BankReceipt{}, &ErrBankRefused{Refusals: out.Report.Rejected}
|
||||
case ingest.ExitWriteIncomplete:
|
||||
// The report says which file landed; that truth is server-side. What the caller needs is
|
||||
// the remedy, and it is the same whether nothing landed or half did.
|
||||
s.log().ErrorContext(ctx, "a correction write did not complete; the client re-sends the same document",
|
||||
"stderr", out.Stderr)
|
||||
return BankReceipt{}, ErrBankIncomplete
|
||||
case ingest.ExitProjectLocked:
|
||||
// NOT run_in_flight on THIS replica: a live run was excluded under the mutex before the verb
|
||||
// was spawned (ApplyBankCorrections), and no path of this process admits one while it is held
|
||||
// — so the holder is transient (a boundary materialization, an operator's manual tmctl, the
|
||||
// stopped run's engine still winding down) and «busy, retry later» is honest. ⚠ Across the v1
|
||||
// boundary above the word CAN be wrong: a sibling replica's Start sees its own mutex and a
|
||||
// clean runs table, and its translate — a real, hours-long run — wins this flock; the honest
|
||||
// word there would be run_in_flight (PD-400, вторая половина: the accepted risk INCLUDES this
|
||||
// false word, not just a wasted attempt).
|
||||
s.log().ErrorContext(ctx, "corrections met a held project with no live run: a transient holder of the book's flock",
|
||||
"stderr", out.Stderr)
|
||||
return BankReceipt{}, ErrBankUnavailable
|
||||
case ingest.ExitSchemaMismatch:
|
||||
// The book's project predates the deployed engine: `tmctl migrate` is owed (deploy order,
|
||||
// row 174). An operator's condition, not the user's — and temporary, which 503 says and a
|
||||
// 500 would not.
|
||||
s.log().ErrorContext(ctx, "corrections refused: the book's project schema is not the engine's; it awaits tmctl migrate",
|
||||
"stderr", out.Stderr)
|
||||
return BankReceipt{}, ErrBankUnavailable
|
||||
case ingest.ExitStopped:
|
||||
// The verb was wound down before it wrote anything (its own SIGTERM contract) — a deploy
|
||||
// restart from the caller's seat. Retrying later converges either way.
|
||||
s.log().ErrorContext(ctx, "the correction verb was stopped mid-call", "stderr", out.Stderr)
|
||||
return BankReceipt{}, ErrBankUnavailable
|
||||
case ingest.ExitConfigInvalid, ingest.ExitSourceUnreadable:
|
||||
// Named BEFORE the band catch below: these are known classes whose fault sits on THIS side
|
||||
// of the seam — the platform renders both of the verb's inputs — so they are an internal
|
||||
// failure, not a "retry later" that would never come true.
|
||||
return fail("the verb refused its caller's own wiring")
|
||||
}
|
||||
if ingest.Refused(out.ExitCode) {
|
||||
// A refusal class this build has no mapping for (19 included): nothing was spent or written,
|
||||
// but WHICH remedy applies is exactly what an unknown number cannot say — so the answer is
|
||||
// the operator's pair of hands, not a guessed one of the other two.
|
||||
s.log().ErrorContext(ctx, "corrections met a refusal class this build cannot map",
|
||||
"exit", out.ExitCode, "stderr", out.Stderr)
|
||||
return BankReceipt{}, ErrBankUnavailable
|
||||
}
|
||||
// Exit 1, the config class (10 — this platform renders both of the verb's inputs, so a broken
|
||||
// one is OUR wiring), 11, and anything else: the deployment is what needs fixing.
|
||||
return fail("an exit outside the door's contract")
|
||||
}
|
||||
|
||||
// bankReceipt shapes the report of an accepted call. The report's own preview echo is `mode`; it is
|
||||
// cross-checked against what was asked rather than trusted, because the receipt of an APPLY served
|
||||
// for a preview would tell a user their correction landed when nothing did.
|
||||
func (s *Service) bankReceipt(in BankCorrectionsInput, rep ingest.BankReport) (BankReceipt, error) {
|
||||
wantMode := "apply"
|
||||
if in.Preview {
|
||||
wantMode = "projection"
|
||||
}
|
||||
if rep.Mode != wantMode {
|
||||
return BankReceipt{}, fmt.Errorf("runs: bank-apply answered mode %q to a %q call", rep.Mode, wantMode)
|
||||
}
|
||||
out := BankReceipt{
|
||||
Preview: in.Preview, Changed: rep.Changed, Depth: rep.Depth,
|
||||
Accepted: rep.Accepted,
|
||||
PreexistingFaults: len(rep.PreexistingProblems),
|
||||
}
|
||||
if rep.Signature.Map != "" {
|
||||
sig := rep.Signature
|
||||
out.Signature = &sig
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SweepCorrectionScratch removes decision documents an unclean death left behind: cleanup rides a
|
||||
// defer and dies with the process (SIGKILL, OOM, a deploy's expired grace), nothing else deletes
|
||||
// by this mask, and each orphan carries up to a megabyte of a user's own corrections sitting in
|
||||
// the state directory indefinitely (workflow finding, P9). Called once at boot, before the door
|
||||
// serves — a file present THEN belongs to no live call by definition.
|
||||
func (s *Service) SweepCorrectionScratch() {
|
||||
stale, err := filepath.Glob(filepath.Join(s.Cfg.StateDir, "bank-corrections-*.json"))
|
||||
if err != nil || len(stale) == 0 {
|
||||
return
|
||||
}
|
||||
for _, f := range stale {
|
||||
_ = os.Remove(f)
|
||||
}
|
||||
s.log().Info("swept correction documents an earlier process left behind", "count", len(stale))
|
||||
}
|
||||
|
||||
// decisionsFile puts the rendered document where the engine can read it — the platform's OWN state
|
||||
// directory, never the book's (D39.110: the engine owns that one).
|
||||
func (s *Service) decisionsFile(doc []byte) (string, func(), error) {
|
||||
if err := os.MkdirAll(s.Cfg.StateDir, 0o750); err != nil {
|
||||
return "", nil, fmt.Errorf("runs: state directory: %w", err)
|
||||
}
|
||||
f, err := os.CreateTemp(s.Cfg.StateDir, "bank-corrections-*.json")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("runs: write the decision document: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.Remove(f.Name()) }
|
||||
if _, err := f.Write(doc); err != nil {
|
||||
_ = f.Close()
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("runs: write the decision document: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("runs: write the decision document: %w", err)
|
||||
}
|
||||
return f.Name(), cleanup, nil
|
||||
}
|
||||
|
||||
// lockBook serializes this book's admissions, resumes and corrections against each other, and
|
||||
// watches the caller's context while it WAITS: the critical sections are one engine verb or one
|
||||
// database transaction, but the WAIT for them is only bounded when a caller whose deadline or
|
||||
// client is gone can leave the queue — a sync.Mutex cannot be waited on under a context, and with
|
||||
// one the K-th caller held its place for K budgets and then did the work for a dead request
|
||||
// (workflow finding, P9). A one-slot channel is that same mutex, waitable.
|
||||
func (s *Service) lockBook(ctx context.Context, bookID string) (unlock func(), err error) {
|
||||
s.booksMu.Lock()
|
||||
if s.books == nil {
|
||||
s.books = make(map[string]*bookLock)
|
||||
}
|
||||
l := s.books[bookID]
|
||||
if l == nil {
|
||||
l = &bookLock{slot: make(chan struct{}, 1)}
|
||||
s.books[bookID] = l
|
||||
}
|
||||
l.refs++
|
||||
s.booksMu.Unlock()
|
||||
release := func() {
|
||||
s.booksMu.Lock()
|
||||
if l.refs--; l.refs == 0 {
|
||||
delete(s.books, bookID) // books are unbounded over a process's life; idle locks are not kept
|
||||
}
|
||||
s.booksMu.Unlock()
|
||||
}
|
||||
select {
|
||||
case l.slot <- struct{}{}:
|
||||
return func() { <-l.slot; release() }, nil
|
||||
case <-ctx.Done():
|
||||
release()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
type bookLock struct {
|
||||
slot chan struct{} // capacity 1: the token in it IS the lock
|
||||
refs int
|
||||
}
|
||||
389
platform/internal/runs/bank_test.go
Normal file
389
platform/internal/runs/bank_test.go
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
package runs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
"textmachine/platform/internal/pgstore"
|
||||
"textmachine/platform/internal/runner"
|
||||
)
|
||||
|
||||
// The correction door's service half (bank.go): the verdict over the engine's exit band, and the
|
||||
// per-book serialization against the run lifecycle.
|
||||
|
||||
type fakeBankApplier struct {
|
||||
mu sync.Mutex
|
||||
out runner.BankApplyOutcome
|
||||
err error
|
||||
calls int
|
||||
lastDoc []byte
|
||||
// entered/release, when set, gate the call so a test can hold the verb mid-flight.
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (f *fakeBankApplier) BankApply(_ context.Context, _, _, decisionsPath string, _ bool) (runner.BankApplyOutcome, error) {
|
||||
f.mu.Lock()
|
||||
f.calls++
|
||||
f.lastDoc, _ = os.ReadFile(decisionsPath)
|
||||
entered, release := f.entered, f.release
|
||||
f.mu.Unlock()
|
||||
if entered != nil {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
return f.out, f.err
|
||||
}
|
||||
|
||||
func okReport(mode string) ingest.BankReport {
|
||||
return ingest.BankReport{
|
||||
Version: ingest.DecisionsReportVersion, BookID: "bk", Mode: mode,
|
||||
Depth: ingest.DepthEditWave, Changed: true,
|
||||
Accepted: []ingest.AcceptedDecision{{Index: 0, Action: "decline", ID: "tm_1", Src: "蛊", State: "applied"}},
|
||||
}
|
||||
}
|
||||
|
||||
// The verdict table: every exit class of the verb lands on ITS remedy and no two remedies merge.
|
||||
func TestBankVerdictKeepsTheRemediesApart(t *testing.T) {
|
||||
svc := service(t, &fakeRunner{}, nil, time.Now())
|
||||
in := BankCorrectionsInput{BookID: "bk", Preview: false}
|
||||
refused := okReport("refused")
|
||||
refused.Rejected = []ingest.RejectedDecision{{Index: 0, Reason: "inert"}}
|
||||
cases := []struct {
|
||||
name string
|
||||
out runner.BankApplyOutcome
|
||||
want error
|
||||
}{
|
||||
{"refused = the user re-decides", runner.BankApplyOutcome{ExitCode: ingest.ExitDecisionsRejected, Exited: true, Report: refused, Decoded: true}, &ErrBankRefused{}},
|
||||
{"incomplete = re-send the same", runner.BankApplyOutcome{ExitCode: ingest.ExitWriteIncomplete, Exited: true, Report: okReport("write_incomplete"), Decoded: true}, ErrBankIncomplete},
|
||||
// Class 12 from the VERB is never a run: the live run was excluded under the mutex before the
|
||||
// spawn, so the holder is transient and the word is «retry later» — `run_in_flight` comes only
|
||||
// from the run row's own check (PD-400).
|
||||
{"a held project = a transient holder, retry later", runner.BankApplyOutcome{ExitCode: ingest.ExitProjectLocked, Exited: true}, ErrBankUnavailable},
|
||||
{"an unmigrated schema = the operator", runner.BankApplyOutcome{ExitCode: ingest.ExitSchemaMismatch, Exited: true}, ErrBankUnavailable},
|
||||
{"a stop mid-call = retry later", runner.BankApplyOutcome{ExitCode: ingest.ExitStopped, Exited: true}, ErrBankUnavailable},
|
||||
{"an unknown refusal class = the operator", runner.BankApplyOutcome{ExitCode: ingest.ExitRefusedOther, Exited: true}, ErrBankUnavailable},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := svc.bankVerdict(t.Context(), in, tc.out)
|
||||
var refusal *ErrBankRefused
|
||||
if wantRefusal := errors.As(tc.want, new(*ErrBankRefused)); wantRefusal {
|
||||
if !errors.As(err, &refusal) || len(refusal.Refusals) != 1 {
|
||||
t.Fatalf("got %v, want the refusal with its reasons", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// The config class and exit 1 are the deployment's own fault: an internal error, none of the
|
||||
// three user-facing remedies.
|
||||
for _, code := range []int{ingest.ExitFailure, ingest.ExitConfigInvalid, ingest.ExitSourceUnreadable} {
|
||||
_, err := svc.bankVerdict(t.Context(), in, runner.BankApplyOutcome{ExitCode: code, Exited: true})
|
||||
if err == nil || errors.Is(err, ErrBankUnavailable) || errors.Is(err, ErrBankIncomplete) ||
|
||||
errors.Is(err, pgstore.ErrRunInFlight) {
|
||||
t.Errorf("exit %d answered %v, want a plain internal failure", code, err)
|
||||
}
|
||||
}
|
||||
// A clean exit whose report did not arrive is not the engine — never a receipt.
|
||||
if _, err := svc.bankVerdict(t.Context(), in, runner.BankApplyOutcome{ExitCode: 0, Exited: true}); err == nil {
|
||||
t.Error("a clean exit without a report was believed")
|
||||
}
|
||||
// The report's mode is cross-checked against the asked act: an APPLY report for a preview call
|
||||
// would tell the user their correction landed when nothing may have.
|
||||
preview := in
|
||||
preview.Preview = true
|
||||
if _, err := svc.bankVerdict(t.Context(), preview,
|
||||
runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}); err == nil {
|
||||
t.Error("an apply-mode report was served as a preview receipt")
|
||||
}
|
||||
}
|
||||
|
||||
// The receipt's shaping: the signature block is null exactly when no stop exists to count against.
|
||||
func TestBankReceiptCarriesTheSignatureOnlyWhenAStopExists(t *testing.T) {
|
||||
svc := service(t, &fakeRunner{}, nil, time.Now())
|
||||
in := BankCorrectionsInput{BookID: "bk", Preview: false}
|
||||
rep := okReport("apply")
|
||||
rep.PreexistingProblems = []string{"a delta that would not load"}
|
||||
rec, err := svc.bankReceipt(in, rep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Signature != nil {
|
||||
t.Errorf("no stop yet, and the receipt counts against %+v", rec.Signature)
|
||||
}
|
||||
if rec.PreexistingFaults != 1 || rec.Depth != ingest.DepthEditWave || !rec.Changed {
|
||||
t.Errorf("receipt: %+v", rec)
|
||||
}
|
||||
rep.Signature = ingest.SignatureState{Map: "/x/y.mined-signature.yaml", Surfaces: 3, Undecided: 1}
|
||||
if rec, err = svc.bankReceipt(in, rep); err != nil || rec.Signature == nil || rec.Signature.Surfaces != 3 {
|
||||
t.Errorf("with a stop: %+v (%v)", rec, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Under a live run the door refuses at once with the run's own word — the verb is never spawned, so
|
||||
// a transient flock can never masquerade as a translation (canon: wait for the stop or the end).
|
||||
func TestCorrectionsRefuseWhileTheBookIsBeingTranslated(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
fake := &fakeBankApplier{out: runner.BankApplyOutcome{ExitCode: 0, Exited: true}}
|
||||
f.svc.Bank = fake
|
||||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
|
||||
UserID: "u1", BookID: f.bookID(t), Preview: true,
|
||||
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
|
||||
})
|
||||
if !errors.Is(err, pgstore.ErrRunInFlight) {
|
||||
t.Fatalf("corrections under a live run answered %v, want run_in_flight", err)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Error("the verb was spawned under a live run")
|
||||
}
|
||||
}
|
||||
|
||||
// The signing stop's row closes one sweep AFTER its screen opens: the journal's bank_stop event
|
||||
// moves the status to awaiting_bank while finished_at waits for the exit marker. The door must
|
||||
// open on that window — refusing run_in_flight there refuses the very screen the platform just
|
||||
// announced (workflow finding, P9). The case above pins the other side: a live row NOT at the
|
||||
// stop still refuses.
|
||||
func TestTheDoorOpensOnTheSigningStopWindow(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true,
|
||||
CeilingChapters: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &fakeBankApplier{
|
||||
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
|
||||
}
|
||||
fake.out.Report.BookID = f.bookID(t)
|
||||
f.svc.Bank = fake
|
||||
// What the sink writes when the engine reports the bank stop: the status moves and the run
|
||||
// stays LIVE, because the attempt has not ended yet (pgstore/sink.go, TypeBankStop).
|
||||
if _, err := f.store.Pool().Exec(f.ctx,
|
||||
`update runs set status = 'awaiting_bank' where id = $1`, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
|
||||
UserID: "u1", BookID: f.bookID(t), Preview: false,
|
||||
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the door refused the signing stop's own window: %v", err)
|
||||
}
|
||||
if fake.calls != 1 {
|
||||
t.Fatalf("the verb ran %d times, want 1", fake.calls)
|
||||
}
|
||||
if rec.Preview {
|
||||
t.Error("an apply came back as a preview")
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's byte ceiling is measured on the RENDERED document BEFORE the verb is spawned, and
|
||||
// the refusal is the size word (413 upstream), never the engine's «re-decide» class (workflow
|
||||
// finding, P9: with HTML escaping off the band above the wire cap is the envelope's few bytes,
|
||||
// and this gate is what answers it).
|
||||
func TestARenderedDocumentOverTheEngineCapIsRefusedBeforeTheSpawn(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
fake := &fakeBankApplier{}
|
||||
f.svc.Bank = fake
|
||||
_, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
|
||||
UserID: "u1", BookID: f.bookID(t), Preview: true,
|
||||
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1",
|
||||
Note: strings.Repeat("g", ingest.MaxDecisionsDocument)}},
|
||||
})
|
||||
if !errors.Is(err, ErrBankDocumentTooLarge) {
|
||||
t.Fatalf("an over-cap render answered %v, want ErrBankDocumentTooLarge", err)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Error("the verb was spawned for a document it cannot read")
|
||||
}
|
||||
}
|
||||
|
||||
// The happy path against a real store: the rendered document reaches the verb — versioned, with the
|
||||
// book's own id — and the receipt answers the report. A stranger's book stays a 404.
|
||||
func TestCorrectionsRenderTheSeamDocumentAndAnswerTheReceipt(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
rep := okReport("projection")
|
||||
rep.BookID = f.bookID(t)
|
||||
fake := &fakeBankApplier{out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: rep, Decoded: true}}
|
||||
f.svc.Bank = fake
|
||||
rec, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
|
||||
UserID: "u1", BookID: f.bookID(t), Preview: true,
|
||||
Decisions: []ingest.BankDecision{{Action: "decline", Src: "蛊"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rec.Preview || len(rec.Accepted) != 1 {
|
||||
t.Errorf("receipt: %+v", rec)
|
||||
}
|
||||
doc := string(fake.lastDoc)
|
||||
if !strings.Contains(doc, `"decisions_version":"tm-bank-decisions-v1"`) ||
|
||||
!strings.Contains(doc, `"book_id":"`+f.bookID(t)+`"`) {
|
||||
t.Errorf("the verb was handed %s", doc)
|
||||
}
|
||||
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
|
||||
UserID: "u2", BookID: f.bookID(t), Preview: true,
|
||||
Decisions: []ingest.BankDecision{{Action: "decline", Src: "蛊"}},
|
||||
}); !errors.Is(err, pgstore.ErrNoBook) {
|
||||
t.Errorf("a stranger's book answered %v, want ErrNoBook", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The serialization the synchronous form stands on: a resume does not re-open the run while the
|
||||
// verb is mid-flight on the same book — the spawned engine would die on the verb's flock, a wasted
|
||||
// attempt (§3.2 of the pack; the mirror half is the run_in_flight refusal above).
|
||||
func TestAResumeWaitsOutALiveCorrectionCall(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true,
|
||||
CeilingChapters: 100}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3",
|
||||
At: f.now.Add(time.Second)})
|
||||
fake := &fakeBankApplier{
|
||||
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
|
||||
entered: make(chan struct{}), release: make(chan struct{}),
|
||||
}
|
||||
fake.out.Report.BookID = f.bookID(t)
|
||||
f.svc.Bank = fake
|
||||
corrections := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
|
||||
UserID: "u1", BookID: f.bookID(t), Preview: false,
|
||||
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
|
||||
})
|
||||
corrections <- err
|
||||
}()
|
||||
<-fake.entered // the verb is mid-flight and the book's lock is held
|
||||
resumed := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.svc.Resume(f.ctx, "u1", runID)
|
||||
resumed <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-resumed:
|
||||
t.Fatalf("the resume completed (%v) while the correction verb held the book", err)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
close(fake.release)
|
||||
if err := <-corrections; err != nil {
|
||||
t.Fatalf("corrections: %v", err)
|
||||
}
|
||||
if err := <-resumed; err != nil {
|
||||
t.Fatalf("resume after the verb: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An idle book keeps no lock entry: the per-book map must not grow with every book a process ever
|
||||
// touched — and a waiter that LEAVES on its context must not leak its entry either.
|
||||
func TestTheBookLockTableDoesNotLeak(t *testing.T) {
|
||||
svc := service(t, &fakeRunner{}, nil, time.Now())
|
||||
unlock, err := svc.lockBook(t.Context(), "bk_1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := make(chan struct{})
|
||||
go func() {
|
||||
u, err := svc.lockBook(t.Context(), "bk_1")
|
||||
if err == nil {
|
||||
u()
|
||||
}
|
||||
close(second)
|
||||
}()
|
||||
unlock()
|
||||
<-second
|
||||
svc.booksMu.Lock()
|
||||
defer svc.booksMu.Unlock()
|
||||
if len(svc.books) != 0 {
|
||||
t.Errorf("%d lock entries survive their books", len(svc.books))
|
||||
}
|
||||
}
|
||||
|
||||
// A caller whose context ends while it WAITS leaves the queue with an error instead of holding a
|
||||
// place in it to do the work for a dead request — the wait, not just the hold, is bounded
|
||||
// (workflow finding, P9).
|
||||
func TestAWaiterWhoseContextEndsLeavesTheBookQueue(t *testing.T) {
|
||||
svc := service(t, &fakeRunner{}, nil, time.Now())
|
||||
unlock, err := svc.lockBook(t.Context(), "bk_1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
waited := make(chan error, 1)
|
||||
go func() {
|
||||
u, err := svc.lockBook(ctx, "bk_1")
|
||||
if err == nil {
|
||||
u()
|
||||
}
|
||||
waited <- err
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-waited:
|
||||
if err == nil {
|
||||
t.Fatal("a cancelled waiter took the lock anyway")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("a cancelled waiter is still in the queue")
|
||||
}
|
||||
unlock()
|
||||
svc.booksMu.Lock()
|
||||
defer svc.booksMu.Unlock()
|
||||
if len(svc.books) != 0 {
|
||||
t.Errorf("%d lock entries survive the cancelled waiter", len(svc.books))
|
||||
}
|
||||
}
|
||||
|
||||
// Only the book's LATEST run may be resumed: an older one re-opened over a newer would open its
|
||||
// bar on the neighbour's finished chapters, and the whole read surface (lastRun by started_at)
|
||||
// would keep quoting the finished neighbour while the resumed run burns money invisibly (workflow
|
||||
// finding, P9; PD-402 keeps the read half).
|
||||
func TestAnOlderRunCannotBeResumedOverANewerOne(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
older := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0,
|
||||
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
|
||||
later := f.now.Add(time.Minute)
|
||||
f.svc.Now = func() time.Time { return later }
|
||||
newer := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0,
|
||||
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: later.Add(time.Second)})
|
||||
if _, err := f.svc.Resume(f.ctx, "u1", older); !errors.Is(err, ErrNotResumable) {
|
||||
t.Fatalf("resuming an older run answered %v, want ErrNotResumable", err)
|
||||
}
|
||||
if _, err := f.svc.Resume(f.ctx, "u1", newer); err != nil {
|
||||
t.Fatalf("the latest run must stay resumable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Boot sweeps decision documents an unclean death left in the state directory — nothing else
|
||||
// deletes by that mask, and each carries up to a megabyte of a user's own corrections (workflow
|
||||
// finding, P9; PD-409). Files outside the mask are not the sweep's to touch.
|
||||
func TestBootSweepsOrphanedCorrectionDocuments(t *testing.T) {
|
||||
svc := service(t, &fakeRunner{}, nil, time.Now())
|
||||
orphan := filepath.Join(svc.Cfg.StateDir, "bank-corrections-123.json")
|
||||
stranger := filepath.Join(svc.Cfg.StateDir, "run-1.exit")
|
||||
for _, p := range []string{orphan, stranger} {
|
||||
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
svc.SweepCorrectionScratch()
|
||||
if _, err := os.Stat(orphan); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("the orphaned decision document survived the boot sweep")
|
||||
}
|
||||
if _, err := os.Stat(stranger); err != nil {
|
||||
t.Error("the sweep touched a file outside its mask")
|
||||
}
|
||||
}
|
||||
|
|
@ -1208,6 +1208,34 @@ func (s *Service) Resume(ctx context.Context, userID, runID string) (pgstore.Run
|
|||
if err := s.runnable(); err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
// The book's own serialization (bank.go): a resume must not re-open the run while the correction
|
||||
// door is mid-verb on this book — the spawned engine would die on the verb's flock, a wasted
|
||||
// attempt. Held through the re-open so the door's own live-run check reads a settled fact; the
|
||||
// caller's context bounds the wait.
|
||||
unlock, err := s.lockBook(ctx, l.BookID)
|
||||
if err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
defer unlock()
|
||||
// Only the book's LATEST run may be resumed. An older stopped run re-opened over a newer one
|
||||
// counts the newer run's finished chapters into its own bar (its baselines predate them, and
|
||||
// the clamp bounds the fraction at one, not the double count under it) — and because every
|
||||
// book-scoped read resolves the run by `started_at desc`, the card and every progress frame
|
||||
// would keep quoting the finished neighbour while this run burned money invisibly (workflow
|
||||
// finding, P9; PD-402 keeps the read-side half). Checked under the book lock, so a Start
|
||||
// admitted while this call waited cannot slip a newer row past it. The two shapes carry their
|
||||
// own words: a LIVE newer run is «the book is being translated» — the same answer every door
|
||||
// gives a working book — and only a finished one says «resume the latest».
|
||||
latest, live, err := s.Store.LatestRun(ctx, l.BookID)
|
||||
if err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
if latest != l.RunID {
|
||||
if live {
|
||||
return pgstore.Run{}, pgstore.ErrRunInFlight
|
||||
}
|
||||
return pgstore.Run{}, fmt.Errorf("%w: a newer run of this book exists, and the book's screens follow that one", ErrNotResumable)
|
||||
}
|
||||
switch l.Status {
|
||||
case "stopped":
|
||||
case "awaiting_bank":
|
||||
|
|
|
|||
|
|
@ -304,6 +304,30 @@ func TestAStopTheUserAskedForOutranksACeilingThatArrivedWithIt(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// The configured key file reaches the UNIT's argv (row 211, the platform half of the live-run
|
||||
// blocker): the spec is what systemd executes, so this is the level at which "the engine gets its
|
||||
// keys" is either true or not.
|
||||
//
|
||||
// Mutation caught: dropping Cfg.KeysFile on the way into TranslateArgs.
|
||||
func TestTheSpawnedUnitCarriesTheDeploymentKeyFile(t *testing.T) {
|
||||
svc := service(t, &fakeRunner{}, nil, time.Now())
|
||||
svc.Cfg.KeysFile = "/etc/tm/keys.env"
|
||||
l := pgstore.LiveRun{RunID: "r1", AttemptNo: 1, Workdir: "/srv/books/bk1", Ceiling: 1}
|
||||
spec, err := svc.spec(l, money.MicroUSD(1_000_000))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
i := slices.Index(spec.Args, "--keys-file")
|
||||
if i < 0 || i+1 >= len(spec.Args) || spec.Args[i+1] != "/etc/tm/keys.env" {
|
||||
t.Errorf("the unit's argv carries no key file: %v", spec.Args)
|
||||
}
|
||||
for _, kv := range spec.Env {
|
||||
if strings.Contains(kv, "keys") || strings.Contains(kv, "KEY") {
|
||||
t.Errorf("a key-related variable reached the unit's environment: %q", kv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func service(t *testing.T, rn UnitRunner, eng EngineStatus, now time.Time) *Service {
|
||||
t.Helper()
|
||||
tpl, err := runner.ParseCeilingTemplate("--ceiling-usd {{usd}}")
|
||||
|
|
@ -681,7 +705,7 @@ func (f *fixture) owed(t *testing.T) []pgstore.OwedBook {
|
|||
//
|
||||
// Mutation caught: passing l.VerifyBank unconditionally.
|
||||
func TestAResumedRunIsSpawnedWithoutTheSigningStop(t *testing.T) {
|
||||
before := runner.TranslateArgs("/srv/books/bk1", true, nil)
|
||||
before := runner.TranslateArgs("/srv/books/bk1", true, "", nil)
|
||||
if !slices.Contains(before, "--verify-bank") {
|
||||
t.Fatal("a run that asked for the signing stop is spawned without it")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"textmachine/platform/internal/ingest"
|
||||
|
|
@ -35,6 +36,13 @@ type EngineStatus interface {
|
|||
Status(ctx context.Context, binary, workdir string) (ingest.StatusReport, error)
|
||||
}
|
||||
|
||||
// BankApplier is the correction channel: `tmctl bank-apply` against a book (D39.156 pack 2в). An
|
||||
// interface for the same reason EngineStatus is one: the door's decisions are pinned without an
|
||||
// engine binary, and a test that wants the real verb is visibly a different test.
|
||||
type BankApplier interface {
|
||||
BankApply(ctx context.Context, binary, workdir, decisionsPath string, preview bool) (runner.BankApplyOutcome, error)
|
||||
}
|
||||
|
||||
// Config is everything the runner needs that an operator chooses.
|
||||
type Config struct {
|
||||
// StateDir is where exit markers live. Platform state, deliberately NOT the book's directory:
|
||||
|
|
@ -51,6 +59,10 @@ type Config struct {
|
|||
// unset variable takes the default — and then starting a run is refused rather than started under
|
||||
// the book's own limit.
|
||||
Ceiling runner.CeilingTemplate
|
||||
// KeysFile is the deployment's provider-key file, passed to `translate` as `--keys-file`
|
||||
// (row 211). The path is an engine argument: keys are never put into the unit's environment and
|
||||
// never pass through this process. Empty passes no flag.
|
||||
KeysFile string
|
||||
// MemoryMax and TasksMax bound ONE run's cgroup — the answer to PD-13.
|
||||
MemoryMax string
|
||||
TasksMax int
|
||||
|
|
@ -75,6 +87,7 @@ type Service struct {
|
|||
Store *pgstore.Store
|
||||
Runner UnitRunner
|
||||
Engine EngineStatus
|
||||
Bank BankApplier
|
||||
Pricing pricing.Model
|
||||
Queue Enqueuer
|
||||
Cfg Config
|
||||
|
|
@ -85,6 +98,11 @@ type Service struct {
|
|||
// resynced remembers when each run was last reconciled from status. In memory on purpose: it is
|
||||
// a rate limit, not a fact — losing it on restart costs one extra status call.
|
||||
resynced map[string]time.Time
|
||||
// books serializes one book's admissions, resumes and corrections against each other — the
|
||||
// per-book rule of the correction door (see bank.go, lockBook). In memory on purpose: the
|
||||
// sections it guards live inside one process's calls.
|
||||
booksMu sync.Mutex
|
||||
books map[string]*bookLock
|
||||
}
|
||||
|
||||
// Enqueuer hands a run to the queue.
|
||||
|
|
@ -221,6 +239,14 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro
|
|||
if err := s.runnable(); err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
// The book's own serialization (bank.go): a run admitted while the correction door is mid-verb
|
||||
// on this book would spawn a `translate` straight into the verb's flock — a wasted attempt. The
|
||||
// door's own budget bounds each queued call, and the caller's context bounds THIS wait.
|
||||
unlock, err := s.lockBook(ctx, in.BookID)
|
||||
if err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
defer unlock()
|
||||
book, err := s.Store.ReadBookForRun(ctx, in.UserID, in.BookID)
|
||||
if err != nil {
|
||||
return pgstore.Run{}, err
|
||||
|
|
|
|||
|
|
@ -156,14 +156,18 @@ func (s *Service) spec(l pgstore.LiveRun, bookCap money.MicroUSD) (runner.Spec,
|
|||
// The path this ATTEMPT is pinned to, which for a resume is the one the run started with
|
||||
// (row 139). Falling back to the configured path is for the first attempt, which has none yet.
|
||||
Binary: s.engineBinary(l),
|
||||
// ⚠ `--verify-bank` is NOT passed again once the stop has been lifted. That flag is what makes
|
||||
// the engine halt at the bank boundary, and it halts there whenever undecided terms remain —
|
||||
// its own words, "the stop clears once every proposed term is promoted or rejected". Resuming
|
||||
// with it still set re-spawned the run straight back into the same stop, so a signature moved
|
||||
// nothing. Dropping it takes the engine's auto path (pipeline/mining.go, D39.42 п.3), which is
|
||||
// the owner's model exactly: the run continues and the undecided rows ride into the bank marked
|
||||
// ⟨проверить⟩ (unified backlog row 191).
|
||||
Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, ceiling),
|
||||
// ⚠ `--verify-bank` is NOT passed again once the stop has been lifted. The reason is the
|
||||
// owner's model, not the engine's old re-halt behaviour: the stop exists to present what is
|
||||
// NEW, and after a lift this run has nothing new to present — the presentation memory (v16)
|
||||
// covers the map's clusters, so even a re-passed flag would auto-continue at the same
|
||||
// boundary. (An earlier edition of this comment quoted the D39.144-era contract — «the stop
|
||||
// clears once every proposed term is promoted or rejected» — which the memory made false,
|
||||
// and the reconciler's LiftBankStop guard built on that reading is what the P9 workflow
|
||||
// review found leaking; the basis is corrected here rather than inherited again.) Dropping
|
||||
// the flag takes the
|
||||
// engine's auto path (pipeline/mining.go, D39.42 п.3): the run continues and undecided
|
||||
// rows ride into the bank marked ⟨проверить⟩ (unified backlog row 191).
|
||||
Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, s.Cfg.KeysFile, ceiling),
|
||||
Workdir: l.Workdir,
|
||||
Env: engineEnv(engineStreamID(l.RunID, l.AttemptNo)),
|
||||
ExitMarker: marker,
|
||||
|
|
@ -262,9 +266,10 @@ func engineStreamID(runID string, attempt int) string {
|
|||
return pgstore.EngineStreamID(runID, attempt)
|
||||
}
|
||||
|
||||
// engineEnv is the environment of the run's unit. Provider keys are deliberately NOT here — the
|
||||
// engine reads them from the .env beside its own book.yaml, so they never pass through this process
|
||||
// — and the only thing this platform puts in it is the run's identity.
|
||||
// engineEnv is the environment of the run's unit. Provider keys are deliberately NOT here — they
|
||||
// reach the engine as the `--keys-file` ARGUMENT of `translate` (row 211, spec above), read by the
|
||||
// engine itself, so they never pass through this process or the unit's environment — and the only
|
||||
// thing this platform puts in it is the run's identity.
|
||||
func engineEnv(streamID string) []string { return []string{"TM_TRACE_ID=" + streamID} }
|
||||
|
||||
func (s *Service) markerPath(runID string, attempt int) string {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue