From 4bd30423f2a8c6de87e62c87fe6cd7ae9f5d1a77 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Thu, 3 Sep 2026 23:48:18 +0300 Subject: [PATCH] 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 --- docs/specs/442-marker-write-rollback.md | 378 ++++++++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 379 insertions(+) create mode 100644 docs/specs/442-marker-write-rollback.md diff --git a/docs/specs/442-marker-write-rollback.md b/docs/specs/442-marker-write-rollback.md new file mode 100644 index 00000000..a9dcca2f --- /dev/null +++ b/docs/specs/442-marker-write-rollback.md @@ -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, остаются безопасным остатком и не + удаляются новой клиентской гонкой. diff --git a/docs/specs/README.md b/docs/specs/README.md index 3098dc92..12ef1d58 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -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) |