Машина состояний: декларативный граф легальных переходов

Единый источник истины `allowedTransitions` (from → {разрешённые to}) в
internal/store; `setState` сверяет переход дополнительным SQL-предикатом
`state IN (<легальные источники>)` — необъявленное ребро (и не самопереход)
отклоняется атомарно, с точным сообщением. Гейт ортогонален гарду
терминальности: ребро из терминального состояния проходит только через
ActivateIfNoOtherActive. Без внешней библиотеки-FSM (обоснование — design.md).

Граф выведен построчно из воркера; ревью дизайна поймало 8 preflight-рёбер
(reconcileToReality → orphaned/deleted) и linking→cancel/defer после краха.
Тест-инвариант «cancel/defer достижимы из любого не-терминального» ловит класс
пропущенного ребра. Фикстуры тестов, форсившие состояния через SetDownloadState,
переведены на прямой UPDATE (forceState).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-08 09:09:17 +03:00
co-authored by Claude Opus 4.8
parent 0d263270cb
commit 90fd8640ed
10 changed files with 716 additions and 25 deletions
@@ -0,0 +1,50 @@
# Tasks
## 1. Граф в store
- [x] 1.1 Объявить `allowedTransitions map[State][]State` (from → to) в
`internal/store/download.go` рядом с `terminalStates`, с комментарием об источнике
истины и правиле самоперехода. Заполнить по таблице из `design.md`.
- [x] 1.2 Построить обратное отображение `to → {легальные from}` (для SQL-предиката)
как package-level `var` через хелпер-инвертор; включать сам `to` (самопереход).
## 2. Гейт в setState
- [x] 2.1 В `setState` добавить предикат `AND state IN (<легальные источники для to>)`
до/рядом с существующим гардом терминальности; аргументы через `placeholders`.
- [x] 2.2 На `n == 0` — диагностическое чтение текущего состояния (через
`sqlx.QueryerContext`, если `e` его поддерживает) для точного сообщения:
«not found» / «illegal transition <cur> → <to>» / терминал без revive.
- [x] 2.3 Сверить `PromoteCatched`: ребро `catched → downloading` присутствует в
графе; оставить его собственный гард `state='catched'`, добавить комментарий-ссылку
на граф.
## 3. Тест согласованности
- [x] 3.1 `TestTransitionGraphWellFormed`: все состояния в ключах и значениях графа —
известные (из полного списка `State`); ни один список не содержит сам ключ
(петли не перечисляются явно).
- [x] 3.2 `TestSetStateAllowsDeclaredEdges`: для набора объявленных не-revive рёбер
(`downloading→completed`, `review→linking`, `recognizing→review`, …)
`SetDownloadState` проходит.
- [x] 3.3 `TestSetStateRejectsUndeclaredEdges`: репрезентативные необъявленные
(`review→done`, `downloading→done`, `completed→linking`) отклоняются, состояние не
меняется.
- [x] 3.4 `TestSelfTransitionAllowed`: `deferred→deferred` проходит.
- [x] 3.5 `TestTerminalReviveOnlyViaActivate`: `failed→downloading` через
`SetDownloadState` отклоняется, а через `ActivateIfNoOtherActive` (при свободном
infohash) проходит.
- [x] 3.6 `TestCancelDeferReachableFromEveryNonTerminal`: инвариант generic-команд —
для каждого не-терминального состояния `cancelled` — легальная цель, и `deferred`
легальная цель (кроме самого `deferred`, где это самопереход). Ловит класс дыры
«забыли состояние» (напр. `linking` после краха).
- [x] 3.7 `TestPreflightDesyncEdges`: рёбра `reconcileToReality` из ревью/терминальных
состояний при пропавшем источнике — `review/deferred/reverted/cancelled →
orphaned` и `→ deleted` — проходят.
## 4. Проверка отсутствия регрессий
- [x] 4.1 `task test` — весь набор зелёный (существующие тесты воркера/store — сеть
безопасности против слишком тесного графа).
- [x] 4.2 `task lint` — 0 issues.
- [x] 4.3 `openspec validate state-transition-graph --strict`.