- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия, error_code на каждом, раскладка поверхностей — описательно, а не SHALL - уборка своего торрента после отмены названа исключением по состоянию, а не по команде; убрана ложная гарантия «данных пользователя не касается» - в docs/review.md записан проскочивший дефект гарда окна после add и новый вопрос проходу adversary про асимметрию признака владения
15 KiB
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уже отказывает. ВcancelledDismissSHALL быть идемпотентным 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)