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:
Sergey Matyunin
2026-09-03 21:42:57 +00:00
committed by claude[bot]
parent f6cd99cef4
commit 4bd30423f2
2 changed files with 379 additions and 0 deletions
+378
View File
@@ -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, остаются безопасным остатком и не
удаляются новой клиентской гонкой.
+1
View File
@@ -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) |