Files
av 0b02a8c224 specs: в state-reconciliation разведены Cancel и Dismiss по состояниям
- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия,
  error_code на каждом, раскладка поверхностей — описательно, а не SHALL
- уборка своего торрента после отмены названа исключением по состоянию, а не
  по команде; убрана ложная гарантия «данных пользователя не касается»
- в docs/review.md записан проскочивший дефект гарда окна после add и новый
  вопрос проходу adversary про асимметрию признака владения
2026-08-06 15:50:41 +03:00

15 KiB
Raw Blame History

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)