Compare commits

...
Author SHA1 Message Date
claude[bot] 5eef4346ca docs: review document for #368
Issue: #368
User-Visible: no
2026-08-29 09:36:29 +00:00
Codex 774388c2e5 docs: name the expected_rev requirement for external writers (#368)
#340/#356 made expected_rev mandatory for config/set and layout/set over a
non-empty store — an honest protection the release notes sold only as a
stale-tab guard. A third-party script writing plans directly cannot infer
from "protects from stale tabs" that it must now read the revision first.

Both changelogs gain an explicit breaking-for-external-writers entry with
the read-then-write recipe; ARCHITECTURE.md's WS contract section extends
the #340 paragraph with the cycle external clients must follow (get rev →
send expected_rev → on conflict re-read and retry); and both conflict
messages now carry the actionable hint for scripts — "include expected_rev
from houseplan/config/get / layout/get" — alongside the tab-oriented
"reload" advice. The backend tests pin only the "revision is required"
substring and stay untouched.

Issue: #368
User-Visible: yes
2026-08-29 12:29:33 +03:00
5 changed files with 122 additions and 5 deletions
+6 -4
View File
@@ -594,8 +594,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
connection.send_error(
msg["id"], "conflict",
f"Layout revision is required; reload the layout "
f"(current rev {current_rev})",
f"Layout revision is required; reload the layout, or — for "
f"external clients — include expected_rev from "
f"houseplan/layout/get (current rev {current_rev})",
)
return
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
@@ -1322,8 +1323,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
connection.send_error(
msg["id"], "conflict",
f"Configuration revision is required; reload the configuration "
f"(current rev {current_rev})",
f"Configuration revision is required; reload the configuration, "
f"or — for external clients — include expected_rev from "
f"houseplan/config/get (current rev {current_rev})",
)
return
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
+6 -1
View File
@@ -891,7 +891,12 @@ The wire schema permits omission only for the first empty-store bootstrap at
revision zero, so the endpoint can return the stable `conflict` domain error
instead of a generic format error. A revision-less write over `rev > 0` is
rejected under the same `write_lock` before validation, no-op detection,
backup cleanup, file collection or update events (#340).
backup cleanup, file collection or update events (#340). The same rule holds
for `layout/set` (#356). External writers (scripts, automations, custom
integrations) must therefore follow the read-then-write cycle the card uses:
call `houseplan/config/get` (or `layout/get`), keep the returned `rev`, and
send it back as `expected_rev`; a `conflict` answer means the document moved —
re-read and retry with the fresh revision (#368).
The normal frontend reaches `houseplan/plan/optimize` only after the exact
preview candidate passes `src/plan-geometry-preflight.ts`. That pure barrier
+10
View File
@@ -2,6 +2,16 @@
## Unreleased
- Breaking for external writers (scripts, automations, custom integrations
that write plans directly): `houseplan/config/set` and `houseplan/layout/set`
over a non-empty store now require `expected_rev` and answer `conflict`
without it. Read the current `rev` from `houseplan/config/get` /
`houseplan/layout/get` first; the card itself has sent the revision since
v1.60 and is unaffected
([#340](https://github.com/Matysh/houseplan-card/issues/340),
[#356](https://github.com/Matysh/houseplan-card/issues/356),
[#368](https://github.com/Matysh/houseplan-card/issues/368)).
- A gate or door bound to a position-reporting cover no longer stutters the
whole card while it moves: the light cut through the opening now steps on a
5% grid, cutting the heavy geometry recomputes from about a hundred per
+10
View File
@@ -8,6 +8,16 @@
## Не выпущено
- Ломающее для внешних клиентов (скрипты, автоматизации, кастомные
интеграции, пишущие планы напрямую): `houseplan/config/set` и
`houseplan/layout/set` поверх непустого хранилища теперь требуют
`expected_rev` и без него отвечают `conflict`. Сначала прочитайте текущий
`rev` через `houseplan/config/get` / `houseplan/layout/get`; сама карточка
шлёт ревизию с v1.60 и не затронута
([#340](https://github.com/Matysh/houseplan-card/issues/340),
[#356](https://github.com/Matysh/houseplan-card/issues/356),
[#368](https://github.com/Matysh/houseplan-card/issues/368)).
- Ворота или дверь на cover-сущности с позицией больше не дёргают карточку
во время движения: световой вырез проёма ступает по сетке 5%, и тяжёлых
пересчётов геометрии вместо ~сотни за цикл — не больше двадцати
+90
View File
@@ -0,0 +1,90 @@
# CODE-REVIEW-368-r1
- Issue: [#368](https://github.com/Matysh/houseplan-card/issues/368)
- Трек: `trivial` (короткий трек, §5.1 PROCESS.md) — S2-analysis → S5-ready, AC зафиксированы в теле issue при переходе.
- Ветка: `issue/368-expected-rev-docs`
- SHA материала ревью: `774388c2e573af3043ead8995358c2930a44c00f` (единственный коммит диапазона `origin/dev..HEAD`, совпадает с SHA, названным автором в хендофф-комментарии)
- Заход: r1 · блокирующих циклов израсходовано 0/2 (лимит короткого трека)
## Скоуп
Диапазон `git diff origin/dev...HEAD` — 4 файла, только правки текста, поведение не меняется:
```
custom_components/houseplan/websocket_api.py | 10 ++++++----
docs/ARCHITECTURE.md | 7 ++++++-
docs/CHANGELOG.md | 10 ++++++++++
docs/CHANGELOG.ru.md | 10 ++++++++++
4 files changed, 32 insertions(+), 5 deletions(-)
```
Класс изменений: A (одна строка кода в `websocket_api.py` — текст f-string) + C (документация/changelog). Задача сама по себе — исправление задокументированности уже введённого в #340/#356 требования `expected_rev`, без изменения логики. AC из тела issue:
1. Оба changelog содержат breaking-абзац со ссылками на #340/#356.
2. Документация WS-команд (если файл существует) описывает требование и пример цикла для внешних клиентов.
3. `conflict`-ответ несёт actionable-подсказку (или зафиксировано, что уже несёт).
Продуктовая рамка (`docs/SCOPE.md`): задача не добавляет и не убирает пользовательскую функциональность — она делает уже принятое решение (#340/#356, безопасность записи через `expected_rev`) видимым для внешних интеграторов, что соответствует J6 («Keep the plan true as the home evolves» — optimistic locking) и не создаёт нового отдельного user job. Вне скоупа: сама механика CAS-проверки не меняется и не пересматривается.
## Как проверялось
Гейты прогнаны локально (зелёного Validate на этом SHA не найдено — прогон делаю сам, набор дешёвый):
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` (запускался также как часть `npm run build`) | OK, 0 ошибок |
| Unit-тесты (JS/TS) | `npm test` | `# tests 1538 / pass 1537 / fail 0 / skipped 1` — зелёный |
| Build + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | build OK; `cmp` — байт-в-байт совпадение (frontend не менялся, поэтому бандл идентичен) |
| `demo/srv/assets/houseplan-card.js` | `cmp dist/... demo/srv/assets/...` | файла нет — ожидаемо, копия стенда не коммитится с #255 (класс D, `AGENTS.md` §Change classes) |
| `node scripts/check-docs.mjs` | не запускал | diff не трогает `src/**` (0 файлов), правило AGENTS.md/§8 PROCESS.md требует его только при правке фронтенда — не применимо |
| Browser-smokes | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | вывод: «Исполняемого frontend-диффа нет (src/**/*.ts не тронут). Browser-smoke этим диффом не выбираются... Тронуто файлов: 4.» → инструмент прямо сообщает «нечего выбирать»; сама карточка (собранный бандл) не менялась, смоки бы тестировали код, который не тронут — не прогонял |
| `npm run golden:verify` | не запускал | diff не меняет рендер/геометрию/стили — визуального результата нет |
| `python -m pytest tests_backend -q` | **не прогонял исполнением** | в этом окружении нет ни `pytest`, ни модуля `homeassistant` (`ModuleNotFoundError`, нет `.venv-backend`) —harness недоступен. Изменение в `websocket_api.py` разобрано **чтением, не исполнением** (см. ниже) вместо этого |
| `node scripts/model-invariants.mjs` | не запускал | diff не трогает геометрию, `layout`, `marker.space`, `open_spans`, рёбра/толщину стен — не применимо |
| Performance-профили | не запускал | не названы в AC, путь не является чувствительным к перфу (текст ошибки/документация) |
| `node scripts/process-gate.mjs` | запускал | `гейт пройден, предупреждений 1` — единственное предупреждение: нет `docs/specs/368-*.md`, что ожидаемо и допустимо для `small`/`trivial` (§5.1) |
| `python3 -m py_compile custom_components/houseplan/websocket_api.py` | запускал | `COMPILE OK` — синтаксическая валидность правки f-строк подтверждена |
### Разбор по AC (доказательство)
**AC1 — оба changelog содержат breaking-абзац со ссылками на #340/#356.**
Проверено чтением диффа: `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` получили идентичный по смыслу абзац в `## Unreleased`, оба со ссылками на #340, #356 и #368, оба в одном коммите `774388c2` (трейлер `User-Visible: yes` соблюдён — правило требует ровно этого). Формулировка сообщает: `config/set`/`layout/set` над непустым store требуют `expected_rev`, откуда брать `rev`, и что сама карточка не затронута. **Доказано чтением.**
Отдельно проверил фактическую точность формулировки «карточка шлёт ревизию с v1.60»: `src/houseplan-card.ts:6949` подтверждает `expected_rev: this._cfgRev` в вызове `config/set`. Для `layout/set` в `src/**` вообще нет ни одного вызова этого типа сообщения — карточка с v1.10.0 (`git log -S"houseplan/layout/set"`, коммит `e7cf0416`, "точечный layout/update вместо layout/set (анти last-writer-wins)") сознательно перешла на поточечный `layout/update`, который в `expected_rev` не нуждается. Утверждение «карточка не затронута» в CHANGELOG остаётся верным (по обеим причинам сразу — и для `config/set`, и потому что `layout/set` картой не используется), но не по причине «шлёт ревизию с v1.60» в буквальном смысле для layout. Это не искажает наблюдаемое поведение и не вводит пользователя в заблуждение относительно сути изменения (собственно brea­king для внешних писателей описан верно), поэтому квалифицирую как **Low**, не блокирует: формулировка обобщает две разные причины неуязвимости под одну фразу. Формально почвы для возврата нет — снимаю без правки, с записью здесь.
**AC2 — документация WS-команд описывает требование и пример.**
`docs/ARCHITECTURE.md:889-899` (существующий раздел «Integration WS API», уже нёсший абзац о #340) расширен: явно распространяет то же правило на `layout/set` (#356) и добавляет цикл для внешних клиентов — прочитать `rev` через `config/get`/`layout/get`, отправить его как `expected_rev`, на `conflict` перечитать и повторить. Таблица команд (строки 866-887) не менялась — и не должна была: столбцы `Parameters`/`Response` уже перечисляли `expected_rev?` для обеих команд. Проверено чтением текста и сверкой с уже существующей до диффа таблицей и её описанием у `ws_layout_set`/`ws_config_set` (докстринг `websocket_api.py:568-574` соответствует). **Доказано чтением.**
**AC3 — `conflict`-ответ несёт actionable-подсказку.**
`custom_components/houseplan/websocket_api.py:594-600` (layout) и `:1323-1332` (config, по номерам до диффа — после сдвига та же пара) — оба сообщения об ошибке дополнены фразой `"... or — for external clients — include expected_rev from houseplan/config|layout/get (current rev N)"`, сохранив исходное `"reload the ... (current rev N)"` для вкладок. Логика веток (`if "expected_rev" not in msg and current_rev:` и вторая проверка сравнения ревизий) не тронута — сравнение диффа построчно подтверждает: изменились только литералы f-строк, не условия. `python3 -m py_compile` подтверждает синтаксическую корректность. **Доказано чтением + компиляцией**, а также юнит-тестами (см. ниже) для инварианта «подстрока не потерялась».
**Автотест, который умеет падать:** `tests_backend/test_ha_websocket.py:516` и `:596` — `assert "revision is required" in rejected["error"]["message"].lower()`. Прочитал оба теста целиком (контекст вокруг присланных строк): они гоняют полный сценарий CAS-конфликта (stale-клиент шлёт `config/set`/`layout/set` без `expected_rev` поверх ненулевой ревизии) и проверяют код ошибки `conflict` плюс присутствие фиксированной подстроки. Тест **умеет падать** — упадёт, если подстрока `"revision is required"` исчезнет или код ошибки перестанет быть `conflict"`; ровно то, что могло случиться при неаккуратной правке текста. Не смог **исполнить** эти тесты в данном окружении (нет `pytest`, нет `homeassistant`, `.venv-backend` отсутствует — AGENTS.md подтверждает, что харнесс есть только в облачных агентах и на машине владельца/WSL). Разобрал изменение построчным сравнением диффа: правка меняет исключительно текст после уже проверяемой тестом подстроки, оставляя её начало (`f"Layout revision is required; reload the layout, or — for "`) и весь код условий нетронутыми. Это разбор **чтением, не исполнением**, как и требует §2.7 PROCESS.md, когда тест недоступен для прогона.
## Находки
Нет High. Нет Medium. Одна снятая Low (см. AC1 выше — не требует правки, оставлена как объяснённое решение ревьюера).
## Что проверено и корректно
- Единственная логическая ветка кода не изменилась — изменился только текст ошибки; условия `if "expected_rev" not in msg and current_rev` и сравнение `msg["expected_rev"] != current_rev` идентичны байт-в-байт до и после.
- Обе строки-подсказки консистентны между `layout/set` и `config/set` (симметричная формулировка, оба указывают правильную парную команду `get`).
- `docs/ARCHITECTURE.md` не дублирует и не противоречит уже существующему абзацу про #340 — расширяет его на #356 и добавляет практический цикл, не переписывая существующую таблицу команд.
- Оба changelog правлены в одном коммите с `User-Visible: yes`, трейлер `Issue: #368` на месте, ветка называется `issue/368-expected-rev-docs` — соответствует §10 PROCESS.md и `scripts/validate-commit-provenance.mjs`/`process-gate.mjs` (прогнан, гейт пройден).
- `process-gate.mjs` — 0 ошибок, 1 ожидаемое предупреждение (нет `docs/specs/368-*.md`, что верно для `trivial`).
- «Одно число — один источник»: в этом диффе новых пользовательских величин нет (только текст сообщений и changelog-проза, без числовых значений, кроме уже существующего `current_rev`, который берётся из той же переменной, что и раньше — источник не размножился).
- Diff не задевает геометрию, конфиг-миграцию, i18n, touch, perf — инварианты модели и браузерные смоки закономерно не требуются, что подтверждает и вывод `smoke-select.mjs`.
## Чего не проверял и почему
- **`python -m pytest tests_backend -q` не исполнялся** — окружение ревью не имеет ни `pytest`, ни `homeassistant`, ни `.venv-backend`. Компенсировано построчным чтением диффа и обоих тестов, пинящих подстроку сообщения (см. AC3). Рекомендация: если у исполнителя/владельца есть доступ к рабочему харнессу — было бы полезно фактически прогнать `test_ha_websocket.py::test_config_set_stale_conflict`-подобные кейсы разово, но для этой правки (только текст, логика нетронута) риск регрессии оцениваю как исчезающе малый, блокировать не буду.
- **Browser-smokes (полный список, 202 файла) не прогонялись** — diff не трогает `src/**`, `smoke-select.mjs` прямо сообщает «выбирать нечего»; прогон всего набора не соразмерен задаче (PROCESS.md §8, «полные наборы — предрелизный гейт»).
- **`golden:verify`** — не запускал, нет визуальных изменений.
- **`model-invariants.mjs`** — не запускал, геометрия не тронута.
- **Performance-профили** — не запускал, не названы в AC и путь не перфочувствителен.
- **`check-docs.mjs`** — не запускал, diff не трогает `src/**`.
## Вывод
Все три AC выполнены и доказаны (две — прямым чтением текста, третья — чтением + компиляцией + существующим автотестом, который умеет падать и логически не мог быть задет правкой). Единственное замечание — Low на неточную формулировку одной фразы в changelog про `layout/set` — не искажает смысла для пользователя и снято без правки. Задача решает заявленный сценарий (документационная прозрачность breaking-изменения #340/#356 для внешних писателей) и не деградирует ничего смежного.
**Вердикт: зелёный.**