specs: в state-reconciliation разведены Cancel и Dismiss по состояниям
- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия, error_code на каждом, раскладка поверхностей — описательно, а не SHALL - уборка своего торрента после отмены названа исключением по состоянию, а не по команде; убрана ложная гарантия «данных пользователя не касается» - в docs/review.md записан проскочивший дефект гарда окна после add и новый вопрос проходу adversary про асимметрию признака владения
This commit is contained in:
+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`)
|
||||
Reference in New Issue
Block a user