specs: в state-reconciliation разведены Cancel и Dismiss по состояниям
- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия, error_code на каждом, раскладка поверхностей — описательно, а не SHALL - уборка своего торрента после отмены названа исключением по состоянию, а не по команде; убрана ложная гарантия «данных пользователя не касается» - в docs/review.md записан проскочивший дефект гарда окна после add и новый вопрос проходу adversary про асимметрию признака владения
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-06
|
||||
@@ -0,0 +1,284 @@
|
||||
## Context
|
||||
|
||||
Аудит capability `state-reconciliation` (сверка код↔спека) нашёл расхождение в
|
||||
требовании «Ручное закрытие загрузки (стоп-кран)». Спека утверждает единый путь
|
||||
закрытия из любого состояния кроме `deleted`, помечаемый
|
||||
`error_code = "user_dismiss"`. Код даёт **два** пути, разведённые по состояниям.
|
||||
|
||||
Что показало чтение кода (провенанс каждого факта — путь:строка):
|
||||
|
||||
| Факт | Где |
|
||||
| --- | --- |
|
||||
| `Cancel` отклоняет терминальные состояния, ставит пустые `error_code`/`error_msg` | `internal/worker/worker.go:973` |
|
||||
| `Dismiss` доступен из любого состояния кроме `deleted`, ставит `user_dismiss`, из `cancelled` — no-op без записи | `internal/worker/worker.go:1001` |
|
||||
| Веб-UI: «Закрыть» только для терминальных, кроме `deleted`/`cancelled` | `internal/httpapi/download.go:129` |
|
||||
| Веб-UI: «Отменить» только для не-терминальных | `web/templates/partials/download_main.html:26`, `partials/card.html:32` |
|
||||
| Экран ревью: «Отклонить» → `Cancel` | `web/templates/partials/review_main.html:77` |
|
||||
| Telegram: «Закрыть» на `failed`/`stuck` и на `done`/`orphaned`/`target_missing` | `internal/tgbot/render.go:271`, `:290` |
|
||||
| Telegram: «Отклонить» → `Cancel` на карточке ревью | `internal/tgbot/render.go:119`, `bot.go:325` |
|
||||
| Telegram: `downloading`/`recognizing`/`linking`/`completed`/`catched` — карточка без действий (только ссылка в веб) | `internal/tgbot/render.go:39` |
|
||||
| REST: есть `cancel` и `retry`, `dismiss` нет | `internal/httpapi/httpapi.go:152` |
|
||||
| `failed` терминален, `stuck` — нет | `internal/store/download.go:54` |
|
||||
| Граф переходов уже описывает пару `Cancel`/`Dismiss` прозой | `internal/store/download.go:86` |
|
||||
|
||||
Важное следствие таблицы: постановка задачи описывала разрыв как
|
||||
«веб-UI против домена», а он шире — раскладка путей **разная у разных
|
||||
поверхностей**. Telegram даёт `Dismiss` из `stuck`, то есть из не-терминального
|
||||
состояния, а на `review` даёт `Cancel`; веб-UI режет строго по терминальности.
|
||||
|
||||
Ограничение по постановке: **кода изменение не трогает** — критерий приёмки
|
||||
задачи требует пустого дифа под `internal/` и `web/`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Нормативный дом поведения перестаёт утверждать то, чего поверхности не дают.
|
||||
- Требование называет `error_code` на каждом пути закрытия, а не на одном.
|
||||
- Требование называет гарантию, которая действительно держится: закрытие
|
||||
доступно из любого не-`deleted` состояния хотя бы одной поверхностью.
|
||||
- Разрыв наблюдаемости назван с ценой, чтобы следующий аудит не открывал его
|
||||
заново.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Не менять поведение: `Dismiss` из не-терминальных состояний веб-UI не
|
||||
зовётся, гейт `Dismissable` остаётся как есть.
|
||||
- Не заводить второй ADR о приёме «спека следует за кодом» — приём уже описан
|
||||
в [ADR-2026-08-06-spec-follows-code-on-narrow-window](../../../docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md).
|
||||
- Не переписывать требование `review` о команде «Отклонить»: его предмет —
|
||||
экран ревью, а не закрытие как класс.
|
||||
- Не заводить `dismiss` в REST: REST помечен опциональным транспортом.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Спека двигается к коду — но только в той половине, где приём ADR применим
|
||||
|
||||
Приём и его границы заданы ADR-2026-08-06: двигается тот, **чья формулировка
|
||||
сильнее рационали**. Требование содержит **два** обещания, и приём применим
|
||||
только к одному из них — ревью предложения показало, что первая редакция этого
|
||||
не различала.
|
||||
|
||||
- **Обещание доступности** («закрыть можно любую загрузку кроме `deleted`»).
|
||||
Рациональ кодом выполнен: страница загрузки веб-UI даёт путь закрытия из
|
||||
каждого не-`deleted`, не-`cancelled` состояния. Формулировка «команда
|
||||
`dismiss` из любого состояния во всех транспортах» была сильнее рационали и
|
||||
требовала от каждой поверхности **обе** кнопки. Здесь спека и правда
|
||||
двигается к коду.
|
||||
- **Обещание различимости** («закрытие помечено `user_dismiss`»). Рациональ —
|
||||
различать инициатора в диагностике — кодом **не** достигнут, а по правилу
|
||||
того же ADR недостающая гарантия чинится кодом. Поэтому спека этот пункт
|
||||
**не закрепляет**: сегодняшняя пустота кода на пути `Cancel` описана как
|
||||
факт, а не как `SHALL`, и вопрос оставлен открытым (D7).
|
||||
|
||||
Альтернатива C (оставить всё как есть) отвергается по тому же основанию, что в
|
||||
ADR: расхождение воспроизводится каждым аудитом, а читатель спеки считает
|
||||
маркер гарантированным.
|
||||
|
||||
### D2. Требование разводит доменную команду и обязательства поверхностей
|
||||
|
||||
Прежний текст смешивал два уровня в одном предложении («команда доступна из
|
||||
любого состояния во всех транспортах»). Новый текст разводит: нормативны
|
||||
**гарантия закрытия** (страница загрузки веб-UI даёт путь из каждого
|
||||
не-`deleted`/`cancelled` состояния) и **гейты доменных путей** (`Cancel` —
|
||||
не-терминальные, `Dismiss` — всё кроме `deleted`). Раскладка кнопок по
|
||||
поверхностям нормативной **не объявляется**: она описана как сегодняшнее
|
||||
состояние, перечислением состояний, а не оборотом «там, где карточка вообще
|
||||
даёт действия».
|
||||
|
||||
Первая редакция закрепляла раскладку через `SHALL`, и ревью предложения нашло
|
||||
в этом две беды разом: она противоречила заявке этого же решения («спека
|
||||
фиксирует гарантию, не вёрстку»), а Telegram-половина формулировки была
|
||||
круговой — истинной при любом поведении кода, включая удаление кнопки.
|
||||
Описательная форма с перечнем состояний фальсифицируема (по ней строится
|
||||
таблица «состояние × поверхность») и не превращает будущее выравнивание
|
||||
поверхностей в нарушение спеки.
|
||||
|
||||
Альтернатива — перечислить в спеке точную раскладку клавиатур Telegram —
|
||||
отклонена: спека стала бы копией `render.go` и краснела бы от косметической
|
||||
правки кнопки.
|
||||
|
||||
### D3. `error_code` называется на каждом пути; нормативен только `user_dismiss`
|
||||
|
||||
Пустой код — тоже наблюдаемое обязательство, и умолчание о нём породило задачу.
|
||||
Пишем прямо: `Dismiss` → `"user_dismiss"` (нормативно), `Cancel` → сегодня
|
||||
пусто (описательно, см. D1 и D7). К этому добавлено то, чего в старом тексте не
|
||||
было вовсе: **оба** пути замещают прежнюю диагностику записи. Первая редакция
|
||||
приписывала затирание только `Cancel` и тем создавала ложное впечатление, что
|
||||
маркерный путь причину сохраняет; `worker.go:1016` пишет `user_dismiss` поверх
|
||||
`qbit_error` ровно так же.
|
||||
|
||||
### D4. Ограничение названо в теле требования, а не спрятано в design
|
||||
|
||||
Форма взята из ADR-2026-08-06: молчащая ложная гарантия дороже названного
|
||||
ограничения. Абзац «известное ограничение» стоит в самом требовании, потому что
|
||||
архив change'а читают реже, чем спеку.
|
||||
|
||||
### D5. `review` не трогаем
|
||||
|
||||
Команда «Отклонить» описана в `review` («Команды ревью и их эффекты»,
|
||||
`review/spec.md:33`, `:370`) — там её дом. Здесь она упоминается как **один из
|
||||
путей закрытия** со ссылкой на состояние, но требование `review` не
|
||||
переписывается: иначе у одного факта появилось бы два нормативных дома.
|
||||
|
||||
Цена решения названа честно: `error_code` пути «Отклонить» теперь стоит в
|
||||
`state-reconciliation`, а сама команда описана в `review`. Разнесение
|
||||
оправдано тем, что предмет здесь — **закрытие как класс**, и сравнение двух
|
||||
путей имеет смысл только рядом.
|
||||
|
||||
### D6. Сценарии добавляются на оба новых утверждения
|
||||
|
||||
Постановка требовала сценарий закрытия не-терминальной загрузки из веб-UI.
|
||||
Добавляются четыре: веб-UI не-терминальная (пустой код), терминальная (маркер +
|
||||
отказ `Cancel`), один и тот же `stuck` с двух поверхностей (маркер зависит от
|
||||
поверхности), замещение прежней диагностики стоп-краном. Плюс переписан
|
||||
сценарий повторного закрытия `cancelled` (no-op без записи **и** отказ `Cancel`
|
||||
конфликтом).
|
||||
|
||||
Выброшены два кандидата первой редакции. Сценарий «Закрытие зависшей загрузки»
|
||||
с `GIVEN stuck (или failed/deferred)` обещал пользовательский путь «Закрыть»
|
||||
для `deferred`, которого нет ни в одной поверхности, — ровно та ложная
|
||||
гарантия, ради снятия которой задача и заведена. Сценарий «Отклонение на ревью
|
||||
из Telegram» привязывал норму к кнопке карточки ревью, то есть заводил второй
|
||||
дом факту, живущему в `review` (D5), и дублировал сценарий веб-UI.
|
||||
|
||||
### D7. Собственный код у `Cancel` — открытый вопрос, а не отвергнутый вариант
|
||||
|
||||
Первая редакция обосновывала потерю наблюдаемости так: «единый маркер
|
||||
потребовал бы звать `Dismiss` из не-терминальных состояний веб-UI, то есть
|
||||
менять рабочее поведение». Архитектурный проход показал, что это ложное
|
||||
основание: рассматривались вариант A (звать `Dismiss` из веб-UI — да, меняет
|
||||
поведение) и вариант C (ничего не делать), а **третья форма не
|
||||
рассматривалась** — дать `Cancel` собственный непустой код (`user_cancel`).
|
||||
Она меняет одну строку `worker.go:985`, не трогает ни состояний, ни файлов, ни
|
||||
интерфейса, и восстанавливает различение полностью.
|
||||
|
||||
Эта форма выходит за объявленную границу задачи: критерий приёмки A3 требует
|
||||
пустого диффа под `internal/` и `web/`. Решать её не пайплайну — вопрос
|
||||
записан наружу (см. «Open Questions»), а изменение урезано до остатка:
|
||||
**спека перестаёт лгать и перестаёт цементировать пустоту**. Отсюда описательная
|
||||
форма в D3 — будущая одностроковая починка не станет нарушением спеки и не
|
||||
потребует нового change.
|
||||
|
||||
Заодно назван настоящий масштаб потери, которого постановка не видела: маркер
|
||||
сегодня кодирует **поверхность**, а не намерение. Один и тот же `stuck`,
|
||||
закрытый из Telegram, получает `user_dismiss`, а закрытый из веб-UI — пустой
|
||||
код. Это зафиксировано сценарием, чтобы вопрос не пришлось открывать заново.
|
||||
|
||||
### D9. Исключение про уборку торрента висит на состоянии, а не на команде
|
||||
|
||||
Правка чекпоинта 2. Редакция D8 повесила carve-out на `Cancel` — и промахнулась
|
||||
осью. Уборка в `worker.go:534` срабатывает по условию `after.State !=
|
||||
StateCatched`, то есть безразлична к тому, какая команда увела задачу из
|
||||
`catched`; `Dismiss` из `catched` даёт тот же `torrents/delete` с
|
||||
`deleteFiles=true`. Проход `adversary` воспроизвёл это тестом
|
||||
(`TestDismissInAddWindowCallsQbitDelete`). Гейт `Dismiss` при этом **шире**
|
||||
гейта `Cancel` — значит формулировка «`Dismiss` не зовёт qBittorrent никогда»
|
||||
была ложной ровно там, где ошибиться дороже всего.
|
||||
|
||||
Исключение переформулировано по условию («закрытие любым путём, уведшее задачу
|
||||
из `catched` в окне после `add`») — как оно и записано в `download-tracking`,
|
||||
командо-нейтрально.
|
||||
|
||||
Заодно убрано утверждение «и данных пользователя не касается». Оно сильнее того,
|
||||
что гарантирует дом исключения: гард подтверждает отсутствие **записи торрента**
|
||||
в qBittorrent, но не отсутствие **данных** на диске, и `adversary` построил путь,
|
||||
на котором сносятся пред-существующие файлы пользователя
|
||||
(`TestCancelInAddWindowDeletesPreExistingData`). Сам этот дефект — в коде и
|
||||
пред-существующий; здесь снимается только ложная гарантия в тексте, а дефект
|
||||
уходит урожаем.
|
||||
|
||||
### D10. Перепривязка названа не-лекарством
|
||||
|
||||
Правка чекпоинта 2. Первая редакция писала, что закрытие оставляет ссылки «без
|
||||
штатной команды снятия **до повторной привязки**» — то есть подавала relink
|
||||
лекарством. `adversary` показал тестами (`TestDismissRelink`,
|
||||
`TestStrandedLink`), что это не так: `Apply` заводит новый батч, а `Undo` и
|
||||
`Delete` работают только с последним, поэтому ссылка прежнего батча брошена
|
||||
навсегда, и последующее «полное удаление» перестаёт освобождать место. Текст
|
||||
приведён к факту; расширение `Undo`/`Delete` на все батчи — урожай.
|
||||
|
||||
### D11. Подтверждение и лог перехода описаны как есть, а не как хотелось
|
||||
|
||||
Правка чекпоинта 2. Два новых обязательства первой редакции въезжали уже
|
||||
нарушенными:
|
||||
|
||||
- «SHALL требовать отдельного подтверждающего шага» — без JS диалога в веб-UI
|
||||
нет (гейт там только раскрытие `<details>` и явный submit), а в Telegram нет
|
||||
danger-зоны. Норма сужена до фактического гейта: обособление плюс
|
||||
дополнительный шаг там, где транспорт его поддерживает.
|
||||
- «прежний код остаётся в логе перехода» — лог-строки `Cancel` и `Dismiss`
|
||||
прежнюю диагностику не пишут вовсе (`worker.go:985-989`, `:1016-1020`).
|
||||
Формулировка исправлена: причина восстановима только по более раннему
|
||||
переходу в `failed`/`stuck` и только пока её держит ретенция логов.
|
||||
|
||||
Оба раза выбран вариант «привести текст к факту», а не «оставить `SHALL` целью»:
|
||||
нормативный дом с заведомо невыполненным `SHALL` — это ровно тот дефект, ради
|
||||
снятия которого заведена задача.
|
||||
|
||||
### D8. Отрицание побочных эффектов сужено до `Dismiss` с явным исключением
|
||||
|
||||
Первая редакция обобщила «команда SHALL только менять статус, qBittorrent не
|
||||
звать» с `Dismiss` на **оба** пути — и тем самым нормативно запретила уборку
|
||||
воркером собственного, только что добавленного торрента при отмене в окне после
|
||||
`add`. Эта уборка предписана `download-tracking` («Добавление пойманной
|
||||
загрузки в qBittorrent»), закреплена тестом
|
||||
`TestProcessCatchedCancelledAfterAddRemovesTorrent` и стоит вторым исключением
|
||||
инварианта «источник неприкосновенен» в `CLAUDE.md`. Изменение, чья цель —
|
||||
снять ложную гарантию, чуть не завело новую, да ещё и противоречащую соседней
|
||||
capability.
|
||||
|
||||
Отрицание возвращено к `Dismiss`, а для `Cancel` дано с явной ссылкой на дом
|
||||
исключения. Общее правило, которое отсюда следует: универсальное «SHALL NOT
|
||||
трогать X» обязано перечислять известные carve-out'ы или ссылаться на
|
||||
требование, где они живут.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Асимметрия поверхностей описана и тем самым узаконена, а завтра её
|
||||
захотят выровнять]** → Описательная форма (D2) выравнивание не блокирует:
|
||||
спека не станет ложной от того, что веб-UI добавит «Закрыть» на `stuck`.
|
||||
Нормативны только гарантия закрытия и гейты доменных путей.
|
||||
- **[Прецедент «спека следует за кодом» применяется буквально]** → Риск
|
||||
унаследован от ADR-2026-08-06 и там же оговорён. Условие приёма проверено
|
||||
поимённо и выполнено **не целиком**: для обещания доступности — да, для
|
||||
обещания различимости — нет, поэтому второе спекой не закрепляется (D1, D7).
|
||||
Это и есть защита от буквального применения.
|
||||
- **[Новые сценарии не имеют теста-оракула]** → Изменение чисто текстовое,
|
||||
гейт его регрессию не покрасит. Оракулом остаётся `openspec validate
|
||||
--strict` (форма) и чтение кода (содержание). Это идёт в границы покрытия
|
||||
ревью, а не замалчивается. Отдельно: клавиатуры Telegram не покрыты тестами
|
||||
вовсе, поэтому описанная в требовании раскладка по этой поверхности держится
|
||||
только на чтении `render.go`.
|
||||
- **[Требование выросло в объёме]** → Выросло сознательно: раньше оно было
|
||||
короче своей же предметной области. Раскладка клавиатур в него не заехала
|
||||
(D2), поэтому косметическая правка UI его не тронет.
|
||||
- **[Часть найденного ревью не закрыта этим изменением]** → Отдано урожаем, а не
|
||||
замолчано (полный список с оракулами — в `review/triage.md`): гард окна после
|
||||
`add` подтверждает отсутствие торрента, но не данных, и сносит
|
||||
пред-существующие файлы пользователя (кандидат `critical`, воспроизведён
|
||||
тестом); `Undo`/`Delete` работают только с последним батчем; прежняя
|
||||
диагностика не дублируется в лог перехода; «per-download блокировка» — на
|
||||
деле глобальный мьютекс, файловый ввод-вывод идёт под ним; дефолтный список
|
||||
даёт `SCAN download`, а `cancelled` копятся без ретеншена; та же no-JS-дыра
|
||||
в пред-существующем требовании «Полное удаление»; гонка «кнопка отрисована —
|
||||
состояние ушло» не описана ни одним требованием проекта; литералы `error_code`
|
||||
рассыпаны по трём спекам без сводной таблицы.
|
||||
- **[Кросс-ссылки в `openspec/specs/` никем не проверяются]** → Шаг `canon`
|
||||
гейта обходит только `docs/` и `CLAUDE.md`. Первая редакция принесла в спеку
|
||||
markdown-ссылку, не резолвившуюся ни из change, ни после архивации; она
|
||||
заменена бэктик-путём — это и есть сложившаяся в `openspec/specs/` конвенция
|
||||
(markdown-ссылок там нет ни одной). Promote-кандидат отдан урожаем.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Дать ли `Cancel` собственный непустой `error_code`** (напр. `user_cancel`).
|
||||
Цена — одна строка `worker.go:985` плюс строка в `docs/database.md`; выигрыш —
|
||||
различение «пользователь отменил активную» / «стоп-кран» / «сверка» в
|
||||
диагностике восстанавливается полностью, и «известное ограничение» из
|
||||
требования исчезает. Не делается здесь: выходит за границу задачи (критерий
|
||||
приёмки A3 — пустой дифф под `internal/`). Пока решения нет, стоит то, что
|
||||
описано: маркер кодирует поверхность, а не намерение. Рекомендация — завести:
|
||||
цена несоразмерно мала.
|
||||
- Стоит ли давать `dismiss` в REST ради равенства транспортов. Сегодня REST
|
||||
помечен опциональным и отдаёт только `cancel`; вопрос не блокирует изменение.
|
||||
@@ -0,0 +1,68 @@
|
||||
## Why
|
||||
|
||||
Требование «Ручное закрытие загрузки (стоп-кран)» обещает команду «Закрыть»
|
||||
(dismiss) «из **любого** состояния, кроме `deleted`, во всех транспортах» и
|
||||
метку `error_code = "user_dismiss"` на переходе. Доменная команда
|
||||
`worker.Dismiss` это выполняет, но **поверхности** её так не показывают:
|
||||
веб-UI держит «Закрыть» в danger-зоне только для терминальных состояний
|
||||
(`Dismissable = IsTerminal() && !deleted && !cancelled`), а закрытие
|
||||
не-терминальной загрузки идёт кнопкой «Отменить»/«Отклонить» → `worker.Cancel`,
|
||||
которая пишет **пустой** `error_code`. Telegram показывает «Закрыть» ещё по
|
||||
третьей раскладке — для `failed`/`stuck` и для `done`/`orphaned`/
|
||||
`target_missing`, а на `review`/`deferred` даёт «Отклонить» → `Cancel`.
|
||||
|
||||
Расходится буква, а не поведение: `Cancel` тоже даёт `cancelled`, файлы и
|
||||
раздачу не трогает, семантика для пользователя та же. Теряется маркер
|
||||
`user_dismiss` в диагностике — по `error_code` не отличить «пользователь закрыл
|
||||
активную» от «пользователь отменил». Пока спека утверждает единый путь закрытия,
|
||||
разрыв невидим и находится заново каждым аудитом capability.
|
||||
|
||||
## What Changes
|
||||
|
||||
Меняется **заявленное**, не наблюдаемое. Кода изменение не трогает.
|
||||
|
||||
- Требование «Ручное закрытие загрузки (стоп-кран)» разводит два уровня:
|
||||
**гейты доменных путей** (`Cancel` — не-терминальные, `Dismiss` — любое кроме
|
||||
`deleted`; это нормативно) и **раскладку кнопок по поверхностям** (описана как
|
||||
сегодняшнее состояние, нормативной не объявляется).
|
||||
- Требование называет `error_code` на каждом пути закрытия: `Dismiss` →
|
||||
`"user_dismiss"` (нормативно), `Cancel` (отмена активной / отклонение на
|
||||
ревью) → сегодня пустой (описательно — собственный код у `Cancel` не
|
||||
запрещён, вопрос открыт). Прежняя формулировка обещала `user_dismiss` на
|
||||
любом закрытии.
|
||||
- Требование называет гарантию, которая держится: страница загрузки веб-UI даёт
|
||||
путь закрытия из каждого не-`deleted` и не-`cancelled` состояния; какой именно
|
||||
путь — выбор поверхности, а не обязательство домена.
|
||||
- Отрицание побочных эффектов остаётся при `Dismiss`; для `Cancel` названо
|
||||
единственное исключение — уборка воркером собственного, только что
|
||||
добавленного торрента при отмене в окне после `add` (дом исключения —
|
||||
`download-tracking`).
|
||||
- Названо, что **оба** пути замещают прежнюю диагностику записи, а не только
|
||||
`Cancel`.
|
||||
- Добавляются сценарии закрытия не-терминальной загрузки из веб-UI, закрытия
|
||||
терминальной, замещения диагностики и расхождения маркера по поверхностям.
|
||||
- Названо известное ограничение: маркер сегодня кодирует поверхность, а не
|
||||
намерение; разрыв описан с ценой, а не замолчан.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `state-reconciliation`: требование «Ручное закрытие загрузки (стоп-кран)» —
|
||||
доступность закрытия по состояниям и поверхностям, `error_code` на каждом
|
||||
пути закрытия, сценарии закрытия не-терминальной загрузки.
|
||||
|
||||
## Impact
|
||||
|
||||
- `openspec/specs/state-reconciliation/spec.md` — одно требование и его
|
||||
сценарии.
|
||||
- `internal/httpapi/download.go`, `web/templates/partials/download_main.html`,
|
||||
`web/templates/partials/card.html`, `internal/tgbot/render.go`,
|
||||
`internal/worker/worker.go` — **только чтение**, правок не предполагается.
|
||||
Отсутствие правок под `internal/` и `web/` — критерий приёмки задачи.
|
||||
- Прецедент приёма — [ADR-2026-08-06-spec-follows-code-on-narrow-window](../../../docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md);
|
||||
второй ADR о том же не заводится.
|
||||
@@ -0,0 +1,367 @@
|
||||
# Триаж чекпоинта 2 — change `cancel-dismiss-split-wording`
|
||||
|
||||
Дата: 2026-08-06. Профиль: `standard`, режим: `по графу`.
|
||||
База диффа: `01e64d60de7495900d9a4c8f5ce9bf811ee7b7e6`.
|
||||
|
||||
## Сводка
|
||||
|
||||
**Гейт:** зелёный. Кодовые шаги (build/vet/lint/gofmt/test/flaky/race/
|
||||
diff-coverage/migrations/govulncheck) — `SKIP` с причиной «кода в диффе нет»;
|
||||
`canon` — `OK`; `gitleaks` — `OK`.
|
||||
|
||||
**Проходы поимённо:**
|
||||
|
||||
| Проход | Исход |
|
||||
|---|---|
|
||||
| `review-gate` | зелёный (кодовые шаги SKIP — кода в диффе нет) |
|
||||
| `review-specs` | отработал, 4 находки + 4 строки границ |
|
||||
| `review-code` | отработал, 1 находка + подтверждение A3 + блок «нарушений нет» |
|
||||
| `review-adversary` | отработал, 5 находок (4 воспроизводящих теста), 3 свойства без пути |
|
||||
| `review-reimpl` | **не запускался** — стадия 3, только `deep`; вне профиля `standard` |
|
||||
| `review-architecture` | **не запускался на этом чекпоинте** — стадия 4 (`wide`/`deep`); отработал на чекпоинте 1 в профиле `design`, находки закрыты правками предложения (design.md, D1–D3, D6–D8) |
|
||||
| `review-ops` | отработал, 3 находки + ответы на обязательные вопросы |
|
||||
|
||||
Состав запущенного сверен с профилем `standard` — расхождений нет.
|
||||
|
||||
**Счёт находок:** на входе 13 (S1–S4, C2, A1, A2, A3adv, A4, A5, O1–O3; C1 —
|
||||
подтверждение приёмки, не находка). После дедупа — 10 (S1=A3adv, C2=A4,
|
||||
S4=A5). Разложено: 3 блокируют, 4 стоит исправить сейчас, 0 гипотез,
|
||||
6 в урожай, 1 promote-кандидат.
|
||||
|
||||
**Состояние change:** файлы staged, но не закоммичены (`HEAD` == база; дифф
|
||||
worktree против базы — 5 файлов, 514 вставок, все под
|
||||
`openspec/changes/cancel-dismiss-split-wording/`). Перед архивацией нужен
|
||||
коммит — это состояние прогона, не находка.
|
||||
|
||||
**Вердикт по архивации:** change **годится к архивации после инлайн-правок**
|
||||
секций 1–2. Находки, требующей остановки или переработки предложения, нет:
|
||||
все семь пунктов — правки текста дельты, локальные и однозначные, кроме одной
|
||||
развилки (п. 7, выбор регистра нормы о подтверждении). Дефекты в коде,
|
||||
вскрытые тестами adversary, — пред-существующие, изменением не введены и не
|
||||
задеты (критерий A3 требует пустой кодовый дифф); они уходят урожаем.
|
||||
|
||||
## Приёмка A1–A3 (доклад приёмщику, не отметка «принято»)
|
||||
|
||||
- **A1** — требование различает `Cancel`/`Dismiss` по состояниям (не-терминальные
|
||||
против «любое кроме `deleted`», конфликт против no-op) и называет `error_code`
|
||||
обоих путей (`user_dismiss` против пустого). Оракул:
|
||||
`openspec validate --strict cancel-dismiss-split-wording` → `Change … is valid`,
|
||||
exit 0 (прогнано мной). **Выполнен**, с оговоркой: нормативная фраза «SHALL
|
||||
называть путь закрытия» внутренне противоречит пустому коду `Cancel` — п. 4 ниже.
|
||||
- **A2** — сценарий «Закрытие не-терминальной загрузки из веб-UI идёт отменой»
|
||||
(дельта, строки 112–119): исход `cancelled`, `error_code` назван (пустой).
|
||||
Оракул: тот же прогон + чтение сценария. **Выполнен.**
|
||||
- **A3** — `git diff --stat <база> -- internal web cmd` — **пусто** (прогнано
|
||||
мной по worktree; полный дифф — 5 файлов только под `openspec/changes/`).
|
||||
Гейт `Dismissable` и danger-zone шаблона не тронуты. **Выполнен.**
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж (архивацию)
|
||||
|
||||
Все три — корзина **(а): дефект введён текстом этого изменения**. Все три —
|
||||
правки одного-двух абзацев дельты
|
||||
`openspec/changes/cancel-dismiss-split-wording/specs/state-reconciliation/spec.md`.
|
||||
|
||||
### 1. Дельта гарантирует «`Dismiss` SHALL NOT вызывать qBittorrent» — гарантия ложна: `Dismiss` из `catched` в окне после `add` даёт `torrents/delete` с `deleteFiles=true`
|
||||
|
||||
- Файл: дельта, строки 36–47; код `internal/worker/worker.go:520–556` (уборка
|
||||
по условию `after.State != store.StateCatched`, безразлична к команде),
|
||||
`internal/httpapi/review.go:384–391` (маршрут `dismiss` по состоянию не
|
||||
гейтится — путь достижим сегодня прямым POST)
|
||||
- Severity: major, Confidence: high. Корзина: **(а)** — сам механизм уборки
|
||||
пред-существует и нормирован командо-нейтрально в `download-tracking`
|
||||
(строки 216–234); ложна только атрибуция исключения одной команде `Cancel`.
|
||||
- Оракул (прогнан мной): `go test ./tmp/adv/ -run TestDismissInAddWindow -v` →
|
||||
«ADV-3 воспроизведён: закрытие путём Dismiss (error_code="user_dismiss")
|
||||
вызвало torrents/delete deleteFiles=[true]». Плюс
|
||||
`internal/worker/catched_test.go:244–268` — штатный тест уборки ставит
|
||||
`cancelled` напрямую, не через `Cancel`.
|
||||
- Дедуп: S1 (`specs`) + A3adv (`adversary`) — одна причина: исключение в коде
|
||||
привязано к состоянию/окну, а не к команде.
|
||||
- Действие: **инлайн**. Переформулировать исключение по условию, а не по
|
||||
команде: «закрытие **любым путём**, уведшее задачу из `catched` в окне между
|
||||
нашим `add` и записью перехода» — как это уже сформулировано в
|
||||
`download-tracking`. Тем же правом убрать из абзаца атрибуцию «`Cancel` тем же
|
||||
ограничен, с одним исключением» — исключение общее для обоих путей.
|
||||
- **В ту же правку входит C2/A4** (дедуп: одна ссылка, одно предложение):
|
||||
markdown-ссылка `../../../specs/download-tracking/spec.md` (строка 44) не
|
||||
резолвится **ни из позиции дельты** (нормализуется в
|
||||
`openspec/changes/specs/download-tracking/spec.md`, файла нет; верно было бы
|
||||
4 уровня), **ни после архивации** (из `openspec/specs/state-reconciliation/`
|
||||
нормализуется в `specs/download-tracking/spec.md`; верно — 1 уровень).
|
||||
Единого относительного пути, верного в обеих позициях, не существует.
|
||||
Проверено арифметикой пути (`os.path.normpath` + `exists`, прогнано мной).
|
||||
Фикс: дать ссылку **бэктиком** — `openspec/specs/download-tracking/spec.md` —
|
||||
как во всех соседних кросс-ссылках capability (прецедент:
|
||||
`openspec/specs/state-reconciliation/spec.md:92`); markdown-ссылок в
|
||||
`openspec/specs/` нет ни одной (grep пуст), и `docs.py check` этот каталог
|
||||
не проверяет — битую ссылку не покрасил бы никто.
|
||||
|
||||
### 2. Дельта заявляет, что уборка в окне после `add` «данных пользователя не касается» — безусловное утверждение сильнее того, что гарантирует код: пред-существующие файлы под `paths.downloads` уничтожаются
|
||||
|
||||
- Файл: дельта, строки 45–46; код `internal/worker/worker.go:495–556` (гард
|
||||
«подтверждённое отсутствие» проверяет отсутствие **торрента**, не отсутствие
|
||||
**данных**)
|
||||
- Severity: major (для текста; кодовый дефект — кандидат `critical`, см. урожай
|
||||
У-1), Confidence: high для текстовой части. Корзина: **(а)** для текста /
|
||||
**(в)** для кода — дыра в гарде пред-существует, изменением не задета; этим
|
||||
change введена только ложная гарантия.
|
||||
- Оракул (прогнан мной): `go test ./tmp/adv/ -run TestCancelInAddWindow -v` →
|
||||
«ADV-1 воспроизведён: worker вызвал torrents/delete deleteFiles=[true];
|
||||
данные …/downloads/Show/e1.mkv существовали под paths.downloads ДО add
|
||||
(гард проверял отсутствие торрента, а не отсутствие данных)».
|
||||
- Почему текст — major, а не перенос `critical` с кода: текст сам данных не
|
||||
уничтожает; но, будучи влит в нормативный дом, он узаконивает дыру — следующий
|
||||
аудит поверит «данных не касается» и закроет расследование. Severity кода на
|
||||
severity текста автоматически не переносится; кодовый дефект едет урожаем со
|
||||
своим весом.
|
||||
- Действие: **инлайн**. Убрать безусловное «и данных пользователя не касается».
|
||||
Right-size замена — отослать к дому нормы без собственной гарантии
|
||||
(«нормирована в `download-tracking`»), не расширяя дельту признанием дыры:
|
||||
дыра — предмет задачи У-1, а не этого требования.
|
||||
|
||||
### 3. Обещание «библиотечные ссылки без штатной команды снятия **до повторной привязки**» ложно: перепривязка их не снимает никогда — `Undo`/`Delete` работают только с последним батчем
|
||||
|
||||
- Файл: дельта, строки 62–66 и сценарий 93–101; код
|
||||
`internal/worker/review.go:332` (каждый `Apply` — новый батч), `:557–572`
|
||||
(`Undo` — только `LatestBatchID`), `:625–641` (`Delete` — только
|
||||
`LatestBatchID`)
|
||||
- Severity: major, Confidence: high. Корзина: **(а)** для формулировки /
|
||||
**(в)** для кода (поведение батчей пред-существует, изменением не задето).
|
||||
- Оракул (прогнан мной): `go test ./tmp/adv/ -run 'TestDismissRelink|TestStrandedLink' -v` →
|
||||
«ADV-2: в библиотеке осталась неуправляемая ссылка (батч 1), которую не
|
||||
снимает ни Undo, ни Delete»; «ADV-2b: запись в deleted, раздача снесена с
|
||||
файлами, но в библиотеке осталась неуправляемая ссылка с живыми данными —
|
||||
место не освобождено».
|
||||
- Последствие текста: единственный маршрут к брошенной ссылке — ровно та связка
|
||||
`Dismiss` из `done` → relink, которую дельта нормирует; «до повторной
|
||||
привязки» называет relink лекарством, которым он не является, а после него
|
||||
«полное удаление» перестаёт освобождать место — danger-зона обещает обратное.
|
||||
- Действие: **инлайн**. Заменить «до повторной привязки» честным: «…без штатной
|
||||
команды снятия; последующая перепривязка их **не** снимает — `Undo` и
|
||||
`Delete` работают только с последним батчем раскладки». Расширение
|
||||
`Undo`/`Delete` на все батчи — урожай У-2.
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 4. Нормативное «`error_code` SHALL называть путь закрытия» нарушено пустым кодом `Cancel` и опровергнуто тем же документом тремя абзацами ниже
|
||||
|
||||
- Файл: дельта, строка 49; код `internal/worker/worker.go:985`
|
||||
(`SetDownloadState(ctx, id, StateCancelled, "", "")` — проверено чтением)
|
||||
- Severity: major, Confidence: high. Корзина: **(а)** — фраза введена этим
|
||||
изменением.
|
||||
- Оракул: код (пустой код ничего не «называет») + сам документ: строки 56–58
|
||||
(«пустота кода на пути `Cancel` … не закреплена нормативно») и 73–82
|
||||
(«известное ограничение»). `SHALL`, который документ сам объявляет
|
||||
неудовлетворённым, — приглашение следующему аудиту завести ту же задачу.
|
||||
- Действие: **инлайн**. Перевести фразу в согласованный регистр: например,
|
||||
«`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать
|
||||
их» (пустой у `Cancel` против `user_dismiss` у `Dismiss` — различает), либо
|
||||
сузить нормативность до `Dismiss` (его `SHALL user_dismiss` уже стоит строкой
|
||||
ниже). Остальной абзац и «известное ограничение» уже согласованы.
|
||||
|
||||
### 5. «Прежняя диагностика остаётся только в логе перехода» — фактически неверно: лог-строки перехода `Cancel`/`Dismiss` прежних `error_code`/`error_msg` не содержат
|
||||
|
||||
- Файл: дельта, строки 53–58 и сценарий 103–110; код
|
||||
`internal/worker/worker.go:985–991, 1016–1022` — проверено чтением: `Cancel`
|
||||
логирует только `from`/`to`, `Dismiss` — `from`/`to`/`code` **нового**
|
||||
перехода; контракт поля `code` — `docs/conventions/logging.md:26–30`.
|
||||
- Severity: major, Confidence: high. Корзина: **(а)** для формулировки /
|
||||
**(б)** для наблюдаемости (лог и раньше не нёс прежней диагностики — урожай
|
||||
У-3).
|
||||
- Последствие: причина падения восстановима только по более ранней, никак не
|
||||
связанной по смыслу лог-строке, чья доступность зависит от неописанной
|
||||
ретенции логов (`docs/architecture.md` — только «логи в stdout контейнера»).
|
||||
- Действие: **инлайн**. Поправить формулировку: прежние код и текст «в записи
|
||||
не сохраняются; лог-строка перехода их не дублирует — они остаются лишь в
|
||||
более ранних строках лога состояния». Добавление `prev_error_code`/`prev_error_msg`
|
||||
в лог — кодовая правка, запрещённая критерием A3, → урожай У-3.
|
||||
|
||||
### 6. Описанная раскладка Telegram врёт в трёх клетках построенной по ней таблицы «состояние × поверхность»
|
||||
|
||||
- Файл: дельта, строки 30–34; код `internal/tgbot/bot.go:452–461`,
|
||||
`internal/tgbot/render.go:44–50, 281–290` — проверено чтением: уведомление
|
||||
`EventDone` — текст без клавиатуры; `EventTargetMissing`/`EventOrphaned` —
|
||||
только ссылка в веб (`webOnly`, и та `nil` при пустом `web_base_url`);
|
||||
`StateLinking` — клавиатуры нет вовсе (не «отсылает в веб»). «Закрыть»
|
||||
появляется на **карточке** (`renderCard`/refresh), а не в уведомлении.
|
||||
- Severity: minor, Confidence: high. Корзина: **(а)** — перечень введён этим
|
||||
изменением (текст описателен: «сегодня раскладка такова», но неверен).
|
||||
- Последствие: реальный пробел — уведомление о рассинхроне приходит без
|
||||
стоп-крана — текстом замаскирован.
|
||||
- Действие: **инлайн**. Либо уточнить: «Telegram даёт „Закрыть“ на **карточке**
|
||||
загрузки в `failed`/`stuck`/`done`/`orphaned`/`target_missing`; уведомления
|
||||
о `done` и рассинхроне кнопок закрытия не несут», либо снять перечень
|
||||
Telegram целиком, оставив нормативную гарантию только за страницей загрузки
|
||||
(раскладка по поверхностям всё равно объявлена ненормативной).
|
||||
|
||||
### 7. Новый `SHALL` об «отдельном подтверждающем шаге» въезжает уже нарушенным: без JS диалога нет, в Telegram нет ни danger-зоны, ни подтверждения
|
||||
|
||||
- Файл: дельта, строки 68–71; код
|
||||
`web/templates/partials/download_main.html:95–98` — проверено чтением,
|
||||
комментарий дословно: «без JS гейт — только раскрытие details и явный submit
|
||||
(диалога нет)»; `internal/tgbot/render.go:266–290` — «Закрыть» в общем ряду
|
||||
кнопок. `openspec/specs/web-ui/spec.md` нормирует «без JavaScript не
|
||||
ломается».
|
||||
- Severity: minor, Confidence: medium. Корзина: **(а)** — прежняя редакция
|
||||
обязательства не содержала (уточнение adversary), норма введена этим
|
||||
изменением; тот же изъян в пред-существующем требовании «Полное удаление
|
||||
загрузки пользователем» — корзина (в), урожай У-6.
|
||||
- Дедуп: S4 (`specs`) + A5 (`adversary`) — одна причина.
|
||||
- Действие: **развилка** — выбор регистра нормы, а не правка формулировки:
|
||||
1. **Сузить норму до фактического гейта**: «„Закрыть“ SHALL размещаться в
|
||||
обособленной danger-зоне, скрытой по умолчанию (раскрытие — сознательный
|
||||
шаг); с JS дополнительно SHALL показываться диалог подтверждения» + назвать
|
||||
известным ограничением, что Telegram danger-зоны не даёт. Цена: слабее
|
||||
звучит R7, зато спека не лжёт и код не требуется.
|
||||
2. **Оставить `SHALL` как цель** и завести урожаем задачу: no-JS-подтверждение
|
||||
(например, промежуточная страница-подтверждение) и danger-гейт в Telegram.
|
||||
Цена: до выполнения задачи нормативный дом содержит невыполненный `SHALL`
|
||||
(ровно то, за что бьёт п. 4).
|
||||
Рекомендация триажа — вариант 1: он согласуется с R12 («спека фиксирует
|
||||
наблюдаемое») и с решением п. 4.
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
Пусто. Все выжившие находки имеют прогнанный оракул (тест, чтение названных
|
||||
строк кода, арифметика пути, вывод команды). Понижений за отсутствие оракула
|
||||
не было; `critical` кода из A1 не понижен, а отделён от текстовой находки и
|
||||
уехал урожаем со своим весом (У-1) — его единственный непрогнанный шаг назван
|
||||
там честно.
|
||||
|
||||
Отдельно: совпадение S1/A3adv и S4/A5 в разных проходах учтено как рост
|
||||
приоритета, не как рост Confidence — под всеми проходами одна модель.
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **Проверка ссылок в `openspec/`** — шаг `canon` (`docs.py check`) обходит
|
||||
только `docs/` и `CLAUDE.md`; битую markdown-ссылку в спеке не красит никто
|
||||
(п. 1/C2 прожил бы до архивации молча). Кандидат: расширить проверку битых
|
||||
ссылок на `openspec/specs/` и `openspec/changes/`, либо записать конвенцией
|
||||
«кросс-ссылки в спеках — только бэктиком» (сегодня это фактическая, но
|
||||
нигде не записанная практика: markdown-ссылок в `openspec/specs/` ноль).
|
||||
Провенанс: `review-code` C2 + `review-adversary` A4.
|
||||
|
||||
## Урожай (пред-существующие дефекты — владельцу задач, не в этот change)
|
||||
|
||||
- **У-1 (кандидат `critical`, Confidence: medium).** Отмена/закрытие в окне
|
||||
после `add` сносит **пред-существующие** файлы пользователя под
|
||||
`paths.downloads`: гард «подтверждённое отсутствие» (`worker.go:495–509`)
|
||||
проверяет отсутствие торрента в qBittorrent, но не отсутствие данных на
|
||||
save_path; `qbt.Add` подхватывает лежащие там файлы, уборка бьёт
|
||||
`torrents/delete` с `deleteFiles=true`. Путь: пользователь снял раздачу,
|
||||
файлы оставив (это предписывает сам `download-tracking:169–174` как
|
||||
восстановление) → подал тот же торрент → отменил в окне листинг+add (сотни
|
||||
мс — секунды). Инвариант «источник неприкосновенен»: исключение (2) покрывает
|
||||
«собственный торрент», но не пред-существующие данные; UI при этом обещает
|
||||
«файлы и раздача останутся нетронутыми» (`download_main.html:100–111`).
|
||||
Оракул: `tmp/adv/adv_test.go`, `TestCancelInAddWindowDeletesPreExistingData` —
|
||||
прогнан, воспроизводит (каталог gitignored — при заведении задачи тест
|
||||
перенести в её материалы). Единственный непрогнанный шаг — семантика боевого
|
||||
qBittorrent `deleteFiles=true` над пред-существующими файлами: в бой ходить
|
||||
запрещено, отсюда medium. Провенанс: `review-adversary` A1, чекпоинт 2
|
||||
change `cancel-dismiss-split-wording`.
|
||||
- **У-2 (major, Confidence: high).** `Undo`/`Delete` работают только с
|
||||
последним батчем (`review.go:557–572, 625–641`), каждый `Apply` создаёт новый
|
||||
(`review.go:332`): связка «закрыть `done` → relink» бросает ссылку батча 1
|
||||
навсегда, а «полное удаление» после этого не освобождает место (инод жив за
|
||||
счёт брошенной ссылки). Оракул: `TestDismissRelinkStrandsLibraryLinks`,
|
||||
`TestStrandedLinkDefeatsDelete` — прогнаны, воспроизводят. Провенанс:
|
||||
`review-adversary` A2.
|
||||
- **У-3 (minor, наблюдаемость).** Лог-строки переходов `Cancel`/`Dismiss` не
|
||||
несут прежних `error_code`/`error_msg`, хотя `d` их держит в момент записи
|
||||
(`worker.go:985–991, 1016–1022`): «почему задача упала» после закрытия не
|
||||
восстановить одной строкой. Кандидат: `prev_error_code`/`prev_error_msg` в
|
||||
лог перехода. Оракул: чтение кода + контракт `logging.md:26–30`. Провенанс:
|
||||
`review-ops` O1.
|
||||
- **У-4 (minor).** «Per-download блокировка» из `CLAUDE.md` (инвариант
|
||||
«переходы состояний») и `worker.go:11` — фактически один глобальный
|
||||
`sync.Mutex` (`worker.go:239`), удерживаемый `Apply` на весь файловый
|
||||
ввод-вывод: долгая раскладка блокирует команды по **другим** загрузкам и
|
||||
поллинг. Минимум — поправить формулировку в двух местах; по существу —
|
||||
per-id блокировка либо вынос I/O из-под замка. Оракул: чтение кода (одно
|
||||
поле, не карта). Провенанс: `review-ops` O2.
|
||||
- **У-5 (minor, эффект — гипотеза при сегодняшнем профиле «единицы/день»).**
|
||||
Дефолтный список — `SCAN download` (негативный `state NOT IN` мимо
|
||||
`idx_download_state`, измерено `EXPLAIN QUERY PLAN` на копии схемы), а
|
||||
`cancelled` копятся без ретенции, и требование под ревью расширяет канал их
|
||||
производства. Частичный индекс или позитивный список состояний; ретеншен —
|
||||
задача в беклоге уже есть. Провенанс: `review-ops` O3.
|
||||
- **У-6 (minor).** Пред-существующее требование «Полное удаление загрузки
|
||||
пользователем» (`openspec/specs/state-reconciliation/spec.md:463–465`) несёт
|
||||
ту же формулировку о подтверждающем шаге с той же no-JS-дырой, что п. 7.
|
||||
Судьба зависит от развилки п. 7 — править согласованно. Провенанс:
|
||||
`review-specs` S4 (смягчающее наблюдение).
|
||||
|
||||
Уже назначено урожаем ранее (не дублирую задачей): R10 из рубрики чекпоинта 1 —
|
||||
«допустимость команды переоценивается на момент исполнения, а не по снимку
|
||||
рендера» (`tasks.md`); туда же примыкает граница `specs` про `/review/{id}`,
|
||||
открытый для любого состояния.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Запускалось:** профиль `standard`, режим «по графу», чекпоинт 2 (после
|
||||
apply). `review-gate` (кодовые шаги SKIP — кода в диффе нет; canon, gitleaks —
|
||||
OK), `review-specs`, `review-code`, `review-adversary`, `review-ops`.
|
||||
|
||||
**Не запускалось:** `review-reimpl` — стадия 3, только в `deep`;
|
||||
`review-architecture` — стадия 4 (`wide`/`deep`); оба вне профиля `standard`.
|
||||
`review-architecture` отработал на чекпоинте 1 (профиль `design`) вместе с
|
||||
`review-specs` и `review-rubric`; его находки закрыты правками предложения
|
||||
(design.md, решения D1–D3, D6–D8) и на этом чекпоинте не перепроверялись.
|
||||
|
||||
**Что запущенные проходы не могли проверить в принципе (из charter'ов):**
|
||||
|
||||
- `specs`: поведение живых qBittorrent/Telegram — клавиатуры Telegram не
|
||||
покрыты тестами вовсе, сверка была чтением кода; плюс его заявленные границы:
|
||||
владение целевым путём после закрытия `done` (ушло в У-2), `/review/{id}`
|
||||
без гейта состояния (примыкает к R10), «активный список» как три разных
|
||||
множества (`store/list.go`, `store/download.go`).
|
||||
- `code`: только записанные конвенции; конвенции о форме OpenSpec-спек в
|
||||
`docs/conventions/` нет — норму задавали `openspec/config.yaml` и `CLAUDE.md`.
|
||||
- `adversary`: семантика **боевого** qBittorrent (`deleteFiles=true` над
|
||||
пред-существующими файлами) — в бой ходить запрещено; это единственный
|
||||
непрогнанный шаг У-1. Свойства без пути: снятие последней копии
|
||||
последовательностью команд не строится; секретов в `error_msg`/логах не
|
||||
найдено; негейченный маршрут `dismiss` в сегодняшнем периметре
|
||||
(`docs/security.md`) — операторское действие, не дефект (совпадает с
|
||||
«Типовым ложноположительным» про отсутствие авторизации — корректно не
|
||||
выведен находкой).
|
||||
- `ops`: `-race` на этом диффе не гонялся (кода нет — SKIP); гонки суждены
|
||||
рассуждением. Ретенция логов stdout нигде не описана — доступность «более
|
||||
ранней лог-строки» из У-3 неизвестна.
|
||||
- триаж (я): нового не ищу по определению — работаю с чужими выводами; пропуск
|
||||
любого прохода — мой пропуск тоже. Тесты `tmp/adv/` прогнаны и воспроизводят,
|
||||
но их фикстуры — фейковый qbt и локальная ФС: они доказывают поведение
|
||||
**нашего** кода, не связки с боем.
|
||||
|
||||
**Осталось целиком на человеке** (из `docs/review.md`, два списка раздельно):
|
||||
|
||||
*Не проверит ни один проход:*
|
||||
- история инцидентов на umbar и что уже ломалось в проде;
|
||||
- поведение SQLite под реальным объёмом и профилем нагрузки;
|
||||
- завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее
|
||||
поведение;
|
||||
- качество распознавания (корпус решено не собирать, REJECTED 2026-08-06);
|
||||
- суждение «этой функциональности не должно существовать».
|
||||
|
||||
*Перестали проверять сознательно:*
|
||||
- идиоматичность Go — с 2026-08-04, вместе с упразднением прохода `idiom` при
|
||||
переезде на плагин; различение «идиоматично против распространено» не
|
||||
спрашивает никто; пересмотр — задача `quality-review-agents`.
|
||||
|
||||
**Каких документов/фактов не хватило (поразрядно, с причиной):**
|
||||
|
||||
- `docs/conventions/` — конвенции о форме OpenSpec-спек нет: правило «ссылки
|
||||
бэктиком, не markdown» пришлось выводить grep'ом по фактике (`review-code`);
|
||||
- `docs/architecture.md` — ретенция логов не описана (только «stdout
|
||||
контейнера»): судьба «прежней диагностики в логе» из У-3 неопределима
|
||||
(`review-ops`);
|
||||
- `docs/review.md`, журнал дефектов — пуст (заведён 2026-07-23): оракулов
|
||||
«такое здесь уже воспроизводилось» не было ни для одной находки, все
|
||||
подтверждения добывались тестами и чтением кода (триаж);
|
||||
- `CLAUDE.md`, инварианты — присутствуют и использованы (ранжирование У-1 по
|
||||
«необратимо» и границе исключения (2)); «Типовые ложноположительные» и оба
|
||||
подраздела «Недоступно проверке» — присутствуют и использованы. Здесь
|
||||
пробелов нет.
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Ручное закрытие загрузки (стоп-кран)
|
||||
|
||||
Система SHALL давать пользователю возможность **закрыть** загрузку — перевести
|
||||
её в терминальное `cancelled`, убрав из активного списка/внимания, — из
|
||||
**любого** состояния, кроме `deleted`. Страница загрузки веб-UI
|
||||
(`/download/{id}`) SHALL предоставлять путь закрытия из каждого не-`deleted` и
|
||||
не-`cancelled` состояния; список загрузок (`/`) ограничиваться действиями над
|
||||
активными загрузками MAY. Загрузку в `deleted` закрывать система SHALL NOT
|
||||
(состояние строго терминально).
|
||||
|
||||
Путей закрытия **два**, и они разведены гейтом терминальности:
|
||||
|
||||
- **«Отменить»/«Отклонить»** (`Cancel`) — отмена **активной** работы. Команда
|
||||
SHALL быть доступна только из не-терминальных состояний и SHALL отклоняться
|
||||
с конфликтом из терминальных, включая `cancelled`.
|
||||
- **«Закрыть»** (`Dismiss`) — стоп-кран для загрузки, которую уже никто не
|
||||
двигает: зависшей, спорной или лишней (в т.ч. дубля-близнеца в
|
||||
`target_missing`, чьи файлы разложены другой загрузкой). Команда SHALL быть
|
||||
доступна из любого состояния, кроме `deleted`, включая терминальные, где
|
||||
`Cancel` уже отказывает. В `cancelled` `Dismiss` SHALL быть идемпотентным
|
||||
no-op **без записи состояния** — иначе он подменил бы `error_code` прежнего
|
||||
закрытия.
|
||||
|
||||
Раскладка путей по поверхностям нормативной **не является**: поверхность
|
||||
выбирает путь по состоянию, и требовать от каждой поверхности **оба** пути
|
||||
система SHALL NOT. Сегодня раскладка такова: страница загрузки веб-UI даёт
|
||||
«Закрыть» в danger-зоне из терминальных состояний, кроме `deleted` и
|
||||
`cancelled`, а из не-терминальных закрывает кнопкой «Отменить» (на экране
|
||||
ревью — «Отклонить»); Telegram даёт «Закрыть» из `failed`, `stuck`, `done`,
|
||||
`orphaned`, `target_missing`; «Отклонить» — на карточке ревью
|
||||
(`review`/`deferred`). Кнопка закрытия в Telegram живёт на **карточке** задачи;
|
||||
уведомления о готовности и о рассинхроне её сегодня не несут, а из `linking`
|
||||
карточка не даёт действий вовсе. REST закрытие предоставлять MAY (сегодня
|
||||
отдаёт только `cancel`).
|
||||
|
||||
Ни один путь закрытия SHALL NOT производить действий с файлами или библиотечными
|
||||
ссылками: система SHALL NOT удалять либо создавать хардлинки под
|
||||
`paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие библиотечные
|
||||
ссылки сознательно остаются на месте. qBittorrent закрытие вызывать SHALL NOT —
|
||||
раздача не снимается и продолжает раздаваться, — с **одним** исключением, и оно
|
||||
привязано к состоянию, а не к команде: если закрытие **любым** путём увело
|
||||
задачу из `catched` в окне между нашим `add` и записью перехода, worker убирает
|
||||
только что добавленный торрент. Уборка нормирована в
|
||||
`openspec/specs/download-tracking/spec.md`, требование «Добавление пойманной
|
||||
загрузки в qBittorrent», и её границы задаёт оно — здесь они не пересказываются
|
||||
и не расширяются. Синхронный source-preflight ни один из путей выполнять SHALL
|
||||
NOT (источник в действии не участвует).
|
||||
|
||||
`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать их.
|
||||
`Dismiss` SHALL помечать переход `error_code = "user_dismiss"`
|
||||
(человекочитаемая причина — в `error_msg`), отличая стоп-кран от отклонения на
|
||||
ревью, от отмены активной работы и от удаления. Оба пути при этом SHALL замещать
|
||||
прежнюю диагностику записи: `Dismiss` — на `user_dismiss` с причиной закрытия,
|
||||
`Cancel` — сегодня на пустые `error_code`/`error_msg`; прежний код состояния
|
||||
(`stalled` у `stuck`, `qbit_error` у `failed`) из записи пропадает безвозвратно.
|
||||
Лог перехода закрытия его не дублирует: восстановить причину можно только по
|
||||
более ранней строке перехода **в** `failed`/`stuck`, пока её держит ретенция
|
||||
логов. Пустота кода на пути `Cancel` описана как сегодняшнее поведение, а не
|
||||
закреплена нормативно: собственный непустой код у `Cancel` требованием не
|
||||
запрещён.
|
||||
|
||||
Новый статус ни для одного из путей система вводить SHALL NOT —
|
||||
переиспользуется существующее терминальное `cancelled` (сверка его не
|
||||
переоценивает). Из `cancelled` пользователю остаётся доступной перепривязка
|
||||
(relink), если он передумает; при этом `Undo` и `Delete` из `cancelled`
|
||||
доступны SHALL NOT (их пол — `done`/`orphaned`/`target_missing`). Закрытие
|
||||
разложенной загрузки поэтому оставляет её библиотечные ссылки без штатной
|
||||
команды снятия, и **перепривязка их не снимает**: `Undo` и `Delete` работают
|
||||
только с последним батчем раскладки, а повторное применение заводит новый.
|
||||
Снять ссылки прежнего батча система средствами не даёт.
|
||||
|
||||
В интерфейсе «Закрыть» SHALL размещаться в отдельной «danger zone» (напр. внизу
|
||||
страницы загрузки), обособленно от штатных действий, и SHALL быть отделена от
|
||||
одиночного клика **дополнительным шагом**: раскрытием danger-зоны и явным
|
||||
подтверждением там, где транспорт его поддерживает (диалог веб-UI при доступном
|
||||
JS, второй шаг клавиатуры в Telegram). «Отменить»/«Отклонить» подтверждения
|
||||
требовать SHALL NOT: команда отменяет работу, которая ещё идёт.
|
||||
|
||||
Отсюда следует **известное ограничение**: по `error_code` отличима «загрузка
|
||||
закрыта стоп-краном» от «загрузка отменена активной», но не «пользователь
|
||||
закрыл активную загрузку» от «пользователь отменил её» — оба пути на
|
||||
не-терминальном состоянии дают один и тот же пустой код. Хуже того, маркер
|
||||
сегодня кодирует не намерение, а поверхность: одно и то же состояние `stuck`
|
||||
закрывается из веб-UI отменой (пустой код), а из Telegram стоп-краном
|
||||
(`user_dismiss`). Цена — потеря различения в логах и диагностике; поведение и
|
||||
данные не страдают. Восстановить различение можно, дав `Cancel` собственный
|
||||
непустой код, — это требованием разрешено и оставлено открытым вопросом, а не
|
||||
отвергнуто.
|
||||
|
||||
#### Scenario: Закрытие записи без цели не трогает раздачу
|
||||
|
||||
- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent,
|
||||
целевых хардлинков нет (напр. её файлы разложены другой загрузкой)
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** раздача с файлами в qBittorrent не удаляется
|
||||
- **AND** запись пропадает из активного списка
|
||||
|
||||
#### Scenario: Закрытие done оставляет библиотечные файлы на месте
|
||||
|
||||
- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** библиотечные хардлинки не удаляются
|
||||
- **AND** раздача в qBittorrent не снимается
|
||||
- **AND** команды `Undo` и `Delete` на записи становятся недоступны, а
|
||||
«Привязать заново» остаётся доступной
|
||||
- **AND** после перепривязки и повторного применения ссылки прежнего батча
|
||||
остаются в библиотеке — их не снимает ни `Undo`, ни `Delete`
|
||||
|
||||
#### Scenario: Стоп-кран замещает прежнюю диагностику состояния
|
||||
|
||||
- **GIVEN** загрузка в `failed` с `error_code = "qbit_error"` и текстом ошибки
|
||||
в `error_msg`
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** прежний код и текст ошибки в записи не сохраняются
|
||||
- **AND** лог перехода закрытия их не дублирует — причина отказа восстановима
|
||||
только по более раннему переходу в `failed`
|
||||
|
||||
#### Scenario: Закрытие не-терминальной загрузки из веб-UI идёт отменой
|
||||
|
||||
- **GIVEN** загрузка в не-терминальном состоянии (`downloading`, `recognizing`,
|
||||
`review`, `deferred`, `stuck`), её страница открыта в веб-UI
|
||||
- **WHEN** пользователь нажимает «Отменить» (на экране ревью — «Отклонить»)
|
||||
- **THEN** запись переходит в `cancelled` и пропадает из активного списка
|
||||
- **AND** `error_code` и `error_msg` перехода пусты (маркера `user_dismiss` на
|
||||
этом пути нет)
|
||||
- **AND** ни раздача в qBittorrent, ни хардлинки не трогаются
|
||||
|
||||
#### Scenario: Стоп-кран доступен там, где отмена уже отказывает
|
||||
|
||||
- **GIVEN** загрузка в терминальном состоянии, кроме `deleted` и `cancelled`
|
||||
(`done`, `failed`, `reverted`, `target_missing`, `orphaned`)
|
||||
- **WHEN** пользователь даёт команду «Закрыть» и подтверждает её
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** команда `Cancel` на том же состоянии отклоняется с конфликтом
|
||||
|
||||
#### Scenario: Один и тот же stuck закрывается разными путями с разных поверхностей
|
||||
|
||||
- **GIVEN** загрузка в `stuck`
|
||||
- **WHEN** пользователь закрывает её из Telegram кнопкой «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** та же загрузка, закрытая со страницы веб-UI кнопкой «Отменить», даёт
|
||||
`cancelled` с пустым `error_code` — по коду перехода поверхности не
|
||||
различить от намерения
|
||||
|
||||
#### Scenario: «Закрыть» недоступна для deleted
|
||||
|
||||
- **GIVEN** загрузка в `deleted`
|
||||
- **WHEN** пользователь пытается вызвать «Закрыть»
|
||||
- **THEN** команда недоступна, состояние остаётся `deleted`
|
||||
|
||||
#### Scenario: Повторное закрытие не подменяет причину прежнего
|
||||
|
||||
- **GIVEN** загрузка в `cancelled`, закрытая ранее отменой (пустой `error_code`)
|
||||
- **WHEN** приходит команда «Закрыть»
|
||||
- **THEN** команда проходит no-op'ом, записи состояния не происходит
|
||||
- **AND** `error_code` остаётся прежним (пустым), а не подменяется на
|
||||
`user_dismiss`
|
||||
- **AND** команда «Отменить» на том же состоянии отклоняется с конфликтом
|
||||
|
||||
#### Scenario: Закрытую запись можно привязать заново
|
||||
|
||||
- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
|
||||
- **WHEN** пользователь даёт команду «Привязать заново»
|
||||
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
|
||||
из `cancelled`)
|
||||
@@ -0,0 +1,64 @@
|
||||
## 1. Спека
|
||||
|
||||
- [x] 1.1 Влить дельту в `openspec/specs/state-reconciliation/spec.md`
|
||||
(требование «Ручное закрытие загрузки (стоп-кран)» целиком) — делает
|
||||
`opsx:archive`/`opsx:sync`, руками спека не правится.
|
||||
- [x] 1.2 Прогнать `openspec validate --strict cancel-dismiss-split-wording`.
|
||||
|
||||
## 2. Код
|
||||
|
||||
- [x] 2.1 Правок нет и быть не должно: `internal/httpapi/download.go`,
|
||||
`web/templates/partials/download_main.html`, `internal/tgbot/render.go`,
|
||||
`internal/worker/worker.go` — только чтение. Проверка — пустой
|
||||
`git diff --stat` под `internal/` и `web/`.
|
||||
|
||||
## 3. Верификация
|
||||
|
||||
- [x] 3.1 `task gate` зелёный.
|
||||
- [x] 3.2 Ревью изменения (второй чекпоинт) проведено, триаж сохранён в
|
||||
`openspec/changes/cancel-dismiss-split-wording/review/`.
|
||||
|
||||
## Рубрика ревью предложения (проход `review-rubric`, чекпоинт 1)
|
||||
|
||||
Свойства, по которым судится требование о пользовательской команде, переводящей
|
||||
сущность в терминальное состояние. Порождены до чтения предмета; здесь стоят
|
||||
приёмочными критериями наравне с A1–A3.
|
||||
|
||||
- [x] R1. Отрицания побочных эффектов сверены с carve-out'ами инвариантов: у
|
||||
каждого «SHALL NOT трогать X» есть либо сценарий, либо ссылка на требование,
|
||||
где X всё-таки трогается.
|
||||
- [x] R2. Пол доступности задан перечислением состояний, названо, что бывает при
|
||||
вызове из недопустимого (конфликт или no-op).
|
||||
- [x] R3. Идемпотентность повтора определена **по каждому пути отдельно**, без
|
||||
обобщающего «закрытие».
|
||||
- [x] R4. Различимость инициатора зафиксирована литералом; где различение
|
||||
теряется — записано ограничением, а не умолчано.
|
||||
- [x] R5. Судьба прежней диагностики названа для обоих путей.
|
||||
- [x] R6. Обязательства поверхностей фальсифицируемы: по тексту строится таблица
|
||||
«состояние × поверхность»; круговых формулировок нет.
|
||||
- [x] R7. Подтверждение опасного нормировано в теле требования, а не только в
|
||||
сценарии одной поверхности — **по фактическому гейту**: обособление плюс
|
||||
дополнительный шаг там, где транспорт его поддерживает (чекпоинт 2, D11).
|
||||
- [x] R8. Названо, какие штатные команды из полученного терминального состояния
|
||||
отпадают.
|
||||
- [x] R9. Конкурентность разрешена правилом «кто первый» (единый сериализующий
|
||||
узел).
|
||||
- [ ] R10. Допустимость команды переоценивается на момент исполнения, а не по
|
||||
снимку рендера. **Не закрыто этим изменением** — отдано урожаем: свойство
|
||||
общее для всех команд проекта, дом ему не в этом требовании.
|
||||
- [x] R11. Терминальность имеет описанный выход (relink) и согласована со
|
||||
сверкой.
|
||||
- [x] R12. Спека фиксирует наблюдаемое, а не внутреннее: косметическая правка UI
|
||||
не делает её ложной.
|
||||
|
||||
## Критерии приёмки (из постановки `docs/tasks/items/dismiss-marker-lost.md`)
|
||||
|
||||
- [x] A1. Требование спеки различает `Cancel` и `Dismiss` по состояниям и
|
||||
называет `error_code` для каждого пути закрытия.
|
||||
**Оракул:** `openspec validate --strict`.
|
||||
- [x] A2. В спеке есть сценарий «пользователь закрывает не-терминальную
|
||||
загрузку из веб-UI» с исходом `cancelled` и названным `error_code`.
|
||||
**Оракул:** тот же прогон.
|
||||
- [x] A3. Гейт `Dismissable` в коде и danger-zone шаблона остаются как есть.
|
||||
**Оракул:** `git diff --stat` в отчёте ревью — файлов под `internal/` и
|
||||
`web/` нет.
|
||||
@@ -540,31 +540,92 @@ SHALL задевать раскладку в полёте.
|
||||
|
||||
### Requirement: Ручное закрытие загрузки (стоп-кран)
|
||||
|
||||
Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss),
|
||||
доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и
|
||||
Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное
|
||||
`cancelled`, убирая её из активного списка/внимания, и SHALL служить
|
||||
универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего
|
||||
дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для
|
||||
загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в
|
||||
`cancelled` команда SHALL быть идемпотентным no-op.
|
||||
Система SHALL давать пользователю возможность **закрыть** загрузку — перевести
|
||||
её в терминальное `cancelled`, убрав из активного списка/внимания, — из
|
||||
**любого** состояния, кроме `deleted`. Страница загрузки веб-UI
|
||||
(`/download/{id}`) SHALL предоставлять путь закрытия из каждого не-`deleted` и
|
||||
не-`cancelled` состояния; список загрузок (`/`) ограничиваться действиями над
|
||||
активными загрузками MAY. Загрузку в `deleted` закрывать система SHALL NOT
|
||||
(состояние строго терминально).
|
||||
|
||||
Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с
|
||||
файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не
|
||||
снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки
|
||||
под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие
|
||||
библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight
|
||||
«Закрыть» выполнять SHALL NOT (источник в действии не участвует).
|
||||
Путей закрытия **два**, и они разведены гейтом терминальности:
|
||||
|
||||
Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина —
|
||||
в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от
|
||||
удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется
|
||||
существующее терминальное `cancelled` (сверка его не переоценивает). Из
|
||||
`cancelled` пользователю остаётся доступной перепривязка (relink), если он
|
||||
передумает.
|
||||
- **«Отменить»/«Отклонить»** (`Cancel`) — отмена **активной** работы. Команда
|
||||
SHALL быть доступна только из не-терминальных состояний и SHALL отклоняться
|
||||
с конфликтом из терминальных, включая `cancelled`.
|
||||
- **«Закрыть»** (`Dismiss`) — стоп-кран для загрузки, которую уже никто не
|
||||
двигает: зависшей, спорной или лишней (в т.ч. дубля-близнеца в
|
||||
`target_missing`, чьи файлы разложены другой загрузкой). Команда SHALL быть
|
||||
доступна из любого состояния, кроме `deleted`, включая терминальные, где
|
||||
`Cancel` уже отказывает. В `cancelled` `Dismiss` SHALL быть идемпотентным
|
||||
no-op **без записи состояния** — иначе он подменил бы `error_code` прежнего
|
||||
закрытия.
|
||||
|
||||
В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу
|
||||
страницы загрузки), обособленно от штатных действий.
|
||||
Раскладка путей по поверхностям нормативной **не является**: поверхность
|
||||
выбирает путь по состоянию, и требовать от каждой поверхности **оба** пути
|
||||
система SHALL NOT. Сегодня раскладка такова: страница загрузки веб-UI даёт
|
||||
«Закрыть» в danger-зоне из терминальных состояний, кроме `deleted` и
|
||||
`cancelled`, а из не-терминальных закрывает кнопкой «Отменить» (на экране
|
||||
ревью — «Отклонить»); Telegram даёт «Закрыть» из `failed`, `stuck`, `done`,
|
||||
`orphaned`, `target_missing`; «Отклонить» — на карточке ревью
|
||||
(`review`/`deferred`). Кнопка закрытия в Telegram живёт на **карточке** задачи;
|
||||
уведомления о готовности и о рассинхроне её сегодня не несут, а из `linking`
|
||||
карточка не даёт действий вовсе. REST закрытие предоставлять MAY (сегодня
|
||||
отдаёт только `cancel`).
|
||||
|
||||
Ни один путь закрытия SHALL NOT производить действий с файлами или библиотечными
|
||||
ссылками: система SHALL NOT удалять либо создавать хардлинки под
|
||||
`paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие библиотечные
|
||||
ссылки сознательно остаются на месте. qBittorrent закрытие вызывать SHALL NOT —
|
||||
раздача не снимается и продолжает раздаваться, — с **одним** исключением, и оно
|
||||
привязано к состоянию, а не к команде: если закрытие **любым** путём увело
|
||||
задачу из `catched` в окне между нашим `add` и записью перехода, worker убирает
|
||||
только что добавленный торрент. Уборка нормирована в
|
||||
`openspec/specs/download-tracking/spec.md`, требование «Добавление пойманной
|
||||
загрузки в qBittorrent», и её границы задаёт оно — здесь они не пересказываются
|
||||
и не расширяются. Синхронный source-preflight ни один из путей выполнять SHALL
|
||||
NOT (источник в действии не участвует).
|
||||
|
||||
`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать их.
|
||||
`Dismiss` SHALL помечать переход `error_code = "user_dismiss"`
|
||||
(человекочитаемая причина — в `error_msg`), отличая стоп-кран от отклонения на
|
||||
ревью, от отмены активной работы и от удаления. Оба пути при этом SHALL замещать
|
||||
прежнюю диагностику записи: `Dismiss` — на `user_dismiss` с причиной закрытия,
|
||||
`Cancel` — сегодня на пустые `error_code`/`error_msg`; прежний код состояния
|
||||
(`stalled` у `stuck`, `qbit_error` у `failed`) из записи пропадает безвозвратно.
|
||||
Лог перехода закрытия его не дублирует: восстановить причину можно только по
|
||||
более ранней строке перехода **в** `failed`/`stuck`, пока её держит ретенция
|
||||
логов. Пустота кода на пути `Cancel` описана как сегодняшнее поведение, а не
|
||||
закреплена нормативно: собственный непустой код у `Cancel` требованием не
|
||||
запрещён.
|
||||
|
||||
Новый статус ни для одного из путей система вводить SHALL NOT —
|
||||
переиспользуется существующее терминальное `cancelled` (сверка его не
|
||||
переоценивает). Из `cancelled` пользователю остаётся доступной перепривязка
|
||||
(relink), если он передумает; при этом `Undo` и `Delete` из `cancelled`
|
||||
доступны SHALL NOT (их пол — `done`/`orphaned`/`target_missing`). Закрытие
|
||||
разложенной загрузки поэтому оставляет её библиотечные ссылки без штатной
|
||||
команды снятия, и **перепривязка их не снимает**: `Undo` и `Delete` работают
|
||||
только с последним батчем раскладки, а повторное применение заводит новый.
|
||||
Снять ссылки прежнего батча система средствами не даёт.
|
||||
|
||||
В интерфейсе «Закрыть» SHALL размещаться в отдельной «danger zone» (напр. внизу
|
||||
страницы загрузки), обособленно от штатных действий, и SHALL быть отделена от
|
||||
одиночного клика **дополнительным шагом**: раскрытием danger-зоны и явным
|
||||
подтверждением там, где транспорт его поддерживает (диалог веб-UI при доступном
|
||||
JS, второй шаг клавиатуры в Telegram). «Отменить»/«Отклонить» подтверждения
|
||||
требовать SHALL NOT: команда отменяет работу, которая ещё идёт.
|
||||
|
||||
Отсюда следует **известное ограничение**: по `error_code` отличима «загрузка
|
||||
закрыта стоп-краном» от «загрузка отменена активной», но не «пользователь
|
||||
закрыл активную загрузку» от «пользователь отменил её» — оба пути на
|
||||
не-терминальном состоянии дают один и тот же пустой код. Хуже того, маркер
|
||||
сегодня кодирует не намерение, а поверхность: одно и то же состояние `stuck`
|
||||
закрывается из веб-UI отменой (пустой код), а из Telegram стоп-краном
|
||||
(`user_dismiss`). Цена — потеря различения в логах и диагностике; поведение и
|
||||
данные не страдают. Восстановить различение можно, дав `Cancel` собственный
|
||||
непустой код, — это требованием разрешено и оставлено открытым вопросом, а не
|
||||
отвергнуто.
|
||||
|
||||
#### Scenario: Закрытие записи без цели не трогает раздачу
|
||||
|
||||
@@ -582,13 +643,47 @@ Telegram, опц. REST). Команда SHALL переводить загруз
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** библиотечные хардлинки не удаляются
|
||||
- **AND** раздача в qBittorrent не снимается
|
||||
- **AND** команды `Undo` и `Delete` на записи становятся недоступны, а
|
||||
«Привязать заново» остаётся доступной
|
||||
- **AND** после перепривязки и повторного применения ссылки прежнего батча
|
||||
остаются в библиотеке — их не снимает ни `Undo`, ни `Delete`
|
||||
|
||||
#### Scenario: Закрытие зависшей загрузки
|
||||
#### Scenario: Стоп-кран замещает прежнюю диагностику состояния
|
||||
|
||||
- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`)
|
||||
- **GIVEN** загрузка в `failed` с `error_code = "qbit_error"` и текстом ошибки
|
||||
в `error_msg`
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled`
|
||||
- **AND** источник в qBittorrent не трогается
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** прежний код и текст ошибки в записи не сохраняются
|
||||
- **AND** лог перехода закрытия их не дублирует — причина отказа восстановима
|
||||
только по более раннему переходу в `failed`
|
||||
|
||||
#### Scenario: Закрытие не-терминальной загрузки из веб-UI идёт отменой
|
||||
|
||||
- **GIVEN** загрузка в не-терминальном состоянии (`downloading`, `recognizing`,
|
||||
`review`, `deferred`, `stuck`), её страница открыта в веб-UI
|
||||
- **WHEN** пользователь нажимает «Отменить» (на экране ревью — «Отклонить»)
|
||||
- **THEN** запись переходит в `cancelled` и пропадает из активного списка
|
||||
- **AND** `error_code` и `error_msg` перехода пусты (маркера `user_dismiss` на
|
||||
этом пути нет)
|
||||
- **AND** ни раздача в qBittorrent, ни хардлинки не трогаются
|
||||
|
||||
#### Scenario: Стоп-кран доступен там, где отмена уже отказывает
|
||||
|
||||
- **GIVEN** загрузка в терминальном состоянии, кроме `deleted` и `cancelled`
|
||||
(`done`, `failed`, `reverted`, `target_missing`, `orphaned`)
|
||||
- **WHEN** пользователь даёт команду «Закрыть» и подтверждает её
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** команда `Cancel` на том же состоянии отклоняется с конфликтом
|
||||
|
||||
#### Scenario: Один и тот же stuck закрывается разными путями с разных поверхностей
|
||||
|
||||
- **GIVEN** загрузка в `stuck`
|
||||
- **WHEN** пользователь закрывает её из Telegram кнопкой «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** та же загрузка, закрытая со страницы веб-UI кнопкой «Отменить», даёт
|
||||
`cancelled` с пустым `error_code` — по коду перехода поверхности не
|
||||
различить от намерения
|
||||
|
||||
#### Scenario: «Закрыть» недоступна для deleted
|
||||
|
||||
@@ -596,10 +691,18 @@ Telegram, опц. REST). Команда SHALL переводить загруз
|
||||
- **WHEN** пользователь пытается вызвать «Закрыть»
|
||||
- **THEN** команда недоступна, состояние остаётся `deleted`
|
||||
|
||||
#### Scenario: Повторное закрытие не подменяет причину прежнего
|
||||
|
||||
- **GIVEN** загрузка в `cancelled`, закрытая ранее отменой (пустой `error_code`)
|
||||
- **WHEN** приходит команда «Закрыть»
|
||||
- **THEN** команда проходит no-op'ом, записи состояния не происходит
|
||||
- **AND** `error_code` остаётся прежним (пустым), а не подменяется на
|
||||
`user_dismiss`
|
||||
- **AND** команда «Отменить» на том же состоянии отклоняется с конфликтом
|
||||
|
||||
#### Scenario: Закрытую запись можно привязать заново
|
||||
|
||||
- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
|
||||
- **WHEN** пользователь даёт команду «Привязать заново»
|
||||
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
|
||||
из `cancelled`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user