Files
houseplan-card/docs/reviews/SPEC-REVIEW-495-r1.md
T
2026-09-09 05:33:25 +00:00

266 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPEC-REVIEW-495-r1 — #495: результат Import согласован с commit; удаление маршрутов робота доходит до Store
- Issue: [#495](https://github.com/Matysh/houseplan-card/issues/495)
- Этап: `spec` (PROCESS.md §2.4)
- ТЗ под ревью: [`docs/specs/495-import-commit-and-route-runs-durability.md`](../specs/495-import-commit-and-route-runs-durability.md)
- Материал: HEAD `70a394d2` (detached), содержит коммит ТЗ
`70a394d2 docs: spec for #495 — import result follows the commit, dropped route runs reach the store`
- Заход: **r1**, блокирующих циклов израсходовано 0/4 (первый раунд — раздел
«Унаследовано из r0» не нужен)
- Трек: полный (issue корректно не помечен `small`; в issue названо, какой
критерий §5 не проходит — «одна поверхность»: два независимых
durability-контракта, Apply-эндпоинт и рекордер следов)
- Вердикт: **зелёный**
## Скоуп ревью
Диапазон `git diff abbca50c..70a394d2` (беспосредственно предыдущий коммит
`dev`) содержит только:
- `docs/specs/495-import-commit-and-route-runs-durability.md` (новый файл);
- вход в issue #495 не менялся сверх комментария S2-аналитики.
Продуктовый код (`custom_components/houseplan/**`), тесты, бандлы —
не тронуты. Это ожидаемо: этап `spec` меняет только документацию.
Прочитано до вердикта: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1–§9,
§12), тело issue #495 и комментарий S2-аналитики, `docs/VACUUM.md`,
`docs/ARCHITECTURE.md` (таблица WS-команд и раздел про crash-resumable
паттерн), сам код: `custom_components/houseplan/websocket_api.py` (обработчик
`ws_import_apply`, целиком блок `write_lock`…`send_result`),
`custom_components/houseplan/import_export.py` (`get_candidate`,
`create_preview`, лимиты вытеснения), `custom_components/houseplan/trails.py`
(`TrailBook.drop_unknown_routes`, `TrailRecorder.async_purge_orphans`,
`_async_delete_many`, `_refresh_lock`), три файла тестов (`test_trails.py`,
`test_trail_recorder.py`, `test_ha_websocket.py`, `test_ha_import_export.py`)
на предмет существования упомянутых в ТЗ тестов-образцов.
## Как проверялось
Ревью ТЗ, не код-ревью: гейты `npm test`/`npm run build`/`tsc`/`check-docs`
не запускались — диапазон не содержит изменений `src/**` или
`custom_components/**`, только новый файл в `docs/specs/`. Запускать их
означало бы проверять пустое множество.
Основная работа ревью — верификация каждого технического утверждения ТЗ
против реального кода, поскольку у полного трека (в отличие от `small`) риск
«описал не тот код» выше, а ТЗ делает много конкретных построчных утверждений
о `websocket_api.py` и `trails.py`.
| Проверка | Результат |
|---|---|
| Обязательные разделы ТЗ (PROCESS.md §7.1) | все присутствуют — таблица ниже |
| AC1–AC7: однозначность + способ доказательства | у каждого назван тест/раунд мутантов — см. «AC» ниже |
| Построчные утверждения о `websocket_api.py` / `import_export.py` / `trails.py` | сверено с кодом на `70a394d2` (эквивалентен `dev`) — см. «Верификация фактов» |
| Ссылки на связанные issue (#491, #335, #162, #265) | тематика совпадает; #491 закрыт (S8), не дублируется |
| Ссылки на существующие тесты-образцы | найдены все три — `test_import_apply_conflict_preserves_state_and_preview_token`, `test_success_events_are_emitted_only_after_both_target_writes`, `test_config_set_purges_tombstoned_and_absent_trails_durably` |
| Признак «догадка выдана за решение» без пометки | целевой поиск — не найдено, см. ниже |
| Продуктовые вопросы владельцу, не решённые самим ТЗ | не найдено — ТЗ решает всё внутри `§11` |
### Обязательные разделы ТЗ (PROCESS.md §7.1)
| Раздел | Есть | Где |
|---|---|---|
| Сценарий | да, две персоны/сценария (Import, Маршруты) | §1.1 |
| Что человек увидит до/после | да, одной фразой на каждую половину | §1.2 |
| Проблема | да, с построчными ссылками на код | §1 |
| Скоуп и не-скоуп | да | §2, §3 |
| Контракт поведения | да, раздельно по подсистемам | §4, §5 |
| UX | да («не затрагивается», обоснованно) | §8.1 |
| Модель данных и миграция | да («формат не меняется») | §7 |
| i18n | да (не затрагивается) | §8.1 |
| AC1…ACn с доказательством | да, 7 штук, у каждого назван тест | §8 |
| План автотестов | да, подробный, включая мутанты | §6 |
| Риски | да | §8.2 |
| Откат | да | §7 |
| Release-артефакты | да (changelog RU+EN) | §9 |
Все разделы на месте — расхождений с §7.1, требующих находки, нет.
### Верификация фактов
ТЗ утверждает поведение конкретных строк кода на `dev`; каждое из этих
утверждений проверено чтением того же кода на материале ревью, поскольку
неверное построчное утверждение — тот дефект, который должен убить полный
трек:
- **B2 (Import).** `ws_import_apply` (`websocket_api.py:526–596`): кандидат
берётся `get_candidate(rt, msg["token"], owner)` (без `consume`) в начале
под `write_lock` — подтверждено, строка 538. После
`await _commit_pair(rt, pending, rollback)` идёт
`get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)`
— подтверждено, строка 634 (текущий код), это и есть точка, которую §4.1 ТЗ
предлагает заменить на `rt.import_previews.pop(msg["token"], None)`. Между
`_commit_pair` и этим вызовом никакого другого выражения, способного
бросить `ImportFailure`, нет — заявление ТЗ «путь commit→ошибка становится
недостижимым по построению» после замены верно.
- **`get_candidate`** (`import_export.py:1999–2011`): при `consume=False` (на
входе) проверяет `expires`, `owner_id`, `digest`; при `consume=True` (после
commit, до правки) — те же проверки плюс безусловный `pop`. Подтверждает
§1 B2 буквально.
- **Вытеснение** (`import_export.py:1956–1965`): `mine` — токены владельца в
порядке вставки (Python dict сохраняет порядок), эвикшн `mine.pop(0)` —
действительно «старейший первым». Применяемый токен создан раньше нового
preview того же владельца (preview → потом apply), значит он вытесняется
первым при достижении лимита — тестовый план AC2 (создать
`MAX_IMPORT_PREVIEWS_PER_USER` новых preview тем же владельцем, чтобы
вытеснить применяемый токен) технически корректен.
- **PairCommitFailure / §4.2.** `_commit_pair` (websocket_api.py:384) бросает
`PairCommitFailure(recovery_pending=...)` при неудаче — до строки с `pop`,
которая находится уже после `await _commit_pair(...)`. Значит на пути
`PairCommitFailure` вызов `get_candidate(..., consume=True)`/новый `pop`
вообще не выполняется — заявление §4.2 «после `PairCommitFailure` токен
остаётся» верно без правки этого пути.
- **B3 (маршруты).** `TrailRecorder.async_purge_orphans`
(`trails.py:314–341`): цикл по живым маркерам вызывает
`effective_routes(...)` → `self.book.drop_unknown_routes(marker_id, ids)`,
результат (`bool`) никуда не сохраняется; запись Store происходит только
внутри `_async_delete_many(orphan_ids)`, и только если `orphan_ids`
непусто (`if not orphan_ids: return 0` — строка 338). Подтверждено
построчно; ровно то, что описывает §1 B3.
- **`asyncio.Lock` неренетерабелен**, и `_async_delete_many` уже сам берёт
`self._refresh_lock` (`trails.py:351`) — если бы `async_purge_orphans` тоже
взял этот лок на весь свой корпус (как требует §5.1) и затем вызвал
публичную `_async_delete_many` как есть, был бы deadlock. ТЗ (§5.1 п.4,
§8.2) корректно называет это и предписывает «внутреннюю версию без
повторного захвата lock» — риск замечен автором ТЗ, а не пропущен.
- **`drop_unknown_routes` возвращает `bool`, не снятые прогоны**
(`trails.py:152–172`, `rec.pop(slot)` без возврата значения) — §5.1
описывает НОВУЮ обёртку `self._drop_unknown_routes(config)` (метод
рекордера, не путать с методом книги того же почти-имени), которая обязана
сама снять копию `current`/`previous` до вызова `book.drop_unknown_routes`,
чтобы получить `{marker_id: {slot: run}}` для отката. §5.2 явно говорит, что
сигнатура `TrailBook.drop_unknown_routes` (включая `bool`) не меняется —
тест `test_drop_unknown_routes_touches_only_runs_that_name_a_route`
(`test_trails.py:195`) подтверждён существующим и совместимым.
- **Тесты-образцы существуют**: `test_import_apply_conflict_preserves_state_and_preview_token`
(`test_ha_import_export.py:2217`), `test_success_events_are_emitted_only_after_both_target_writes`
(`test_ha_import_export.py:2483`), `test_config_set_purges_tombstoned_and_absent_trails_durably`
(`test_ha_websocket.py:409`) — все три подтверждены существующими,
ссылки ТЗ корректны.
- **«Существующие тесты Apply без правок»**: тест на строке 1208
`test_ha_import_export.py` (`runtime.import_previews[expiring]["expires"] = 0`
→ `get_candidate(...)` бросает `preview_expired`) — это unit-тест на саму
функцию `get_candidate`, а не на обработчик `ws_import_apply`; правка
ТЗ не трогает `get_candidate`, только убирает её повторный вызов в
обработчике. Тест остаётся зелёным без правок, как и заявлено.
- **docs/VACUUM.md / docs/ARCHITECTURE.md уже описывают целевое поведение**
(§9 ТЗ): `VACUUM.md:98` — «changing the target space is a NEW route
identity … recorded runs … are dropped»; `ARCHITECTURE.md:1178` —
`houseplan/import/apply` описан как «crash-resumable paired config/layout
commit». Оба подтверждены; заявление «фикс приводит код к тексту, документы
не меняются» корректно.
- **Вызовы `_purge_trail_recorder`/`async_purge_orphans`** после каждого
config-commit — подтверждено 4 точками вызова
(`websocket_api.py:654,1757,2278`, `__init__.py:193`), согласуется с §1
«вызывается после каждого commit конфига».
Не найдено ни одного построчного утверждения о коде, которое разошлось бы с
материалом ревью.
### Догадка, выданная за решение — целевой поиск
Не найдено. Единственные более-менее «решательные» технические детали —
имя новой приватной обёртки `_drop_unknown_routes` и точный протокол отката
(что именно копируется для восстановления) — прямо не диктуют пользователю
видимое поведение и относятся к классу «агенты решают между собой»
(PROCESS.md §7.1); ТЗ не выдаёт их за нормативный факт о продукте, а
описывает как техническую реализацию контракта §5.1, который сам
сформулирован как обязательный результат («результат обязан дойти до
Store»), а не как гадание о существующем коде.
## Критерии приёмки (AC1–AC7)
Каждый пронумерован, для каждого назван ровно один тест или группа тестов —
проверяемость есть у всех:
| AC | Доказывается | Проверка ревьюера |
|---|---|---|
| AC1 | `test_issue_495_apply_result_follows_the_commit_when_the_preview_expires_meanwhile` (backend) | сценарий (обёртка над `_commit_pair`, `expires=0`) технически реализуем на верифицированном коде §4.1 |
| AC2 | `test_issue_495_apply_result_survives_eviction_by_a_newer_preview` (backend) | эвикшн-механизм подтверждён «старейший первым» — сценарий воспроизводим |
| AC3 | `test_issue_495_dropped_route_runs_reach_the_store_without_orphans` (backend/stub) | воспроизводимо на подтверждённом `_async_delete_many`/`drop_unknown_routes` |
| AC4 | `test_issue_495_dropped_route_runs_roll_back_when_the_store_write_fails` (backend/stub) | откат по образцу существующего отката в `_async_delete_many` (строки 373–383), симметрия описана в §5.1 п.4/5 |
| AC5 | `test_issue_495_dropped_route_runs_share_the_orphan_transaction` (backend/stub) | комбинация путей п.4/п.5 §5.1, не противоречит текущей структуре `_async_delete_many` |
| AC6 | новый тест `test_ha_websocket.py` по образцу `test_config_set_purges_tombstoned_and_absent_trails_durably` (backend/HA) | образец существует и подтверждён (строка 409) |
| AC7 | мутанты §6.3, `scripts/mutation-gate.mjs` + `backend-test-guard.mjs` | пять мутантов, у каждого назван ровно один свидетель-тест; отрицательный прогон — требование самого ТЗ (§6.3, «до перевода в S7») |
Ни один AC не оставляет открытого вопроса о способе доказательства.
## Соответствие SCOPE.md
Задача — про J6 («Keep the plan true as the home evolves»): расхождение
ответа/памяти с диском — именно та ложь плана, которую J6 запрещает. Both
half (Import-ответ и маршруты робота) относятся к этому job. Продуктового
расширения скоупа нет: §3 (не-скоуп) явно исключает алгоритмы импорта,
продление TTL, защиту токена до commit, #491-фехтование — все обоснованно, и
все совпадают с тем, что реально не нужно трогать по прочитанному коду.
## Что проверено и корректно
- Обязательные разделы §7.1 все присутствуют.
- Все 7 AC пронумерованы, однозначны, у каждого назван способ доказательства
и конкретный тест/группа тестов.
- Каждое построчное техническое утверждение о `websocket_api.py`,
`import_export.py`, `trails.py` подтверждено чтением кода на материале
ревью (см. «Верификация фактов») — ни одного расхождения.
- Технически рискованное место (расширение `_refresh_lock`,
неренетерабельность `asyncio.Lock`) не просто не упущено — ТЗ прямо
формулирует решение («внутренняя версия без повторного захвата lock») и
называет риск в §8.2.
- «Принятые предположения» (§11) — три штуки, все нормативно нейтральны
(не меняют видимое поведение), явно помечены как предположения, а не факт.
- Продуктовых вопросов владельцу, требующих ответа, не найдено — контракт
«результат определяется commit» и «удаление доходит до Store» не оставляет
места для персонального/UX-выбора; это баг-фикс с уже согласованным на
уровне документации (VACUUM.md, ARCHITECTURE.md) целевым поведением.
- Ссылки на #491, #335, #162, #265 — не дублируются, использованы как
прецеденты паттернов (откат памяти, crash-resumable commit).
- Откат (§7) полный: revert одного коммита, без миграции данных.
- Release-артефакты (§9): changelog RU+EN назван; изменение документации
подсистем корректно признано ненужным (текст уже соответствует целевому
поведению).
## Чего не проверял
- Гейты `npm test`/`npm run build`/`tsc`/`check-docs`/`python -m pytest
tests_backend` не запускались — диапазон ревью не содержит изменений кода
или тестов, только новый файл в `docs/specs/`; прогон был бы проверкой
пустого множества для этого раунда.
- Не проверялась реализуемость точного протокола отката §5.1 п.4/5
построчно как будущий diff (мутанты §6.3 и тесты §6 ещё не написаны) — это
по определению предмет `code`-этапа и будущего code-review-раунда этой же
задачи, а не ревью ТЗ.
- Не проверялось поведение `_drop_unknown_routes`-обёртки на конкурентных
вызовах `async_refresh` во время purge — сценарий не упомянут ни в ТЗ, ни в
issue как открытый риск, и `_refresh_lock` по конструкции сериализует оба
пути; отдельного продуктового риска здесь не просматривается.
- Мутанты и HA-харнесс AC6 не прогонялись (кода ещё нет) — асимметрично
этапу; будет предметом code-review.
## Вердикт
**Зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0 → в задаче.**
ТЗ полное по §7.1, каждый AC однозначен и доказуем, все построчные
технические утверждения о существующем коде подтверждены чтением того же
кода на материале ревью, риск неренетерабельности lock замечен и решён
самим автором ТЗ, продуктовых вопросов владельцу не осталось. Следующий
статус — «Готово к разработке» (`S5-ready`).
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/495-import-commit-and-route-runs-durability`, коммит `70a394d22821` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `76d0e202bdc79141e49c499710b7ae826be6b8d6`
```
git log --all --format='%H %T' | grep 76d0e202bdc7
```
- ТЗ `docs/specs/495-import-commit-and-route-runs-durability.md`, блоб `a6fb6bbe17da6cc4ca7579279397f40730c235fd`
```
git log --all --find-object=a6fb6bbe17da6cc4ca7579279397f40730c235fd -- docs/specs/495-import-commit-and-route-runs-durability.md
```
- Вердикт конвейера: `green` · High 0