mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
docs: specify atomic marker write rollback
Define guarded immutable candidates for semantic marker saves and asynchronous retry-safe vacuum calibration UX. Issue: #442 User-Visible: no
This commit is contained in:
committed by
claude[bot]
parent
f6cd99cef4
commit
4bd30423f2
@@ -0,0 +1,378 @@
|
||||
# ТЗ #442 — Атомарный откат отклонённых записей маркера
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/442
|
||||
- Приоритет: P2, `bug`
|
||||
- Статус ТЗ: готово к ревью
|
||||
- Маршрут: full; меняются основной Save устройства и сохранение калибровки
|
||||
робота, включая отказный UX и конкурентный контракт
|
||||
- Связанные контракты: #439 (guarded optimistic rollback общих настроек),
|
||||
#441 (атомарные CRUD-операции маршрутов карт), #162 (маршрутная
|
||||
калибровка робота), #314 (отдельный откат физической геометрии)
|
||||
|
||||
## Сценарий
|
||||
|
||||
Home admin редактирует устройство либо калибрует карту робота. Backend
|
||||
отклоняет `config/set` из-за semantic validation, конфликта или транспортной
|
||||
ошибки. Карточка должна сразу снова показывать последнее подтверждённое
|
||||
сервером состояние, но не терять введённый в открытом UI draft: пользователь
|
||||
исправляет значение или повторяет сохранение без перезагрузки карточки и без
|
||||
повторной ручной подгонки.
|
||||
|
||||
## Что человек увидит до и после
|
||||
|
||||
**До:** после отказа основной Device editor сообщает об ошибке, но локальный
|
||||
marker остаётся изменённым и может попасть в View либо в следующую запись.
|
||||
Калибровка закрывается и показывает success до ответа сервера; при отказе на
|
||||
экране остаётся фантомная матрица, а ручную подгонку приходится повторять.
|
||||
|
||||
**После:** непринятый marker config автоматически возвращается к последнему
|
||||
подтверждённому состоянию. Device editor остаётся открыт с введёнными полями.
|
||||
Auto/manual calibration остаётся открытой и занятой до ответа: при успехе она
|
||||
закрывается и только затем сообщает об успехе, при отказе сохраняет рассчитанный
|
||||
draft для Retry и показывает ошибку.
|
||||
|
||||
## Подтверждённая проблема
|
||||
|
||||
1. `HouseplanEditorRuntime._saveMarker()` меняет `cfg.markers` до
|
||||
`await _saveConfigNow()`. Обычный `catch` снимает `busy` и показывает toast,
|
||||
но не возвращает `_serverCfg`, не перестраивает devices и не отделяет отказ
|
||||
`config/set` от ошибок последующих layout/file housekeeping.
|
||||
2. Backend реально может отклонить доступные из Device editor изменения через
|
||||
`validate_marker_controls`, `validate_marker_light_entities`,
|
||||
`validate_marker_value_badges` и `validate_marker_vacuum_routes`.
|
||||
3. `_vacSaveMatrix()` мутирует marker in-place, перестраивает devices и вызывает
|
||||
debounced `_saveConfig()`, после чего callers сразу закрывают
|
||||
`_vacCalConfirm`/`_vacFit` и показывают `vac.autocal_done` либо
|
||||
`vac.cal_done`. Promise результата у UI нет.
|
||||
4. CRUD маршрутов в `src/editors/vacuum-maps-section.ts` уже использует
|
||||
`optimisticAttempt()`/`rollbackOptimistic()` и остаётся эталоном, а не второй
|
||||
реализацией в рамках #442.
|
||||
5. Общий writer уже сериализует запросы и отдельно обрабатывает conflict и
|
||||
physical geometry. #442 не должен подменять эти механизмы глобальным reload
|
||||
либо откатом несвязанной транзакции.
|
||||
|
||||
## Скоуп
|
||||
|
||||
В скоупе:
|
||||
|
||||
- атомарный immutable candidate для основного Save устройства;
|
||||
- guarded rollback всего config-кандидата при отказе именно его `config/set`;
|
||||
- восстановление производных marker/device представлений после отката;
|
||||
- сохранение draft Device editor и возможность Retry;
|
||||
- асинхронная запись auto-calibration, принятого high-residual proposal и manual
|
||||
fit;
|
||||
- busy-состояние калибровочного UI до ответа сервера;
|
||||
- success toast и закрытие calibration UI только после подтверждённой записи;
|
||||
- сохранение рассчитанной матрицы или параметров ручной подгонки при отказе;
|
||||
- конкурентные случаи conflict reload и более новой локальной ревизии;
|
||||
- регрессия атомарных маршрутов #441;
|
||||
- unit, browser smoke и mutation witnesses;
|
||||
- документация и оба changelog.
|
||||
|
||||
## Не-скоуп
|
||||
|
||||
- атомаризация всех 18 generic `_saveConfig()` call sites;
|
||||
- Hide/Show, удаление marker, discovery seeding, обычные `live`, `trail_mode` и
|
||||
`source` настройки робота, если они не входят в сохраняемый calibration/route
|
||||
candidate;
|
||||
- изменение четырёх backend validators, schema либо формата ошибок;
|
||||
- изменение математики auto-calibration, residual threshold или manual fit;
|
||||
- изменение маршрутизации карт, route identity либо выбора этажа;
|
||||
- откат физической геометрии или замена механизма #314;
|
||||
- транзакционное удаление безвредных файлов-копий после rejected rebind;
|
||||
- новый глобальный transaction manager для всех editor writes;
|
||||
- новые тексты ошибок, отдельные модальные предупреждения или дополнительные
|
||||
подтверждения.
|
||||
|
||||
## Контракт поведения
|
||||
|
||||
### 1. Граница marker-транзакции
|
||||
|
||||
Основной Save строит новый `ServerConfig` как отдельный candidate из текущего
|
||||
подтверждённого root. До отправки не допускается in-place изменение предыдущего
|
||||
`cfg.markers`: предыдущий config должен оставаться пригодным для точного
|
||||
восстановления.
|
||||
|
||||
Одна попытка фиксирует:
|
||||
|
||||
- глубокую копию предыдущего config;
|
||||
- предыдущий `_cfgContentFingerprint`;
|
||||
- `_cfgRev` до отправки;
|
||||
- candidate и его content fingerprint.
|
||||
|
||||
Candidate может быть показан оптимистично, но при отклонении его
|
||||
`config/set` вызывается guarded rollback. После успешного rollback карточка
|
||||
сбрасывает marker-derived caches/signature, перестраивает devices и запрашивает
|
||||
render. Ни одно поле непринятого marker не остаётся в View или следующей записи.
|
||||
|
||||
Конфигурационная транзакция заканчивается сразу после успешного
|
||||
`_saveConfigNow()`. Layout update, очистка старого layout id и файловое
|
||||
housekeeping выполняются только после durable config acceptance. Ошибка такого
|
||||
последующего best-effort шага не имеет права откатывать уже принятый сервером
|
||||
config.
|
||||
|
||||
### 2. Guarded rollback и конкуренция
|
||||
|
||||
Rollback применяется только если текущие revision и content fingerprint всё
|
||||
ещё принадлежат не принятому candidate. Сравнение content обязательно и для
|
||||
того же object identity: более новая in-place правка не может быть затёрта
|
||||
старым reject.
|
||||
|
||||
Таблица решений:
|
||||
|
||||
| Событие во время попытки | Итог |
|
||||
|---|---|
|
||||
| semantic/schema/transport reject, candidate всё ещё текущий | восстановить previous config и прежний fingerprint |
|
||||
| `conflict`, `_saveConfigNow()` уже перечитал server truth | не откатывать authoritative reload |
|
||||
| появилась более новая local revision/content | не откатывать новую правку |
|
||||
| пользователь закрыл диалог во время запроса | config откатывается по guard; диалог не воскрешается; toast остаётся |
|
||||
| config принят, затем упал layout/file side effect | принятый config остаётся; сообщается ошибка соответствующего шага |
|
||||
|
||||
Сериализация через существующую `_writeChain` и `expected_rev` сохраняется.
|
||||
Pending debounced write не должен обгонять прямой marker/calibration save; один
|
||||
и тот же candidate не отправляется повторно скрытым debounce.
|
||||
|
||||
### 3. Основной Device editor
|
||||
|
||||
При Save UI становится busy и не запускает вторую попытку. После успеха
|
||||
сохраняется текущий UX: диалог закрывается, devices перестраиваются и
|
||||
показывается `toast.marker_saved`.
|
||||
|
||||
При отказе config-записи:
|
||||
|
||||
- `_serverCfg` и отображение возвращаются к accepted state;
|
||||
- диалог, если он ещё открыт, остаётся открыт и выходит из busy;
|
||||
- его локальные поля остаются такими, какими их ввёл пользователь;
|
||||
- Retry строит новый immutable candidate поверх актуального server config;
|
||||
- success toast не показывается; показывается существующая локализованная
|
||||
ошибка;
|
||||
- если диалог закрыт пользователем, он не создаётся заново.
|
||||
|
||||
Rebind сохраняет порядок безопасности файлов: copy допустим до config save,
|
||||
cleanup — только после acceptance. Оставшаяся после reject копия безвредна и
|
||||
остаётся вне скоупа housekeeping.
|
||||
|
||||
### 4. Routes/maps
|
||||
|
||||
Добавление, переназначение и удаление `map_routes` продолжает использовать
|
||||
атомарный `persistRoutes()` из #441. #442 не возвращает эти операции к общему
|
||||
debounce и не создаёт второй route writer.
|
||||
|
||||
Основной Save устройства обязан переносить текущий vacuum block в candidate,
|
||||
не стирая уже принятую route transaction. Ошибка основного Save откатывает его
|
||||
полный candidate к состоянию непосредственно перед попыткой, а не к снимку до
|
||||
последней успешно принятой route-операции.
|
||||
|
||||
### 5. Запись калибровки
|
||||
|
||||
`_vacSaveMatrix()` становится асинхронной атомарной операцией либо делегирует
|
||||
такой операции. Она:
|
||||
|
||||
1. строит отдельный config candidate;
|
||||
2. материализует минимальный marker в candidate для first-use vacuum, не
|
||||
изменяя previous config;
|
||||
3. записывает matrix в exact route/legacy target через существующий
|
||||
`writeVacuumMatrix()`;
|
||||
4. фиксирует optimistic attempt, присваивает candidate и перестраивает preview;
|
||||
5. ожидает `_saveConfigNow()`;
|
||||
6. возвращает success только после server acceptance;
|
||||
7. при reject выполняет guarded rollback, перестраивает devices и возвращает
|
||||
failure вызывающему UI.
|
||||
|
||||
### 6. Calibration UX
|
||||
|
||||
Для low-residual auto-calibration Device editor остаётся на экране. На время
|
||||
записи соответствующие calibration controls busy/disabled; повторный клик не
|
||||
создаёт второй запрос. `vac.autocal_done` появляется только после acceptance.
|
||||
При reject диалог остаётся открыт, busy снимается, показывается ошибка; Retry
|
||||
повторно использует тот же пользовательский вход и снова рассчитывает matrix.
|
||||
|
||||
Для high-residual proposal:
|
||||
|
||||
- Apply не очищает `_vacCalConfirm` до ответа;
|
||||
- proposal получает busy state, закрытие, Cancel, Fit и повторный Apply на это
|
||||
время недоступны;
|
||||
- success закрывает proposal и показывает `vac.autocal_done`;
|
||||
- reject оставляет тот же proposal/matrix открытым, снимает busy и позволяет
|
||||
Retry либо переход в ручную подгонку.
|
||||
|
||||
Для manual fit:
|
||||
|
||||
- Save не очищает `_vacFit` до ответа;
|
||||
- overlay получает busy state; drag, rotate/mirror, Save и выход, который мог бы
|
||||
потерять draft, на время запроса не создают новую попытку;
|
||||
- success закрывает overlay и показывает `vac.cal_done`;
|
||||
- reject оставляет exact `FitParams` и route identity, снимает busy, возвращает
|
||||
accepted config и позволяет Retry без повторной подгонки.
|
||||
|
||||
Обычный Cancel до начала записи сохраняет прежнее поведение. Нового
|
||||
предупреждения при ошибке не добавляется.
|
||||
|
||||
## Данные и совместимость
|
||||
|
||||
- Persisted schema, marker/vacuum shape и revision protocol не меняются.
|
||||
- Миграции данных нет.
|
||||
- Успешные marker, route и calibration payloads должны быть эквивалентны
|
||||
текущим после canonicalization.
|
||||
- Legacy calibration и explicit `map_routes` сохраняют контракт #162/#443.
|
||||
- First-use write сохраняет остальные принятые поля marker: clone-and-patch не
|
||||
удаляет неизвестные/future поля marker, config или vacuum.
|
||||
|
||||
## Touch, клавиатура и доступность
|
||||
|
||||
Busy является настоящим disabled-состоянием controls, а не только визуальным
|
||||
индикатором. Повторные touch/click/Enter не создают дополнительный Save.
|
||||
Focus остаётся в том же открытом dialog/overlay после reject. Закрытие через
|
||||
Esc/scrim во время уже начатой попытки не воскрешает UI после ответа и не
|
||||
мешает config rollback. Новых targets, жестов и строк i18n нет.
|
||||
|
||||
## Ошибки и крайние случаи
|
||||
|
||||
| Случай | Ожидаемое поведение |
|
||||
|---|---|
|
||||
| controls cycle отклонён backend | marker в View прежний, draft dialog сохранён, Retry доступен |
|
||||
| invalid light/value source отклонён | тот же guarded rollback без частичного marker |
|
||||
| route calibration отклонена | accepted matrix остаётся в config, новая fit/proposal остаётся UI-draft |
|
||||
| first first-use vacuum calibration отклонена | synthetic marker не остаётся локально |
|
||||
| повторный Save во время pending | один `config/set` |
|
||||
| conflict reload во время reject | server truth выигрывает, старый rollback no-op |
|
||||
| новая локальная правка поверх candidate | новая content revision выигрывает |
|
||||
| Esc закрыл Device editor в полёте | config восстановлен, dialog не воскрешён, error toast виден |
|
||||
| config принят, layout update не удался | config не откатывается и не расходится с сервером |
|
||||
| route CRUD #441 reject/retry | прежний атомарный UX остаётся зелёным |
|
||||
|
||||
## Acceptance criteria и доказательства
|
||||
|
||||
### AC1. Основной Save атомарен
|
||||
|
||||
Unit/contract test доказывает, что `_saveMarker()` строит отдельный candidate и
|
||||
при semantic reject восстанавливает весь previous config/fingerprint. Browser
|
||||
smoke отклоняет `controls` или другой реально валидируемый field, сверяет View,
|
||||
сохранённый dialog draft, busy=false и успешный Retry.
|
||||
|
||||
### AC2. Откат не затирает владельца новой ревизии
|
||||
|
||||
Unit покрывает обычный reject, conflict reload, заменённый root и более новую
|
||||
in-place правку того же candidate. Mutant с unconditional rollback и mutant,
|
||||
который пропускает fingerprint для same identity, обязаны краснеть.
|
||||
|
||||
### AC3. Main Save отделяет durable config от side effects
|
||||
|
||||
Test double принимает `config/set`, затем отклоняет layout/file operation.
|
||||
Принятый marker остаётся локально, старый config не восстанавливается, а
|
||||
операция не показывает `toast.marker_saved` до завершения обязательной части
|
||||
успешного пути.
|
||||
|
||||
### AC4. Auto-calibration ждёт сервер
|
||||
|
||||
Browser smoke держит `config/set` deferred: до resolve отсутствует success,
|
||||
Device editor/proposal остаётся открыт и busy, повторный Apply не пишет второй
|
||||
раз. Resolve закрывает нужный UI и даёт ровно один success toast.
|
||||
|
||||
### AC5. Reject auto proposal сохраняет Retry
|
||||
|
||||
High-residual proposal при reject остаётся с той же matrix/route identity,
|
||||
accepted config восстановлен, busy снят и второй Apply может успешно записать
|
||||
ровно одну калибровку.
|
||||
|
||||
### AC6. Reject manual fit сохраняет подгонку
|
||||
|
||||
Browser smoke фиксирует изменённые `FitParams`, отклоняет Save и проверяет:
|
||||
overlay не закрыт, параметры не изменились, pointer/кнопки снова доступны,
|
||||
config вернулся к accepted matrix. Retry success закрывает overlay и только
|
||||
тогда показывает `vac.cal_done`.
|
||||
|
||||
### AC7. First-use и route identity сохранены
|
||||
|
||||
Тест отклоняет первую калибровку auto-discovered vacuum: minimal marker не
|
||||
остаётся в `_serverCfg`. Существующие multifloor/first-use tests подтверждают,
|
||||
что successful retry пишет matrix в exact route и не возвращает legacy path.
|
||||
|
||||
### AC8. #441 не регрессировал
|
||||
|
||||
`smoke_vacuum_route_draft` продолжает доказывать atomic add/reassign/delete,
|
||||
reject/retry и отсутствие пустого route draft в persisted config. Основной
|
||||
marker Save не стирает только что принятую route transaction.
|
||||
|
||||
### AC9. Общие гейты
|
||||
|
||||
Зелёные typecheck, unit, build и синхрон трёх bundle trees. Для diff в `src/**`
|
||||
обязателен `check-docs`; `smoke-select` определяет дополнительные browser
|
||||
scenarios. Полный Linux HA harness остаётся каноническим CI. Изменение не
|
||||
трогает геометрию, поэтому `model-invariants` неприменим. Visible busy/reject UX
|
||||
проверяется smoke; постоянный golden добавляется только при наличии подходящей
|
||||
canonical surface.
|
||||
|
||||
## План тестирования
|
||||
|
||||
- расширить `test/serialized-write-queue.test.mjs` проверкой same-identity
|
||||
content change;
|
||||
- выделить pure candidate/rollback helpers для marker и calibration там, где
|
||||
это уменьшает stateful browser setup;
|
||||
- добавить browser smoke rejected marker Save + preserved draft + Retry;
|
||||
- расширить vacuum smoke deferred/rejected auto proposal и manual fit;
|
||||
- сохранить зелёными `smoke_vacuum_route_draft`, `smoke_vacuum_firstuse`,
|
||||
`smoke_vacuum_multifloor`, `smoke_vacuum` и `smoke_dialog_zombie`;
|
||||
- добавить mutation-gate anchors для отсутствующего marker/calibration
|
||||
rollback, раннего success/close и same-identity fingerprint guard;
|
||||
- выполнить `typecheck`, `npm test`, `build`, `bundle:sync`, `check-docs`,
|
||||
выбранные smokes и обязательный CI на exact SHA.
|
||||
|
||||
## Карта реализации
|
||||
|
||||
- `src/serialized-write-queue.ts` — строгий guarded rollback при изменённом
|
||||
content того же object identity;
|
||||
- `src/houseplan-editor-runtime.ts` — immutable marker/calibration candidates,
|
||||
async persistence, rollback/rebuild и delayed success/close;
|
||||
- `src/houseplan-card.ts` — типы busy-state, render/disabled contract и async
|
||||
delegates;
|
||||
- при необходимости небольшой отдельный pure-модуль marker candidate, чтобы не
|
||||
раздувать editor runtime;
|
||||
- `test/`, `demo/smoke_*.mjs`, `scripts/mutation-gate.mjs` — witnesses;
|
||||
- `docs/CONFIG-COMPATIBILITY.md`, `docs/VACUUM.md`, оба changelog и screenshot
|
||||
fingerprint — release artifacts.
|
||||
|
||||
## Риски и rollback
|
||||
|
||||
- Главный риск — откатить authoritative conflict reload или более новую правку;
|
||||
закрывается revision + unconditional content-fingerprint guard.
|
||||
- Второй риск — принять config на сервере, затем ошибочно вернуть локально
|
||||
previous из-за layout/file failure; закрывается явной durable boundary.
|
||||
- Третий риск — потерять first-use marker либо route identity при clone; его
|
||||
закрывают first-use и multifloor witnesses.
|
||||
- Четвёртый риск — закрыть calibration UI до реального ответа и потерять draft;
|
||||
закрывается deferred/reject/retry smoke.
|
||||
- Rollback реализации — revert frontend/tests/docs/bundle. Data migration нет;
|
||||
принятые marker/calibration payloads совместимы с предыдущей версией.
|
||||
|
||||
## Release-артефакты
|
||||
|
||||
- В том же user-visible implementation commit обновляются
|
||||
`docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #442.
|
||||
- `docs/CONFIG-COMPATIBILITY.md` фиксирует: semantic reject marker write не
|
||||
остаётся локальной конфигурацией, conflict/newer revision выигрывают.
|
||||
- `docs/VACUUM.md` фиксирует, что success калибровки означает подтверждённую
|
||||
запись, а reject сохраняет auto/manual draft для Retry.
|
||||
- Любой `src/**` diff требует канонической `Docs screenshots` съёмки и приёмки
|
||||
fingerprint по процессу. Если существующий кадр реально меняется, его diff
|
||||
просматривается; несвязанные локальные baseline differences не принимаются.
|
||||
- Новая постоянная golden surface не требуется, если calibration pending/reject
|
||||
UI отсутствует в текущей canonical matrix; light/dark и keyboard/touch
|
||||
состояние подтверждается browser smoke.
|
||||
- Нового performance или security artifact не требуется: payload size,
|
||||
вычислительная геометрия и trust boundary не меняются.
|
||||
|
||||
## Принятые предположения
|
||||
|
||||
- Default Q1: в #442 входят только UI-пути, которые могут изменить поля четырёх
|
||||
marker semantic validators; generic marker/config writes вне этого множества
|
||||
не атомаризируются.
|
||||
- Default Q2: calibration UI остаётся открытым и busy до ответа; reject
|
||||
сохраняет рассчитанный draft для Retry, success закрывает UI после acceptance.
|
||||
- Любой reject semantic marker attempt откатывает только эту попытку; conflict
|
||||
reload и более новая revision/content имеют приоритет.
|
||||
- Draft основного Device editor живёт отдельно от `_serverCfg` и не теряется при
|
||||
rollback.
|
||||
- Backend/schema/i18n, успешный View и физическая геометрия не меняются.
|
||||
- Файлы, скопированные перед rejected rebind, остаются безопасным остатком и не
|
||||
удаляются новой клиентской гонкой.
|
||||
@@ -92,6 +92,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
|
||||
| Issue | ТЗ |
|
||||
|---|---|
|
||||
| [#10](https://github.com/Matysh/houseplan-card/issues/10) Roomba live position | [010-vacuum-roomba-live-position.md](010-vacuum-roomba-live-position.md) |
|
||||
| [#442](https://github.com/Matysh/houseplan-card/issues/442) Атомарный откат отклонённых записей маркера | [442-marker-write-rollback.md](442-marker-write-rollback.md) |
|
||||
| [#11](https://github.com/Matysh/houseplan-card/issues/11) Vacuum source health | [011-vacuum-source-health.md](011-vacuum-source-health.md) |
|
||||
| [#12](https://github.com/Matysh/houseplan-card/issues/12) Room cleaning highlight | [012-vacuum-room-cleaning-highlight.md](012-vacuum-room-cleaning-highlight.md) |
|
||||
| [#13](https://github.com/Matysh/houseplan-card/issues/13) Golden open context tray | [013-golden-open-context-tray.md](013-golden-open-context-tray.md) |
|
||||
|
||||
Reference in New Issue
Block a user