web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-10
|
||||
@@ -0,0 +1,223 @@
|
||||
## Context
|
||||
|
||||
Полное удаление загрузки (`Reviewer.Delete`) снимает обе стороны — библиотечные
|
||||
хардлинки и раздачу с файлами из qBittorrent — и уводит задачу в терминальное
|
||||
`deleted`. Это осознанный выход за инвариант «источник неприкосновенен», и
|
||||
единственное, что его оправдывает, — явное подтверждение человека. Доступна
|
||||
операция из `done`, `orphaned`, `target_missing`.
|
||||
|
||||
Сегодня её единственная поверхность — danger-секция страницы одной загрузки
|
||||
(`web/templates/partials/download_main.html`, `POST
|
||||
/ui/downloads/{id}/delete`). Групповая уборка через неё превращается в обход
|
||||
карточек по одной.
|
||||
|
||||
Ограничения, из которых растёт весь дизайн:
|
||||
|
||||
- удаление необратимо, и подтверждение ослаблять нельзя ни ради пачки, ни ради
|
||||
удобства;
|
||||
- веб-UI работает без JavaScript (инвариант `web-ui`), значит подтверждение не
|
||||
может держаться на `hx-confirm`;
|
||||
- карточки основного списка самообновляются (`card-live-refresh`), а своп
|
||||
корня уносит вместе с разметкой состояние чекбоксов;
|
||||
- выход LLM и вход человека недоверенные: идентификаторы приходят с формы и
|
||||
проходят `ident.Parse` на границе.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Снести пачку раздач за один проход подтверждения, не теряя поимённости.
|
||||
- Не расширить прав: групповой путь допускает ровно то же, что поштучный.
|
||||
- Пережить отказ на одной загрузке, не отменяя остальных, и назвать исход по
|
||||
каждой.
|
||||
- Свести условие «удаление разрешено» к одной точке домена.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Групповой `Dismiss` (закрытие без файлов) — отдельная задача.
|
||||
- Автоматическая чистка по сроку хранения (`db-retention-cleanup`).
|
||||
- Изменение самой операции `Delete`: её условия, идемпотентность и разбор
|
||||
ошибок qBittorrent остаются как есть.
|
||||
- Групповое удаление в Telegram и REST API — только веб-UI.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Отдельная страница, а не режим выбора на основном списке
|
||||
|
||||
Основной список живой: карточки наблюдаемых задач сами опрашивают сервер и
|
||||
свопят себя целиком (`hx-swap="outerHTML"`). Выбор в чекбоксах такой своп не
|
||||
переживает — htmx подставляет присланную разметку, а состояние формы в ней
|
||||
отсутствует. Пометить чекбокс `hx-preserve` можно, но тогда сохранённым
|
||||
окажется узел, а не соответствие «чекбокс ↔ строка», и после перерисовки список
|
||||
поедет относительно отметок.
|
||||
|
||||
Отвергнуто: режим выбора на `/` с отключением поллинга на время выбора —
|
||||
поллинг пришлось бы гасить и возвращать клиентским состоянием, то есть завести
|
||||
на клиенте доменное состояние, чего конвенция веб-UI не допускает.
|
||||
|
||||
Страница живёт по `GET /delete` и ссылается из шапки. Своего поллинга не несёт:
|
||||
её строки статичны до перезагрузки, и это осознанно — предмет страницы не
|
||||
живая задача, а выбор человека.
|
||||
|
||||
### D2. Подтверждение — второй экран сервера, а не диалог браузера
|
||||
|
||||
`hx-confirm` (и `confirm()` вообще) требует JavaScript и не может назвать
|
||||
раздачи поимённо иначе как в теле алерта. Подтверждение делаем экраном:
|
||||
|
||||
1. `GET /delete` — список разрешённых к удалению, чекбоксы, кнопка;
|
||||
2. `POST /ui/delete/confirm` — страница подтверждения: каждая выбранная
|
||||
раздача названа заголовком, состоянием и идентификатором; форма несёт те же
|
||||
идентификаторы скрытыми полями и признак подтверждения;
|
||||
3. `POST /ui/delete` — исполнение.
|
||||
|
||||
Шаг 2 — POST, хотя ничего не меняет: идентификаторов может быть много, а
|
||||
длина URL ограничена. PRG здесь не нужен — страница подтверждения не результат
|
||||
мутации.
|
||||
|
||||
Строка подтверждения называет заголовок, идентификатор **и состояние**, а для
|
||||
`orphaned` — отдельную отметку «источник пропал, библиотечная ссылка осталась
|
||||
последней копией данных». Состояние здесь не украшение: гард последней копии в
|
||||
`Delete` выключен сознательно, и на пачке из десятка заголовков человек иначе не
|
||||
отличит «снимаю ссылку, раздача цела» от «снимаю единственную копию».
|
||||
Признак берётся из состояния (`orphaned` по определению значит «источник
|
||||
пропал, цель — последняя копия»), а не обходом файловой системы.
|
||||
|
||||
Отвергнуто: одна страница с раскрывающимся блоком подтверждения. Тогда
|
||||
«подтвердил» и «выбрал» живут в одной отправке формы, и признак подтверждения
|
||||
становится галочкой, которую браузер может восстановить автозаполнением.
|
||||
|
||||
### D3. Признак подтверждения проверяет сервер, и его отсутствие — отказ
|
||||
|
||||
`POST /ui/delete` без поля подтверждения отвечает отказом и **не зовёт `Delete`
|
||||
ни разу**. Проверка стоит до цикла: подтверждение — это условие операции, а не
|
||||
украшение экрана. Именно это состояние проверяет приёмка.
|
||||
|
||||
### D4. «Удаление разрешено» — метод состояния в `store`
|
||||
|
||||
Сейчас перечень `done`/`orphaned`/`target_missing` записан трижды:
|
||||
`switch` в `worker.Delete`, сборка `Deletable` в `internal/httpapi/download.go`
|
||||
и выбор клавиатуры в `internal/tgbot/render.go`. Групповая страница стала бы
|
||||
четвёртым местом, а расхождение между ними означало бы кнопку, ведущую в
|
||||
конфликт, — или наоборот, скрытую возможность.
|
||||
|
||||
Заводим `(store.State).CanDelete() bool` рядом с `IsTerminal`/`IsObservable`,
|
||||
перечень состояний — в одном списке. Все четыре места зовут его, включая
|
||||
Telegram: транспорт, оставшийся со своим перечнем, разойдётся с доменом молча
|
||||
на первом же изменении списка.
|
||||
|
||||
Проверку в `worker.Delete` при этом **не снимаем**: транспорт решает, что
|
||||
показать, а домен — что допустить, и допуск обязан держаться без транспорта.
|
||||
|
||||
### D5. Отказ на одной загрузке не отменяет остальных
|
||||
|
||||
Цикл идёт по всем выбранным, ошибка каждой попадает в её строку отчёта. Ни
|
||||
транзакции, ни отката тут быть не может: удаление файлов необратимо, и
|
||||
«откатить» уже снесённую раздачу нечем. Значит, единственная честная семантика
|
||||
— «каждая сама за себя», а отчёт обязан назвать обе половины поимённо.
|
||||
|
||||
Вызовы идут **последовательно**. Параллельные ушли бы в тот же
|
||||
`torrents/delete` и в тот же мьютекс воркера, выигрыш нулевой, а порядок
|
||||
сообщений в логе и отчёте перестал бы совпадать с порядком действий.
|
||||
|
||||
Ошибка транслируется публичным каналом (`userErr`), как и на поштучном пути:
|
||||
сырой текст ошибки наружу не идёт.
|
||||
|
||||
### D6. Результат — страница ответа на POST, без PRG
|
||||
|
||||
Отчёт называет удалённые и отказавшие поимённо, поэтому его нечем передать
|
||||
через редирект: в query он не поместится, а сессий у сервиса нет. Отдаём
|
||||
страницу результата прямо ответом `200` на `POST /ui/delete`.
|
||||
|
||||
Цена — предупреждение браузера при обновлении страницы. Повтор безопасен:
|
||||
удалённые уже в `deleted`, `CanDelete` для них ложно, и повторная отправка
|
||||
вернёт по ним конфликт, а не второе удаление.
|
||||
|
||||
**Исполнение не отменяется отменой запроса.** Пачка идёт с контекстом,
|
||||
отвязанным от `r.Context()` (`context.WithoutCancel`). Закрытая вкладка или
|
||||
обрыв связи иначе оборвали бы необратимую операцию посередине — в том числе
|
||||
внутри одной загрузки, между снятием библиотечных ссылок и вызовом
|
||||
qBittorrent. Отчёт при обрыве человек не увидит, поэтому исход каждой единицы
|
||||
обязан оставаться в журнале: `worker.Delete` уже пишет `logCmd` по каждому
|
||||
вызову, и это единственный след, переживающий потерю ответа.
|
||||
|
||||
### D7. Порог на размер пачки
|
||||
|
||||
За один запрос принимается не больше **20** идентификаторов (решение человека на
|
||||
чекпоинте 2026-08-10). Причины две: подтверждение, перечисляющее три сотни
|
||||
раздач, человек не читает — то есть перестаёт быть подтверждением; и один
|
||||
синхронный запрос упирается в столько же последовательных вызовов qBittorrent.
|
||||
Двадцать строк прочитываются целиком, и это перевесило удобство уборки сотни
|
||||
раздач одним заходом. Превышение — отказ целиком, без единого удаления.
|
||||
|
||||
Страница выбора при этом показывает **все** разрешённые загрузки без
|
||||
пагинации: разбиение по страницам сломало бы саму возможность выбрать пачку.
|
||||
Поэтому порог **назван на самой странице**, рядом с кнопкой, а отказ по порогу
|
||||
возвращает страницу выбора с сохранёнными отметками. Порог, о котором человек
|
||||
узнаёт только из отказа, отнимает всю проделанную работу: отметив шестьдесят
|
||||
строк из ста, он получил бы пустой экран и необходимость угадывать границу.
|
||||
Ограничивать выбор на клиенте нечем — страница обязана работать без JS.
|
||||
|
||||
### D8. Вход разбирается целиком на обеих границах
|
||||
|
||||
Каждый пришедший `id` разбирается через `ident.Parse`. Не разобравшийся —
|
||||
отказ всего запроса, а не тихий пропуск: молча выброшенный идентификатор
|
||||
означал бы, что человек подтвердил удаление раздачи, которую не удалили, и
|
||||
узнал бы об этом только по отсутствию строки в отчёте.
|
||||
|
||||
**Границ две, и проверки на них одинаковы.** Исполняющий `POST /ui/delete`
|
||||
получает идентификаторы формой заново — сессий у сервиса нет, состояние между
|
||||
шагами не хранится, — значит он такая же входная граница, как и подтверждение.
|
||||
Разбор, схлопывание дублей, порог и отказ на пустом наборе стоят на обеих; иначе
|
||||
запрос с признаком подтверждения и произвольным списком обошёл бы порог и увёл
|
||||
неограниченную серию необратимых `torrents/delete` за один заход.
|
||||
|
||||
Дубликаты в списке схлопываются до первого вхождения — повторный `Delete` по
|
||||
той же задаче дал бы ложный конфликт во второй строке отчёта.
|
||||
|
||||
Пустой набор — отказ: страница подтверждения без единой названной раздачи
|
||||
обесценивает сам жест подтверждения.
|
||||
|
||||
Идентификатор, разобравшийся, но не имеющий записи (устаревшая вкладка, чужая
|
||||
ссылка), выбрасывать молча нельзя по той же причине. Он идёт отдельной строкой
|
||||
«загрузка не найдена» — и на подтверждении, и в отчёте: «каждая сама за себя»
|
||||
из D5 распространяется и на этот исход.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Пачка сносит больше, чем человек имел в виду** → подтверждение поимённое и
|
||||
обязательное, порог на размер пачки, отчёт называет снесённое поимённо.
|
||||
- **Состояние задачи меняется между выбором и исполнением** (фоновая сверка
|
||||
увела `done` в `orphaned` или наоборот) → допуск проверяет `worker.Delete`
|
||||
под своим замком в момент операции; экран влиять на это не может, и отказ
|
||||
придёт строкой отчёта.
|
||||
- **Длинный синхронный запрос** при пачке в 20 раздач → порог, последовательный
|
||||
проход и отсутствие `WriteTimeout` у сервера; уход в фон не делаем — тогда
|
||||
результат перестал бы быть поимённым ответом на подтверждение.
|
||||
- **Страница выбора устаревает** (не самообновляется) → к моменту отправки
|
||||
часть строк может быть неактуальна; ловится тем же допуском в `worker.Delete`
|
||||
и строкой отказа в отчёте.
|
||||
- **Четвёртая поверхность одного действия** → условие допуска сведено в
|
||||
`CanDelete`, сама операция не дублируется: групповой путь зовёт тот же
|
||||
`Reviewer.Delete`.
|
||||
|
||||
### D9. Системный отказ останавливает пачку после трёх подряд
|
||||
|
||||
`Delete` снимает сначала библиотечные ссылки, потом зовёт qBittorrent. Если
|
||||
qBittorrent недоступен, каждая единица пачки успевает выполнить **необратимый
|
||||
локальный шаг** и падает на внешнем. Цикл «каждая сама за себя» дошёл бы до
|
||||
конца: один клик оставил бы двадцать тайтлов без раскладки в Jellyfin, не
|
||||
освободив ни байта. На поштучном пути человек останавливался сам после первой же
|
||||
ошибки — групповой путь эту естественную остановку снимает, и её надо вернуть
|
||||
машиной.
|
||||
|
||||
Порог — **три подряд** (решение человека на чекпоинте 2026-08-10). Счётчик
|
||||
сбрасывается на каждом успехе: одиночная сетевая ошибка пачку не рвёт, а три
|
||||
подряд означают, что сосед лежит, а не что не повезло. Конфликт состояния
|
||||
(`ErrConflict`) системным отказом не считается — он про задачу, а не про соседа,
|
||||
и на счётчик не влияет.
|
||||
|
||||
Отвергнуто: остановка на первом же отказе — одна случайная сетевая ошибка
|
||||
обрывала бы всю пачку, и человек проходил бы подтверждение заново.
|
||||
|
||||
Отвергнуто: «каждая сама за себя» без исключений — проще и предсказуемее, но
|
||||
ценой того самого ущерба, ради ограничения которого стоит порог пачки.
|
||||
@@ -0,0 +1,59 @@
|
||||
## Why
|
||||
|
||||
Удалить раздачу вместе с файлами сегодня можно только со страницы одной
|
||||
загрузки, из её danger-секции. Чтобы освободить место после десяти закрытых
|
||||
раздач, человек десять раз проходит путь «список → карточка → подтверждение».
|
||||
Работа механическая, а цена ошибки высокая: на каждом проходе он подтверждает
|
||||
необратимое действие заново и легко теряет, какие раздачи уже снёс.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Появляется отдельная страница веб-UI «Удаление», доступная из шапки. На ней
|
||||
перечислены только те загрузки, для которых удаление с файлами разрешено
|
||||
поштучно (`done`, `orphaned`, `target_missing`), у каждой строки — чекбокс.
|
||||
- Одна кнопка «Удалить выбранные» ведёт на **страницу подтверждения**, где
|
||||
выбранные раздачи названы поимённо. Удаление уходит только оттуда: POST без
|
||||
признака подтверждения отклоняется, ни одного удаления не делает.
|
||||
- Результат показывается поимённо: что удалено и что отказало, с причиной
|
||||
каждого отказа. Отказ на одной загрузке не отменяет остальных.
|
||||
- Групповой путь прав не расширяет: каждая выбранная загрузка проходит ту же
|
||||
проверку состояния, что и поштучное удаление, и отклоняется тем же конфликтом.
|
||||
- Условие «удаление разрешено» переезжает в единую точку домена
|
||||
(`store.State`): сейчас оно записано дважды — в проверке воркера и в сборке
|
||||
представления страницы загрузки, — а групповая страница была бы третьим
|
||||
местом.
|
||||
- Страница намеренно **не самообновляется**: живая перерисовка карточек стёрла
|
||||
бы выбор человека. Это и есть причина, по которой режим выбора не вешается на
|
||||
основной список.
|
||||
|
||||
Не входит: групповое закрытие без файлов (`Dismiss`), автоматическая чистка по
|
||||
сроку хранения, изменение самой операции удаления одной загрузки.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет: групповое удаление — это новая поверхность существующего поведения,
|
||||
а не новое поведение системы.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `web-ui`: добавляется страница группового удаления с обязательным поимённым
|
||||
подтверждением и поимённым отчётом об исходе; ссылка на неё в шапке.
|
||||
- `state-reconciliation`: требование «Полное удаление загрузки пользователем»
|
||||
уточняется — подтверждение может быть одно на пачку, но остаётся обязательным
|
||||
и поимённым, а условия допустимости у каждой загрузки прежние и проверяются
|
||||
для каждой отдельно.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/httpapi`: страница выбора, страница подтверждения, обработчик
|
||||
группового удаления и страница результата; ссылка в шапке.
|
||||
- `web/templates`: новый шаблон страницы и её партиалы,
|
||||
`partials/header.html`.
|
||||
- `internal/store`: единая точка «в этом состоянии удаление разрешено».
|
||||
- `internal/worker`: `Reviewer.Delete` зовётся по каждой выбранной загрузке;
|
||||
сама операция не меняется.
|
||||
- qBittorrent `torrents/delete` с `deleteFiles=true` — внешний сервис,
|
||||
необратимая операция; число вызовов за один запрос человека растёт с одного
|
||||
до числа выбранных.
|
||||
@@ -0,0 +1,315 @@
|
||||
# Ревью кода: `bulk-delete-page` — итог триажа (стадия `large`)
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Размер / сложность / метка:** крупное / незнакомое / **large**. Метка
|
||||
поднята с `medium` повторной разметкой после правок дельта-спек: изменение
|
||||
трогает `store` + `worker` + `httpapi` + `tgbot` + шаблоны и заводит новую
|
||||
точку входа поверх необратимой операции.
|
||||
- **Режим прогона:** по графу.
|
||||
- **Гейт:** зелёный после всех правок, diff-coverage 89 %.
|
||||
- **Находок на входе:** 10 новых (`architecture` A1–A3, `adversary` S1–S4,
|
||||
`ops` O1–O3) + 6 перенесённых из отчёта `medium`-стадии. **На выходе:** 6.
|
||||
|
||||
Прогон шёл в две стадии. Сперва `medium` (autotests, specs, code, basics,
|
||||
triage) — его находки отработаны:
|
||||
|
||||
- отказ чтения хранилища отличён от отсутствия записи (строка «состояние
|
||||
прочитать не удалось», лог, сценарий спеки);
|
||||
- строки отчёта читаются неотменяемым контекстом;
|
||||
- отсутствие записи явно названо в спеке не-системным отказом;
|
||||
- три sentinel'а разбора пачки переехали в `classifyErr`, таблица
|
||||
`docs/conventions/errors.md` дополнена;
|
||||
- добавлен оракул на ссылку `/delete` в шапке;
|
||||
- по решению человека введён потолок времени на проход (`bulkBudget`);
|
||||
- по решению человека спека перестала обещать, что повтор отправки ничего не
|
||||
делает.
|
||||
|
||||
### Тема без отчёта на этой стадии — находка о прогоне
|
||||
|
||||
**Три прохода `medium`-стадии (`autotests`, `specs`, `code`) после правок не
|
||||
перезапускались, а правки затронули их предмет.**
|
||||
|
||||
- `specs` (тема **requirements**) отчитывался по **прежнему** тексту дельта-спек.
|
||||
После него дельты изменены трижды. Соответствие кода изменённому тексту не
|
||||
проверял никто.
|
||||
- `code` (тема **conventions**) отчитывался до переезда sentinel'ов в
|
||||
`classifyErr` и до правки `docs/conventions/errors.md`.
|
||||
- `autotests` — гейт перепрогнан целиком и зелёный; вопрос темы «есть ли тест,
|
||||
который упал бы без этой правки» на новых строках заново не задавался.
|
||||
|
||||
Это не «проходы не нашли», а «проходы не смотрели».
|
||||
|
||||
### План разметки с исходом по каждой теме
|
||||
|
||||
| тема | дом | глубина | закрывает | исход |
|
||||
|---|---|---|---|---|
|
||||
| requirements | `openspec/specs/{web-ui,state-reconciliation}` + дельты | разбор | `specs` | отчёта на этой стадии нет; закрыта на `medium` (3 находки), текст дельт после этого изменён |
|
||||
| autotests | `CLAUDE.md` → «Гейт» | — | `autotests` | отчёта на этой стадии нет; гейт перепрогнан, зелёный |
|
||||
| conventions | `docs/conventions/` | разбор | `code` | отчёта на этой стадии нет; закрыта на `medium` (3 находки) |
|
||||
| architecture | `docs/architecture.md` + источник `docs/passport.md` | доказательство | `architecture` | **закрыта**, 3 находки (A1–A3) |
|
||||
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 4 находки (S1–S4) |
|
||||
| operations | `docs/architecture.md` «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта**, 3 находки (O1–O3) |
|
||||
| тема проекта | своих тем в `docs/` нет | — | — | дома у темы нет |
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. Строка `done` уезжает в пачку без отметки последней копии — единственная копия медиафайла стирается по подтверждению, которое об этом промолчало
|
||||
|
||||
- Файл: `internal/httpapi/bulkdelete.go:113,220`; причина —
|
||||
`internal/worker/reconcile.go:28-41,56-67`, `internal/layout/layout.go:490-510`
|
||||
- Severity: `major`; Confidence: high; проход `adversary` (S1)
|
||||
- **Оракул** — два падающих теста (`go test -overlay`):
|
||||
`TestAdversaryDoneRowIsLastCopyWithoutWarning`,
|
||||
`TestAdversaryDoneRowConfirmedWithoutLastCopyWarning` («ни страница выбора, ни
|
||||
подтверждение не сказали о последней копии… отчёт "Удалено — 1"»).
|
||||
Механизм сверен триажем: `LastCopy: d.State == store.StateOrphaned`;
|
||||
`sourceSeen` берётся исключительно из присутствия хеша в ответе
|
||||
`torrents/info`, байты на диске сверка не щупает, поэтому
|
||||
`deriveState(true, true) == done` держится сколько угодно долго;
|
||||
`layout.Remove` не зовёт `isLastCopy` вовсе.
|
||||
- Последствие: задача в `done`, у которой байты источника исчезли с диска, а
|
||||
раздача осталась в списке qBittorrent (ручная уборка каталога, отвалившийся
|
||||
том, торрент в `missingFiles`), предлагается к удалению без единственного
|
||||
предохранителя, который дельта-спека для этого и заводит. `Undo` на том же
|
||||
входе отказал бы целиком. Позиция №1 шкалы `docs/security.md`, инвариант
|
||||
«последняя копия не снимается» — **необратимо**. Спека и решение D2 исходят из
|
||||
тождества «`orphaned` ⟺ `nlink<=1`»; оно неверно в одну сторону.
|
||||
- **Действие: развилка.** → **решение человека (2026-08-10): оставить как есть,
|
||||
назвать границу в спеке.** Отметка выводится из состояния, случай «`done` с
|
||||
пропавшими байтами источника» ею не покрыт, и это записано прямо в требовании
|
||||
о поимённом подтверждении. Довод: поштучный путь такой отметки не несёт
|
||||
вовсе — пачка не ухудшила положение, а улучшила его не до конца.
|
||||
|
||||
### 2. Подтверждённое удаление старой закрытой задачи сносит источник у другой, живой загрузки с тем же инфохэшем
|
||||
|
||||
- Файл: `internal/worker/review.go:665-669`; причина —
|
||||
`internal/store/download.go:325-371`
|
||||
- Severity: `major`; Confidence: high; проход `adversary` (S2)
|
||||
- **Оракул** — два падающих теста:
|
||||
`TestAdversaryDeletablePageOffersHashOwnedByActiveDownload`,
|
||||
`TestAdversaryDeleteWipesSourceOfAnotherActiveDownload` («`Delete(A)` снёс
|
||||
раздачу с файлами, хотя тем же инфохэшем владеет активная загрузка B в
|
||||
состоянии `downloading`»). `CreateDownloadIfNoActive` ищет владельца только
|
||||
среди активных, поэтому терминальная A и активная B по одному хешу — легальное
|
||||
состояние; `ListDeletableDownloads` фильтрует только по состоянию;
|
||||
`qbt.Delete` вызывается без проверки активного владения, хотя предикат в
|
||||
домене есть — `store.FindActiveByInfohash`.
|
||||
- Последствие: человек подтверждает удаление одной сущности, необратимое
|
||||
действие применяется к другой — класс из журнала `docs/review.md`
|
||||
(2026-08-06). **Дефект предсуществует в поштучном `Delete`**; изменение его не
|
||||
вносит, но снимает естественную остановку «человек открыл карточку и
|
||||
посмотрел».
|
||||
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
|
||||
задачей.** Дефект целиком в поштучном `Delete`, изменение его не вносит;
|
||||
чинить его здесь значит превратить задачу про новую страницу в переборку
|
||||
удаления.
|
||||
|
||||
### 3. Пачка удалений морозит весь сервис до ~145 секунд, и предохранитель от этого стоит в транспорте, компенсируя свойство ядра
|
||||
|
||||
- Файл: `internal/httpapi/bulkdelete.go:30-39,156-167`; причина —
|
||||
`internal/worker/review.go:619-621`
|
||||
- Severity: `major`; Confidence: high; проходы `ops` (O1), `architecture` (A1) и
|
||||
`basics` прошлой стадии — три раза одна причина
|
||||
- **Оракул** — замер `ops` на настоящем `*worker.Worker`: при задержке
|
||||
`qbt.Delete` 300 мс конкурентная `Cancel(unknown-id)` через тот же worker
|
||||
заблокирована на `280.450631ms`. Плюс падающий тест прошлой стадии
|
||||
`TestTriageDeleteHoldsWorkerLockAcrossQbt`. Сверено триажем:
|
||||
`internal/worker/worker.go:9-10` и `docs/architecture.md` называют `w.mu`
|
||||
**per-download**, фактически это один мьютекс на весь воркер; `worker.go:387-390`
|
||||
— «Медленные вызовы идут ВНЕ `w.mu`»; таймаут клиента qBittorrent — зашитые
|
||||
30 с, поля в конфиге нет. Отсюда арифметика: `bulkBudget` проверяется между
|
||||
единицами, значит реальный потолок — 120 + до 30 секунд.
|
||||
- Последствие: всё это время стоят `Poll` (→ сверка), `processCatched`,
|
||||
уведомления и команды остальных транспортов. Плюс архитектурная цена: в
|
||||
`httpapi` живут три доменных правила и понятие «невыполненный остаток»;
|
||||
поштучный путь той же защиты не имеет; групповой `Dismiss` либо скопирует
|
||||
~60 строк, либо тихо обойдётся без них.
|
||||
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
|
||||
задачей.** Свойство целиком пре-существующее (поштучное удаление морозит
|
||||
сервис до 30 с той же причиной); бюджет времени остаётся как ограничение
|
||||
сверху, сужение замка — отдельная работа со своей спекой.
|
||||
|
||||
---
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 4. При недоступном qBittorrent задача остаётся `done`, хотя раскладки в Jellyfin уже нет, и сама не выправится до подъёма соседа
|
||||
|
||||
- Файл: `internal/worker/review.go:634-670`; причина —
|
||||
`internal/worker/worker.go:635-639`
|
||||
- Severity: `major`; Confidence: high; проход `ops` (O2)
|
||||
- **Оракул** — замер: `down (unreachable) Delete: elapsed=524.545µs
|
||||
err=connection refused`; `bulkFailThreshold=3` останавливает пачку за
|
||||
миллисекунды (6 ERROR-строк, шторма нет). Сверено триажем: `Poll` начинается с
|
||||
`w.qbt.Torrents(ctx, "")` и при ошибке возвращается на первой же строке,
|
||||
поэтому `reconcileDesync` — а с ним коррекция `done → target_missing`, на
|
||||
которую рассчитывают D9 и спека, — не вызывается ни разу, пока сосед лежит.
|
||||
- Последствие: ссылки сняты и `file_link` удалены, раздача не снесена, место не
|
||||
освобождено, состояние говорит `done`. Расхождение живёт весь простой соседа.
|
||||
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
|
||||
задачей.** Порядок шагов и ранний выход `Poll` — пре-существующие, поштучный
|
||||
путь ведёт себя ровно так же.
|
||||
|
||||
### 5. Bidi-символы в имени раздачи уезжают в экран подтверждения дословно
|
||||
|
||||
- Файл: `internal/worker/discover.go:55`, `internal/httpapi/httpapi.go:701-709`
|
||||
- Severity: `minor`; Confidence: high; проход `adversary` (S3)
|
||||
- **Оракул** — падающий тест `TestAdversaryConfirmRendersBidiTitleVerbatim`:
|
||||
U+202E уехал дословно и на `/delete`, и на `/ui/delete/confirm`.
|
||||
- Последствие: поимённое чтение заголовков — единственный предохранитель
|
||||
необратимой пачки; подменённый порядок символов делает его ненадёжным.
|
||||
- **Действие: инлайн.** → **исправлено**: `displaySafe` снимает `unicode.Cf` и
|
||||
управляющие на показе (не на записи), оракул добавлен.
|
||||
|
||||
### 6. `docs/security.md` объявляет несуществующий пробел
|
||||
|
||||
- Файл: `docs/security.md:106-107`
|
||||
- Severity: `minor`; Confidence: high; проход `adversary` (наблюдение)
|
||||
- **Оракул** — сверено по исходникам: `internal/llm/openai.go:23`
|
||||
`maxResponseBody = 8 << 20`, `internal/metadata/http.go:41` — 4 MiB. Документ
|
||||
утверждал «Лимита на размер ответа LLM нет — известный пробел».
|
||||
- **Действие: инлайн.** → **исправлено**: оба лимита названы числами в
|
||||
`docs/security.md`, вопрос темы в `docs/review.md` переформулирован.
|
||||
|
||||
---
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **S4 (`adversary`, `major` → гипотеза): исходный путь раскладки не проверяется
|
||||
на принадлежность песочнице.** Тест `TestAdversarySourceEscapesDownloadsSandbox`
|
||||
разложил `…/config/config.toml` как `…/movies/Дюна (2021)/Дюна (2021).toml`
|
||||
через `..` в имени файла раздачи. **Вне диффа целиком** и с недостающим
|
||||
звеном, названным самим проходом: согласится ли qBittorrent отдать в
|
||||
`/torrents/files` имя с `..` (libtorrent такие пути санитизирует). Уезжает в
|
||||
урожай задачей.
|
||||
- **O3 (`ops`, `minor` → гипотеза): страница `/delete` раздувается без
|
||||
ретеншена.** Замер: 5000 строк → 2 241 604 байта, 29.7 мс. На обозримом
|
||||
горизонте предела не надо; отсутствие пагинации заказано спекой дословно;
|
||||
смежная причина стоит задачей `db-retention-cleanup`.
|
||||
- **A2 (`architecture`, `minor` → снято): «третий способ выбрать загрузки по
|
||||
набору состояний».** Перечень берётся из единой точки; `ListDownloadsByState`
|
||||
делает `SELECT *` без `LEFT JOIN recognition` и с другим `ORDER BY` —
|
||||
переиспользованию не подлежит без правки обоих вызывающих.
|
||||
- **A3 (`architecture`, `minor` → promote): строка загрузки размножена по трём
|
||||
шаблонам.** Норма говорит о паре «страница ↔ htmx-фрагмент одного
|
||||
обработчика»; здесь три разные страницы с разной семантикой строки и без htmx.
|
||||
Второй независимый провенанс повышает приоритет правила, `confidence` — нет.
|
||||
|
||||
---
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- Партиал строки — один на все экраны многостраничной формы (`code`,
|
||||
`architecture`, независимо).
|
||||
- Таймаут клиента qBittorrent — из конфига, а не дефолт транспорта: в
|
||||
`[qbittorrent]` поля нет, в `[llm]`, `[metadata.*]`, `[jellyfin]` — есть
|
||||
(триаж).
|
||||
- Комментарий пакета `worker` и `docs/architecture.md` называют `w.mu`
|
||||
per-download блокировкой, а это единый мьютекс на весь воркер (`ops`).
|
||||
- `store.State.CanDelete()` — в `docs/architecture.md` → «Единые точки проекта»
|
||||
рядом с `IsObservable()` (`architecture`).
|
||||
- Правило именования маршрутов веб-UI: глагол-первым `/delete` против семьи
|
||||
`/downloads/{id}/…` (`architecture`).
|
||||
- Дубль построения `IN (?,…)` в `store` — хелпер при третьем появлении
|
||||
(`architecture`).
|
||||
- Отвязка необратимой операции от контекста запроса — правило, а не приём:
|
||||
групповой путь делает `context.WithoutCancel`, поштучный нет (`code`).
|
||||
- Отказы `store` не проверяются ни одним тестом — как класс (`autotests`).
|
||||
|
||||
---
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Метка `large`, режим по графу.** Запущено на этой стадии: `architecture`,
|
||||
`adversary`, `ops` — все на глубине «доказательство», все вернули отчёт.
|
||||
|
||||
**Что не запускалось и почему:** `autotests`, `specs`, `code` — сознательно не
|
||||
перезапускались после правок `medium`-стадии; цена названа первой строкой
|
||||
сводки. `basics` — не запускался по плану: все шесть тем ядра разобраны
|
||||
именными проходами, поэтому возражений о метке от корректора прийти не могло.
|
||||
|
||||
**Чего запущенные проходы не могли проверить в принципе:** `architecture` не
|
||||
судит корректность кода и не строит путей отказа; `adversary` показывает
|
||||
достижимость, но не частоту; `ops` меряет на синтетическом входе своего
|
||||
прогона, а не на рабочем потоке.
|
||||
|
||||
**Потолки проходов.** Ни один из трёх проходов не сообщил своего потолка.
|
||||
Молчание неотличимо от «срезать было нечего» — это находка о прогоне. Триажу
|
||||
пришли сжатые пересказы выводов, а не дословные блоки `Coverage of this pass`.
|
||||
Потолком триажа не срезано ничего: на входе 10 новых находок, в первые две
|
||||
секции ушло 6, остальные названы поимённо.
|
||||
|
||||
**Что остаётся целиком на человеке:** история инцидентов на umbar; поведение
|
||||
SQLite под реальным объёмом и профилем нагрузки; завязка внешних потребителей на
|
||||
новые маршруты; суждение «этой функциональности не должно существовать»;
|
||||
качество распознавания.
|
||||
|
||||
**Перестали проверять сознательно:** идиоматичность Go (проход `idiom`
|
||||
упразднён 2026-08-04); на метках `small`/`medium` не проверяется ничто,
|
||||
требующее запуска — здесь метка `large`, и именно поэтому появились находки 1–4;
|
||||
**на этом прогоне добавилось третье, разовое** — темы `requirements`,
|
||||
`conventions`, `autotests` не перепроверялись после правок.
|
||||
|
||||
**Каких документов не хватило:** `docs/security.md` содержал устаревший факт
|
||||
(находка 6, исправлено); `docs/architecture.md` и комментарий пакета `worker`
|
||||
описывают `w.mu` неверно — проходы рассуждали против описания, а не против кода,
|
||||
и это пришлось сверять триажу. `docs/review.md` → «Типовые ложноположительные» и
|
||||
журнал дефектов есть и применены.
|
||||
|
||||
**Чего в конвейере нет вовсе:** решения проекта (`docs/adr/`) не сверялись —
|
||||
процессный документ, расхождение ловит сверка документации; записанные
|
||||
наблюдения (`docs/research/`) не использовались — всякое число снято на этом
|
||||
прогоне; поимённая сверка с руководствами по стилю языка не задавалась ни одним
|
||||
проходом; альтернативной реализации, с которой можно сдиффить решения, у
|
||||
конвейера нет.
|
||||
|
||||
**Формулировка «критичных проблем не обнаружено» к этому отчёту не применима:**
|
||||
проверено ровно то, что перечислено выше, и не проверено ровно то, что
|
||||
перечислено выше.
|
||||
|
||||
---
|
||||
|
||||
## Досверка `specs` после правок (закрывает названную выше дыру прогона)
|
||||
|
||||
Проход `requirements` перезапущен на изменённом тексте дельт и изменённом коде —
|
||||
дыра «соответствие кода изменённому тексту спеки не проверял никто» закрыта.
|
||||
Покрытие: все 6 требований обеих дельт по под-пунктам, `openspec validate
|
||||
--strict` — valid, тесты затронутых пакетов зелёные.
|
||||
|
||||
**Д1 (major, high). Чистка заголовка задела все заголовки веб-UI и снимала
|
||||
лишнее.** `internal/httpapi/httpapi.go`. Оракул — прогон копии функции:
|
||||
`"👨👩👧 family"` → `"👨👩👧 family"`, `"shah\u200Cname"` → `"shahname"`,
|
||||
`"Duna\nchast 2"` → `"Dunachast 2"`. `unicode.Cf` — это не только
|
||||
bidi-override, но и ZWJ/ZWNJ; поиск по списку идёт по сохранённому имени, и
|
||||
скопированное с экрана название своей же записи не нашло бы. Правило нигде не
|
||||
записано и применено непоследовательно (третья ветка заголовка чистку не
|
||||
проходила). → **исправлено**: снимается только `unicode.Bidi_Control`,
|
||||
управляющие заменяются пробелом, чистка применяется ко всем трём веткам, и
|
||||
заведено MODIFIED-требование «Заголовок загрузки из имени раздачи» со сценарием.
|
||||
Тест расширен проверкой, что составные эмодзи остаются целыми.
|
||||
|
||||
**Д2 (minor, high). Потерянный ответ пачки не восстанавливался по журналу.**
|
||||
Требование «Исход каждой единицы SHALL попадать в журнал» держалось на воркере,
|
||||
который отказы по конфликту и отсутствию записи пишет на `DEBUG`, а не начатые
|
||||
единицы не пишет вовсе; итоговая строка несла только счётчики. → **исправлено**:
|
||||
транспорт пишет строку `bulk delete item` на `INFO` по каждой единице с исходом
|
||||
`deleted|failed|skipped` и причиной отказа.
|
||||
|
||||
**Д3 (minor, high). Отказ разбора тела формы диагностировался как
|
||||
«некорректный идентификатор», исходная ошибка не доезжала ни до человека, ни до
|
||||
лога.** → **исправлено**: отдельный sentinel с своим текстом, исходная ошибка
|
||||
уходит в приватный канал.
|
||||
|
||||
**Поведение вне спеки, снятое заодно:** признак подтверждения и пачка
|
||||
принимались и из строки запроса (`r.Form`), тогда как требование говорит «в
|
||||
форме» — читаем только тело (`r.PostForm`).
|
||||
|
||||
**Осталось открытым и названо:** текст MODIFIED-требования
|
||||
`state-reconciliation` обещает, что повторное удаление опирается на «приведённый
|
||||
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
|
||||
сверки ожидаемо)». При лежащем qBittorrent это неверно — сверка не доходит до
|
||||
коррекции (находка 4). Фраза пре-существующая; по решению человека дефект уходит
|
||||
в беклог отдельной задачей, а текст вливается в спеку как есть.
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Полное удаление загрузки пользователем
|
||||
|
||||
Система SHALL предоставлять пользователю команду **«Удалить»** (delete),
|
||||
доступную из состояний `done`, `orphaned` и `target_missing` во всех транспортах
|
||||
(веб-UI и Telegram, опц. REST). Команда SHALL снимать **обе** стороны загрузки —
|
||||
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
|
||||
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.
|
||||
|
||||
Перечень состояний, из которых удаление допустимо, SHALL иметь **единственный
|
||||
дом** — предикат состояния в модели данных. Транспорты, решающие, показывать
|
||||
действие или нет, SHALL опираться на него, а не на собственный список. Проверку
|
||||
допуска в ядре это SHALL NOT отменять: транспорт решает, что показать, ядро — что
|
||||
допустить, и допуск SHALL держаться без транспорта.
|
||||
|
||||
Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`:
|
||||
`superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с
|
||||
выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие
|
||||
от `Undo`, delete SHALL снимать целевую ссылку, даже если она — последняя копия
|
||||
данных (`nlink <= 1`). Это осознанный выход за
|
||||
инвариант «источник неприкосновенен», поэтому delete SHALL требовать явного
|
||||
**подтверждения** пользователя перед выполнением и SHALL NOT срабатывать по
|
||||
одиночному клику/тапу. Снятие цели SHALL затрагивать только собственные ссылки
|
||||
загрузки строго под `paths.movies`/`series`; файлы источника под
|
||||
`paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по
|
||||
вызову API с `deleteFiles=true`.
|
||||
|
||||
Подтверждение SHALL быть допустимо **одно на пачку** загрузок, когда транспорт
|
||||
даёт групповое удаление: ослаблением требования это не является, если
|
||||
подтверждение называет каждую загрузку пачки поимённо. Условия допустимости
|
||||
групповой путь смягчать SHALL NOT — каждая загрузка пачки проходит те же
|
||||
проверки и тот же отказ по конфликту, что и при поштучном удалении, а отказ на
|
||||
одной остальных отменять SHALL NOT.
|
||||
|
||||
В отличие от прочих команд, требующих источника, delete синхронный source-preflight
|
||||
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
|
||||
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
|
||||
трактуется как уже снятая сторона, а не как повод привести состояние сверкой и
|
||||
отказать. Удаление SHALL быть идемпотентным к отсутствующей стороне: в `orphaned`
|
||||
(нет источника) отсутствие раздачи в qBittorrent ошибкой считаться SHALL NOT; в
|
||||
`target_missing` (нет цели) пустой список живых ссылок обрабатывается как «нечего
|
||||
снимать». Если qBittorrent вернул ошибку при удалении присутствующей раздачи,
|
||||
система в `deleted` переходить SHALL NOT (не заявляем освобождение места, которого
|
||||
не произошло), SHALL сообщить пользователю причину отказа (это не `ErrConflict`,
|
||||
а ошибка внешнего сервиса — транслируется как таковая), и повторный delete
|
||||
идемпотентно дожимает удаление, опираясь на оставшийся `done` либо приведённый
|
||||
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
|
||||
сверки ожидаемо).
|
||||
|
||||
Инициатора перехода в `deleted` система SHALL отличать от фоновой сверки:
|
||||
пользовательское удаление SHALL помечаться `error_code = "user_delete"` (сверка
|
||||
кладёт `"reconcile"`), человекочитаемую причину — в `error_msg` и лог перехода.
|
||||
Новый статус для этого система вводить SHALL NOT — переиспользуется существующее
|
||||
терминальное `deleted` (сверка его не переоценивает, см. требование о `deleted`).
|
||||
|
||||
#### Scenario: Удаление из done снимает обе стороны и освобождает место
|
||||
|
||||
- **GIVEN** задача в `done`: раздача присутствует в qBittorrent, её библиотечные
|
||||
хардлинки существуют
|
||||
- **WHEN** пользователь подтверждает «Удалить»
|
||||
- **THEN** библиотечные ссылки последнего батча снимаются
|
||||
- **AND** раздача с файлами удаляется из qBittorrent (`deleteFiles=true`)
|
||||
- **AND** задача переходит в `deleted` с `error_code = "user_delete"`
|
||||
|
||||
#### Scenario: Удаление из orphaned снимает последнюю копию осознанно
|
||||
|
||||
- **GIVEN** задача в `orphaned`: источник пропал, библиотечный хардлинк остался
|
||||
единственной копией данных (`nlink <= 1`)
|
||||
- **WHEN** пользователь подтверждает «Удалить»
|
||||
- **THEN** библиотечная ссылка снимается несмотря на то, что она последняя копия
|
||||
(гард последней копии выключен, в отличие от `Undo`)
|
||||
- **AND** отсутствие раздачи в qBittorrent ошибкой не считается
|
||||
- **AND** задача переходит в `deleted`
|
||||
|
||||
#### Scenario: Удаление из target_missing сносит остаточную раздачу
|
||||
|
||||
- **GIVEN** задача в `target_missing`: источник присутствует, цель уже удалена
|
||||
вручную
|
||||
- **WHEN** пользователь подтверждает «Удалить»
|
||||
- **THEN** снятие цели идемпотентно (живых ссылок нет)
|
||||
- **AND** раздача с файлами удаляется из qBittorrent
|
||||
- **AND** задача переходит в `deleted`
|
||||
|
||||
#### Scenario: Удаление требует подтверждения
|
||||
|
||||
- **GIVEN** задача в `done`
|
||||
- **WHEN** пользователь инициирует «Удалить», но не подтверждает действие
|
||||
- **THEN** ни ссылки, ни раздача не удаляются, состояние остаётся `done`
|
||||
|
||||
#### Scenario: Удаление недоступно из прочих состояний
|
||||
|
||||
- **GIVEN** задача в `review` (или ином состоянии вне `done`/`orphaned`/
|
||||
`target_missing`)
|
||||
- **WHEN** приходит команда «Удалить»
|
||||
- **THEN** команда отклоняется с конфликтом, состояние не меняется
|
||||
|
||||
#### Scenario: Ошибка qBittorrent не метит deleted ложно
|
||||
|
||||
- **GIVEN** задача в `done`, раздача присутствует, но qBittorrent вернул ошибку
|
||||
на удаление
|
||||
- **WHEN** пользователь подтверждает «Удалить»
|
||||
- **THEN** задача в `deleted` не переходит (место не освобождено)
|
||||
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
|
||||
- **AND** повторный delete идемпотентно дожимает удаление
|
||||
|
||||
#### Scenario: Одно подтверждение на пачку не смягчает допуска
|
||||
|
||||
- **GIVEN** транспорт даёт групповое удаление, и человек подтвердил пачку, где
|
||||
каждая загрузка названа поимённо
|
||||
- **WHEN** одна из загрузок пачки находится в состоянии, из которого удаление
|
||||
недоступно
|
||||
- **THEN** по ней приходит тот же отказ по конфликту, что и при поштучном
|
||||
удалении
|
||||
- **AND** остальные загрузки пачки удаляются
|
||||
@@ -0,0 +1,498 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Страницы веб-UI
|
||||
|
||||
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||
добавления, **серверными фильтром по группе состояний, поиском и постраничной
|
||||
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`),
|
||||
страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
|
||||
задач — секцией живой статистики раздачи, а также **страницу группового
|
||||
удаления загрузок** (`/delete`). Карточки активных (downloading)
|
||||
загрузок в списке SHALL содержать индикатор прогресса. Карточка загрузки с
|
||||
распознанным типом SHALL нести значок типа (фильм/сериал). Фильтр, поиск и номер
|
||||
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
|
||||
и SHALL работать без клиентского JavaScript. Терминальные состояния `deleted` и
|
||||
`cancelled` SHALL быть скрыты в списке по умолчанию (переключатель «показать
|
||||
всё» раскрывает оба). Шапка SHALL нести ссылки на список загрузок и на страницу
|
||||
группового удаления. Механика живого
|
||||
обновления прогресса и наполнение секции раздачи определяются capability
|
||||
`live-status`.
|
||||
|
||||
#### Scenario: Просмотр одной загрузки
|
||||
|
||||
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
|
||||
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
|
||||
историей
|
||||
|
||||
#### Scenario: Прогресс активной загрузки в списке
|
||||
|
||||
- **WHEN** в списке есть загрузка в состоянии `downloading`
|
||||
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
|
||||
и ETA)
|
||||
|
||||
#### Scenario: Отменённые и удалённые скрыты по умолчанию
|
||||
|
||||
- **WHEN** в списке есть загрузки в состоянии `deleted` или `cancelled` и фильтр
|
||||
«показать всё» не включён
|
||||
- **THEN** они не отображаются, но доступны при включённом переключателе
|
||||
|
||||
#### Scenario: Значок типа в карточке списка
|
||||
|
||||
- **WHEN** загрузка в списке имеет распознанный тип (`movie` или `series`)
|
||||
- **THEN** её карточка показывает значок типа (🎬 фильм / 📺 сериал); при
|
||||
отсутствии распознанного типа значок не показывается
|
||||
|
||||
#### Scenario: Пагинация списка
|
||||
|
||||
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
|
||||
страницу, и клиент запрашивает `GET /?page=N`
|
||||
- **THEN** возвращается N-я страница результатов и элементы навигации по
|
||||
страницам, сохраняющие текущие фильтр и поисковый запрос
|
||||
|
||||
#### Scenario: Серверный фильтр и поиск
|
||||
|
||||
- **WHEN** клиент запрашивает список с параметрами фильтра по состоянию и/или
|
||||
строкой поиска (`GET /?f=review&q=дюна`)
|
||||
- **THEN** сервер возвращает только подходящие загрузки (по группе состояний и
|
||||
совпадению строки в названии, любом идентификаторе загрузки — `download.id`
|
||||
ИЛИ infohash — и контексте), отфильтрованные на стороне БД, а не на клиенте
|
||||
|
||||
#### Scenario: Ссылка на групповое удаление в шапке
|
||||
|
||||
- **WHEN** клиент открывает любую страницу веб-UI
|
||||
- **THEN** в шапке есть ссылка на страницу группового удаления (`/delete`)
|
||||
|
||||
### Requirement: Заголовок загрузки из имени раздачи
|
||||
|
||||
Веб-UI SHALL показывать заголовком загрузки (в карточке списка и в шапке
|
||||
страницы `/download/{id}`) сохранённое отображаемое имя раздачи (`display_name`,
|
||||
переданное в qBittorrent при приёме). Если имя пусто, заголовок SHALL брать
|
||||
распознанное название из плана; если и его нет — сырой источник (`source_ref`),
|
||||
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
|
||||
несколько строк сырым magnet.
|
||||
|
||||
Имя раздачи — недоверенный вход, поэтому **на показе** заголовок SHALL терять
|
||||
управляющие символы направления письма (с ними строка читается не в том
|
||||
порядке, в каком хранится) и SHALL заменять прочие управляющие пробелом. Прочие
|
||||
форматирующие символы юникода система снимать SHALL NOT: без соединителей
|
||||
рассыпаются составные эмодзи и меняется написание имён на ряде письменностей.
|
||||
Хранимое значение эта чистка менять SHALL NOT — поиск по списку идёт по
|
||||
сохранённому имени.
|
||||
|
||||
#### Scenario: Заголовок из имени раздачи
|
||||
|
||||
- **WHEN** у загрузки сохранено отображаемое имя раздачи
|
||||
- **THEN** карточка и страница показывают это имя заголовком
|
||||
|
||||
#### Scenario: Фолбек до распознавания и без имени
|
||||
|
||||
- **WHEN** отображаемого имени нет, но есть распознанное название
|
||||
- **THEN** заголовком служит распознанное название
|
||||
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
|
||||
строки сырой источник, а не многострочный magnet
|
||||
|
||||
#### Scenario: Переворачивающий символ до показа не доезжает
|
||||
|
||||
- **GIVEN** имя раздачи содержит символ переопределения направления письма
|
||||
- **WHEN** заголовок показывается на любой странице веб-UI
|
||||
- **THEN** этого символа в разметке нет
|
||||
- **AND** составные эмодзи в том же имени остаются целыми
|
||||
|
||||
### Requirement: Самообновление живой задачи
|
||||
|
||||
Карточка списка и страница `/download/{id}` SHALL самообновляться, пока задача
|
||||
**наблюдаема**, и SHALL прекращать самообновление, как только она наблюдаемой
|
||||
быть перестала. Наблюдаемы все нетерминальные задачи, а из терминальных — те,
|
||||
которые фоновая сверка возвращает в поток сама: `failed`, `target_missing`,
|
||||
`orphaned`. Задача, которую с места двигает только человек (`done`, `cancelled`,
|
||||
`reverted`, `deleted`), наблюдаемой не является. Признак SHALL жить в домене
|
||||
рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT
|
||||
заводить.
|
||||
|
||||
Требование распространяется на **карточку списка `/` и страницу
|
||||
`/download/{id}`** и на страницу группового удаления (`/delete`)
|
||||
распространяться SHALL NOT: там строки несут выбор человека, а своп корня унёс
|
||||
бы отметки вместе с разметкой — и человек подтвердил бы необратимое удаление по
|
||||
выбору, которого уже не видит. Наблюдаемость самих загрузок этого не отменяет:
|
||||
строки `/delete` перечисляют в том числе `orphaned` и `target_missing`.
|
||||
|
||||
Самообновление SHALL приносить смену состояния целиком — бейдж статуса,
|
||||
заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT
|
||||
сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия
|
||||
этого браузера (переход воркера, действие из Telegram, фоновая сверка), MUST
|
||||
становиться видимой тем же способом, пока задача наблюдаема: интерфейс не знает,
|
||||
кто изменил состояние.
|
||||
|
||||
У одной поверхности SHALL быть **ровно один** источник самообновления. Вложенные
|
||||
живые регионы (прогресс качания в карточке, секция раздачи на странице) MUST NOT
|
||||
опрашивать сервер самостоятельно: своп корня уносит вложенный узел вместе с его
|
||||
поллером, поэтому два опроса на одну поверхность подменяют разметку друг друга и
|
||||
опрашивают одно и то же дважды.
|
||||
|
||||
Интервал самообновления SHALL зависеть от того, несёт ли поверхность блок живых
|
||||
цифр качания: у поверхности с таким блоком интервал SHALL быть **строго меньше**,
|
||||
чем у поверхности без него. Числовые значения интервалов живут в документации
|
||||
проекта, не в спеке.
|
||||
|
||||
Тик самообновления, не сумевший прочитать задачу (записи нет, хранилище
|
||||
отказало), SHALL отвечать успехом и фрагментом, который объясняет положение дел
|
||||
и **не несёт** самообновления: неуспешный ответ не заменяет разметку, поэтому
|
||||
поверхность осталась бы прежней, а опрос продолжался бы бесконечно.
|
||||
|
||||
#### Scenario: Завершение качания видно без перезагрузки
|
||||
|
||||
- **GIVEN** открыт список загрузок и в нём есть задача в `downloading`
|
||||
- **WHEN** qBittorrent довёл раздачу до конца и воркер увёл задачу в
|
||||
`recognizing` и дальше в `review`
|
||||
- **THEN** карточка без перезагрузки страницы показывает бейдж ревью и кнопку
|
||||
«Ревью →»
|
||||
- **AND** блок живого прогресса с неё исчезает
|
||||
|
||||
#### Scenario: Переход, сделанный не из этого браузера
|
||||
|
||||
- **GIVEN** открыт список загрузок и в нём есть задача в `review`
|
||||
- **WHEN** человек подтвердил план из Telegram и задача прошла `linking` в `done`
|
||||
- **THEN** карточка без перезагрузки страницы показывает бейдж `done` и действия
|
||||
терминальной задачи
|
||||
|
||||
#### Scenario: Ненаблюдаемая задача не опрашивается
|
||||
|
||||
- **WHEN** задача находится в `done`, `cancelled`, `reverted` или `deleted`
|
||||
- **THEN** её карточка и страница `/download/{id}` не несут самообновления, и
|
||||
фоновых запросов по ним не уходит
|
||||
|
||||
#### Scenario: Задача, оживлённая сверкой, видна без перезагрузки
|
||||
|
||||
- **GIVEN** открыт список, и в нём есть задача в `failed` (магнет не добрал
|
||||
метаданные за отведённое время)
|
||||
- **WHEN** источник ожил и фоновая сверка вернула задачу в `downloading`
|
||||
- **THEN** карточка без перезагрузки страницы показывает состояние качания
|
||||
|
||||
#### Scenario: Один источник обновления на поверхность
|
||||
|
||||
- **WHEN** отрисована карточка задачи в `downloading` или страница задачи, чья
|
||||
раздача сидирует
|
||||
- **THEN** самообновление объявлено ровно в одном месте поверхности, а вложенные
|
||||
живые регионы своего опроса не ведут
|
||||
|
||||
#### Scenario: Быстрее обновляется то, где есть живые цифры
|
||||
|
||||
- **WHEN** рядом отрисованы карточка задачи в `downloading` и карточка задачи в
|
||||
`review`
|
||||
- **THEN** объявленный интервал самообновления первой строго меньше, чем у второй
|
||||
|
||||
#### Scenario: Тик, который не смог прочитать задачу
|
||||
|
||||
- **GIVEN** открыта карточка наблюдаемой задачи
|
||||
- **WHEN** очередной тик самообновления не нашёл записи или получил отказ
|
||||
хранилища
|
||||
- **THEN** ответ успешен и несёт фрагмент с объяснением
|
||||
- **AND** фрагмент не несёт самообновления, поэтому опрос прекращается
|
||||
|
||||
#### Scenario: Группа и фильтр списка пересчитываются навигацией
|
||||
|
||||
- **GIVEN** открыт список и в нём есть задача в `downloading`
|
||||
- **WHEN** задача дошла до терминального состояния на глазах у смотрящего
|
||||
- **THEN** карточка показывает новое состояние и остаётся на своём месте в
|
||||
прежней группе списка
|
||||
- **AND** группа и фильтр пересчитываются при следующей навигации или
|
||||
перезагрузке — список целиком самообновлением не пересобирается
|
||||
|
||||
#### Scenario: Страница группового удаления не самообновляется
|
||||
|
||||
- **GIVEN** открыта страница `/delete`, и среди её строк есть загрузки в
|
||||
`orphaned` и `target_missing` (наблюдаемые состояния)
|
||||
- **THEN** ни строки, ни страница целиком самообновления не несут, и фоновых
|
||||
запросов по ним не уходит
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Страница группового удаления загрузок
|
||||
|
||||
Веб-UI SHALL предоставлять отдельную страницу (`GET /delete`), перечисляющую
|
||||
**только** те загрузки, для которых полное удаление с файлами разрешено
|
||||
поштучно (состояния `done`, `orphaned`, `target_missing` — см.
|
||||
`state-reconciliation`, «Полное удаление загрузки пользователем»). Загрузки в
|
||||
прочих состояниях страница показывать SHALL NOT. У каждой строки SHALL быть
|
||||
чекбокс выбора, отображаемый заголовок загрузки и её состояние; страница SHALL
|
||||
предлагать одно действие — «Удалить выбранные».
|
||||
|
||||
Условие «в этом состоянии удаление разрешено» SHALL вычисляться единой точкой
|
||||
домена, общей со страницей одной загрузки и с проверкой допуска в ядре;
|
||||
собственного перечня состояний страница держать SHALL NOT.
|
||||
|
||||
Страница SHALL показывать все разрешённые к удалению загрузки без постраничной
|
||||
выдачи: разбиение на страницы лишило бы возможности выбрать пачку. Верхний
|
||||
предел размера одной пачки SHALL быть назван **на самой странице**, рядом с
|
||||
действием: предел, о котором человек узнаёт только из отказа, отнимает уже
|
||||
сделанный выбор.
|
||||
|
||||
Страница SHALL NOT самообновляться опросом сервера — своп разметки стёр бы
|
||||
выбор человека.
|
||||
|
||||
Страница и все её действия SHALL работать без клиентского JavaScript.
|
||||
|
||||
#### Scenario: Показаны только разрешённые к удалению
|
||||
|
||||
- **WHEN** клиент открывает `GET /delete`, а в хранилище есть загрузки во всех
|
||||
состояниях
|
||||
- **THEN** страница содержит строки загрузок в `done`, `orphaned` и
|
||||
`target_missing`
|
||||
- **AND** не содержит строк загрузок в прочих состояниях
|
||||
|
||||
#### Scenario: Ни одной разрешённой загрузки
|
||||
|
||||
- **WHEN** клиент открывает `GET /delete`, а разрешённых к удалению загрузок
|
||||
нет
|
||||
- **THEN** страница показывает пустое состояние и не предлагает удаление
|
||||
|
||||
#### Scenario: Предел пачки назван до отправки
|
||||
|
||||
- **WHEN** клиент открывает `GET /delete` и на странице есть хотя бы одна
|
||||
строка
|
||||
- **THEN** страница называет верхний предел числа загрузок в одной пачке
|
||||
|
||||
#### Scenario: Страница не опрашивает сервер
|
||||
|
||||
- **WHEN** клиент открывает `GET /delete`
|
||||
- **THEN** разметка страницы не содержит самообновления (`hx-trigger="every …"`)
|
||||
|
||||
### Requirement: Групповое удаление требует поимённого подтверждения
|
||||
|
||||
Групповое удаление SHALL идти двумя шагами: выбор и **подтверждение**. Шаг
|
||||
подтверждения SHALL называть каждую выбранную загрузку поимённо — отображаемым
|
||||
заголовком, идентификатором и **состоянием**, — и SHALL нести признак
|
||||
подтверждения в форме исполняющего запроса. Для загрузки в состоянии
|
||||
`orphaned` подтверждение SHALL нести явную отметку, что источник уже пропал и
|
||||
библиотечная ссылка осталась последней копией данных: гард последней копии в
|
||||
удалении выключен сознательно, и осведомлённость человека — единственный
|
||||
оставшийся предохранитель.
|
||||
|
||||
**Граница этой отметки названа прямо: она выводится из состояния, а не из
|
||||
файловой системы.** Случай, когда байты источника исчезли с диска, но раздача
|
||||
осталась в списке qBittorrent, сверка `done` не переоценивает (присутствие
|
||||
источника она берёт из списка раздач, а не с диска) — такая загрузка остаётся
|
||||
`done`, и отметки не получает, хотя библиотечная ссылка уже последняя копия.
|
||||
Требовать обхода файловой системы на экране подтверждения система SHALL NOT;
|
||||
непокрытый случай назван здесь, чтобы отметка не читалась как гарантия.
|
||||
Поштучный путь удаления такой отметки не несёт вовсе.
|
||||
|
||||
Запрос группового удаления без признака подтверждения система SHALL отклонять и
|
||||
SHALL NOT выполнять ни одного удаления. Одно подтверждение SHALL покрывать
|
||||
ровно ту пачку, которая на нём перечислена.
|
||||
|
||||
**Оба запроса — и подтверждение, и исполнение — суть входные границы**, и
|
||||
проверки входа на них одинаковы: исполняющий запрос получает идентификаторы
|
||||
формой заново, а не из состояния сервера, поэтому опираться на проверки,
|
||||
сделанные на шаге подтверждения, он SHALL NOT.
|
||||
|
||||
На каждой из этих границ система SHALL:
|
||||
|
||||
- разбирать каждый идентификатор; идентификатор, который не разобрался, SHALL
|
||||
отклонять запрос целиком, а молча пропускать его система SHALL NOT — человек
|
||||
подтвердил удаление поимённо, и пропуск был бы расхождением с
|
||||
подтверждённым;
|
||||
- схлопывать повторы одного идентификатора до одного;
|
||||
- отклонять запрос целиком при превышении верхнего предела размера пачки, без
|
||||
единого удаления;
|
||||
- отклонять запрос с пустым набором идентификаторов: страницу подтверждения без
|
||||
единой названной загрузки система показывать SHALL NOT, команду удаления не
|
||||
зовёт ни разу.
|
||||
|
||||
Отказ по превышению предела SHALL возвращать страницу выбора с **сохранёнными**
|
||||
отметками и объяснением, а не пустой экран отказа: иначе проверка отнимает всю
|
||||
проделанную человеком работу.
|
||||
|
||||
Идентификатор, который разобрался, но записи в хранилище не имеет, система
|
||||
SHALL называть отдельной строкой — на подтверждении и в отчёте — и молча
|
||||
выбрасывать его SHALL NOT.
|
||||
|
||||
Отказ чтения хранилища система SHALL отличать от отсутствия записи и SHALL NOT
|
||||
выдавать одно за другое: строка, о которой сказано «удалять нечего», а на деле
|
||||
снесённая с файлами, разводит подтверждённое с исполненным, а для `orphaned`
|
||||
уносит с экрана отметку о последней копии — единственный оставшийся
|
||||
предохранитель. Такой идентификатор SHALL получать собственную строку,
|
||||
называющую, что состояние прочитать не удалось, а сам отказ SHALL уходить в
|
||||
журнал.
|
||||
|
||||
#### Scenario: Подтверждение называет выбранные поимённо
|
||||
|
||||
- **WHEN** человек выбирает несколько загрузок и отправляет форму выбора
|
||||
- **THEN** открывается страница подтверждения, где каждая выбранная загрузка
|
||||
названа заголовком, идентификатором и состоянием
|
||||
- **AND** удаление ещё не выполнено
|
||||
|
||||
#### Scenario: Подтверждение предупреждает о последней копии
|
||||
|
||||
- **GIVEN** среди выбранных есть загрузка в состоянии `orphaned`
|
||||
- **WHEN** открывается страница подтверждения
|
||||
- **THEN** её строка несёт отметку, что библиотечная ссылка осталась последней
|
||||
копией данных
|
||||
|
||||
#### Scenario: Без подтверждения не удаляется ничего
|
||||
|
||||
- **GIVEN** выбраны разрешённые к удалению загрузки
|
||||
- **WHEN** приходит запрос группового удаления без признака подтверждения
|
||||
- **THEN** запрос отклоняется с объяснением
|
||||
- **AND** команда удаления не вызывается ни по одной загрузке
|
||||
|
||||
#### Scenario: Неразобранный идентификатор отклоняет запрос
|
||||
|
||||
- **WHEN** в пачке приходит идентификатор, который не разбирается
|
||||
- **THEN** запрос отклоняется целиком
|
||||
- **AND** команда удаления не вызывается ни по одной загрузке
|
||||
|
||||
#### Scenario: Гарды исполняющего запроса не слабее гардов подтверждения
|
||||
|
||||
- **WHEN** исполняющий запрос приходит с признаком подтверждения, но с
|
||||
неразобранным идентификатором, либо с пачкой сверх предела, либо с пустым
|
||||
набором
|
||||
- **THEN** он отклоняется тем же отказом, что и на шаге подтверждения
|
||||
- **AND** команда удаления не вызывается ни по одной загрузке
|
||||
|
||||
#### Scenario: Пачка сверх предела отклоняется и не стирает выбор
|
||||
|
||||
- **WHEN** в пачке приходит больше идентификаторов, чем допускает предел
|
||||
- **THEN** запрос отклоняется с указанием предела
|
||||
- **AND** команда удаления не вызывается ни по одной загрузке
|
||||
- **AND** ответ возвращает страницу выбора с сохранёнными отметками
|
||||
|
||||
#### Scenario: Пустой выбор
|
||||
|
||||
- **WHEN** человек отправляет форму, не отметив ни одной загрузки
|
||||
- **THEN** страница подтверждения не показывается, ответ объясняет, что выбирать
|
||||
нечего
|
||||
- **AND** команда удаления не вызывается ни по одной загрузке
|
||||
|
||||
#### Scenario: Отказ чтения не выдаётся за отсутствие записи
|
||||
|
||||
- **GIVEN** в пачке есть идентификатор, чтение которого отказало (не «записи
|
||||
нет», а отказ хранилища)
|
||||
- **WHEN** открывается страница подтверждения
|
||||
- **THEN** его строка говорит, что состояние прочитать не удалось, и не
|
||||
утверждает, что удалять нечего
|
||||
- **AND** отказ записан в журнал
|
||||
|
||||
#### Scenario: Идентификатор без записи назван строкой
|
||||
|
||||
- **GIVEN** в пачке из трёх идентификаторов один не имеет записи в хранилище
|
||||
- **WHEN** открывается страница подтверждения, а затем выполняется удаление
|
||||
- **THEN** этот идентификатор назван отдельной строкой и на подтверждении, и в
|
||||
отчёте
|
||||
- **AND** остальные две загрузки удалены
|
||||
|
||||
### Requirement: Исход группового удаления назван поимённо
|
||||
|
||||
Групповое удаление SHALL выполнять команду удаления по каждой подтверждённой
|
||||
загрузке **последовательно и независимо**: отказ на одной загрузке остальных
|
||||
отменять SHALL NOT. По завершении система SHALL показать страницу результата,
|
||||
называющую поимённо удалённые загрузки и отказавшие — каждую с причиной отказа.
|
||||
|
||||
Отчёт SHALL отдаваться **ответом на исполняющий запрос**, а не перенаправлением:
|
||||
поимённый исход нечем передать через параметры адреса, а сессий у сервиса нет.
|
||||
Повторная отправка той же формы удалённые загрузки повторно сносить SHALL NOT —
|
||||
они находятся в терминальном `deleted`, удаление им недоступно, и повторный
|
||||
запрос даёт по ним отказ по конфликту. Остаток, не выполненный из-за остановки
|
||||
прохода, повторная отправка **доисполняет**, и это ожидаемо: эти загрузки
|
||||
человек подтвердил тем же подтверждением, а браузер о повторной отправке
|
||||
переспрашивает сам. Утверждать, что повтор ничего не делает, система SHALL NOT.
|
||||
|
||||
Исполнение пачки система SHALL доводить до конца независимо от того, дождался
|
||||
ли клиент ответа: отмена HTTP-запроса (закрытая вкладка, обрыв связи)
|
||||
прекращать необратимую операцию на середине SHALL NOT. Исход каждой единицы
|
||||
SHALL попадать в журнал, чтобы факт «что именно снесено» пережил потерю ответа.
|
||||
|
||||
**Проход ограничен сверху временем.** Удаление удерживает общий замок ядра на
|
||||
всё время обращения к qBittorrent, поэтому медленно, но **успешно** отвечающий
|
||||
внешний сервис останавливает фоновую работу сервиса целиком, а порог отказов
|
||||
такого не ловит — он считает только ошибки. Система SHALL держать потолок
|
||||
времени на один проход и по его исчерпании SHALL прекращать проход, называя
|
||||
остаток в отчёте невыполненным. Потолок SHALL проверяться **между** единицами:
|
||||
начатое удаление обрывать SHALL NOT — оборванное, оно встанет между снятием
|
||||
библиотечных ссылок и сносом раздачи.
|
||||
|
||||
**Системный отказ пачку останавливает.** Удаление снимает библиотечные ссылки
|
||||
раньше, чем сносит раздачу, поэтому при недоступном qBittorrent каждая единица
|
||||
успевает выполнить необратимый локальный шаг и падает на внешнем: тайтл уходит
|
||||
из библиотеки, а место не освобождается. Поэтому после **порога подряд идущих
|
||||
отказов внешнего сервиса** (отказ, который не является конфликтом состояния)
|
||||
система SHALL прекращать проход, а остаток подтверждённой пачки SHALL называть
|
||||
в отчёте невыполненным с причиной остановки. Счётчик подряд идущих отказов
|
||||
SHALL сбрасываться на каждом успешном удалении: одиночная сетевая ошибка пачку
|
||||
прерывать SHALL NOT. Системными SHALL NOT считаться два класса отказа — конфликт
|
||||
состояния и отсутствие записи: оба про саму задачу, а не про доступность соседа,
|
||||
и до внешнего сервиса такой вызов вообще не доходит.
|
||||
|
||||
Причина отказа SHALL передаваться публичным каналом (нейтральное сообщение),
|
||||
сырой текст ошибки наружу уходить SHALL NOT.
|
||||
|
||||
Групповой путь прав поштучного расширять SHALL NOT: допуск по состоянию
|
||||
проверяет ядро в момент операции, и загрузка в недопустимом состоянии SHALL
|
||||
отклоняться тем же конфликтом, что и при поштучном удалении.
|
||||
|
||||
#### Scenario: Отказ одной не отменяет остальных
|
||||
|
||||
- **GIVEN** подтверждены три загрузки, и удаление второй из них отказывает
|
||||
- **WHEN** выполняется групповое удаление
|
||||
- **THEN** первая и третья удалены
|
||||
- **AND** страница результата называет вторую и причину её отказа
|
||||
|
||||
#### Scenario: Недопустимое состояние отклоняется тем же конфликтом
|
||||
|
||||
- **GIVEN** в подтверждённой пачке есть загрузка в состоянии, из которого
|
||||
удаление недоступно
|
||||
- **WHEN** выполняется групповое удаление
|
||||
- **THEN** по этой загрузке приходит отказ по конфликту состояния, и она
|
||||
попадает в отчёт строкой отказа
|
||||
- **AND** остальные подтверждённые загрузки удалены
|
||||
|
||||
#### Scenario: Все удалены успешно
|
||||
|
||||
- **GIVEN** подтверждены две загрузки, обе в разрешённом состоянии
|
||||
- **WHEN** выполняется групповое удаление
|
||||
- **THEN** страница результата называет обе как удалённые и не содержит отказов
|
||||
|
||||
#### Scenario: Обрыв связи не останавливает пачку
|
||||
|
||||
- **GIVEN** подтверждена пачка загрузок, и исполнение началось
|
||||
- **WHEN** клиент обрывает запрос до получения ответа
|
||||
- **THEN** удаление доводится по всем подтверждённым загрузкам
|
||||
- **AND** исход каждой из них остаётся в журнале
|
||||
|
||||
#### Scenario: Потолок времени останавливает проход
|
||||
|
||||
- **GIVEN** подтверждена пачка, а удаление каждой единицы идёт долго
|
||||
- **WHEN** отведённое на проход время исчерпано
|
||||
- **THEN** новых удалений не начинается, а остаток назван в отчёте
|
||||
невыполненным с причиной остановки
|
||||
- **AND** удаление, начатое до исчерпания, доводится до конца
|
||||
|
||||
#### Scenario: Повтор доисполняет невыполненный остаток
|
||||
|
||||
- **GIVEN** проход был остановлен, и часть пачки осталась невыполненной
|
||||
- **WHEN** человек отправляет ту же форму повторно
|
||||
- **THEN** уже удалённые загрузки повторно не сносятся (отказ по конфликту)
|
||||
- **AND** невыполненный остаток удаляется
|
||||
|
||||
#### Scenario: Недоступный внешний сервис останавливает пачку
|
||||
|
||||
- **GIVEN** подтверждена пачка загрузок, а qBittorrent недоступен
|
||||
- **WHEN** выполняется групповое удаление и отказы внешнего сервиса идут подряд
|
||||
- **THEN** после достижения порога подряд идущих отказов проход прекращается
|
||||
- **AND** остаток пачки назван в отчёте невыполненным с причиной остановки
|
||||
|
||||
#### Scenario: Одиночный отказ пачку не прерывает
|
||||
|
||||
- **GIVEN** подтверждены четыре загрузки, и отказ внешнего сервиса приходит
|
||||
только по второй
|
||||
- **WHEN** выполняется групповое удаление
|
||||
- **THEN** проход доходит до конца, удалены первая, третья и четвёртая
|
||||
- **AND** отчёт называет отказавшей только вторую
|
||||
|
||||
#### Scenario: Сырая ошибка наружу не уходит
|
||||
|
||||
- **GIVEN** удаление одной из загрузок отказало ошибкой внешнего сервиса
|
||||
- **WHEN** отрисовывается страница результата
|
||||
- **THEN** её строка несёт нейтральное сообщение публичного канала, а не текст
|
||||
ошибки внешнего сервиса
|
||||
@@ -0,0 +1,151 @@
|
||||
## 1. Единая точка допуска
|
||||
|
||||
- [x] 1.1 Завести `(store.State).CanDelete()` рядом с `IsTerminal`/
|
||||
`IsObservable`, перечень `done`/`orphaned`/`target_missing` — одним списком
|
||||
- [x] 1.2 Перевести `switch` в `worker.Delete` на `CanDelete` (проверку в ядре
|
||||
не снимать)
|
||||
- [x] 1.3 Перевести сборку `Deletable` в `internal/httpapi/download.go` на
|
||||
`CanDelete`
|
||||
- [x] 1.4 Перевести выбор клавиатуры в `internal/tgbot/render.go` на
|
||||
`CanDelete` — иначе Telegram остаётся четвёртым перечнем состояний
|
||||
- [x] 1.5 Тест: `CanDelete` истинно ровно для трёх состояний и ложно для
|
||||
остальных (перебор всех состояний)
|
||||
- [x] 1.6 Тест `internal/tgbot`: клавиатура с действием удаления приходит ровно
|
||||
для тех состояний, где `CanDelete` истинно
|
||||
|
||||
## 2. Чтение списка разрешённых к удалению
|
||||
|
||||
- [x] 2.1 Добавить в `Reader` метод выборки загрузок, разрешённых к удалению,
|
||||
без пагинации; порядок — как в основном списке
|
||||
- [x] 2.2 Реализовать выборку в `store` через существующую механику фильтра по
|
||||
состояниям (перечень состояний берётся из единой точки, второго списка не
|
||||
заводить)
|
||||
- [x] 2.3 Тест `store`: выборка возвращает только `done`/`orphaned`/
|
||||
`target_missing`
|
||||
|
||||
## 3. Страница выбора
|
||||
|
||||
- [x] 3.1 Шаблон страницы `/delete`: строки с чекбоксами, заголовок, состояние
|
||||
каждой загрузки, кнопка «Удалить выбранные», названный предел размера пачки
|
||||
рядом с кнопкой, пустое состояние
|
||||
- [x] 3.2 Ссылка на страницу в `web/templates/partials/header.html`, активный
|
||||
пункт навигации
|
||||
- [x] 3.3 Обработчик `GET /delete`: сборка представления, без `hx-*`
|
||||
самообновления
|
||||
- [x] 3.4 Тест: подставной читатель отдаёт задачи во всех состояниях — в
|
||||
разметке есть строки только у `done`/`orphaned`/`target_missing`
|
||||
- [x] 3.5 Тест: в разметке страницы нет `hx-trigger="every`
|
||||
- [x] 3.6 Тест: страница называет предел размера пачки
|
||||
- [x] 3.7 Тест: пустое состояние — нет разрешённых, удаление не предлагается
|
||||
|
||||
## 4. Разбор входа (общий для обеих границ)
|
||||
|
||||
- [x] 4.1 Общая функция разбора пачки: `ident.Parse` по каждому идентификатору
|
||||
с отказом всего запроса, схлопывание дублей, проверка предела, отказ на пустом
|
||||
наборе. Предел — именованная константа, значение **20**
|
||||
- [x] 4.2 Обработчик `POST /ui/delete/confirm`: разбор через общую функцию,
|
||||
чтение выбранных загрузок; ненайденная запись — строка «загрузка не найдена»,
|
||||
а не молчаливый пропуск
|
||||
- [x] 4.3 Отказ по пределу возвращает страницу выбора с сохранёнными отметками
|
||||
и объяснением
|
||||
- [x] 4.4 Шаблон страницы подтверждения: каждая выбранная загрузка названа
|
||||
заголовком, идентификатором и состоянием; для `orphaned` — отметка «источник
|
||||
пропал, библиотечная ссылка — последняя копия данных»; скрытые поля с
|
||||
идентификаторами, признак подтверждения, кнопка исполнения и ссылка возврата
|
||||
- [x] 4.5 Тест: страница подтверждения содержит заголовки всех выбранных и не
|
||||
делает ни одного вызова `Delete`
|
||||
- [x] 4.6 Тест: строка `orphaned` на подтверждении несёт отметку о последней
|
||||
копии
|
||||
- [x] 4.7 Тест: пачка сверх предела отклоняется целиком, `Delete` не зовётся, а
|
||||
ответ несёт страницу выбора с сохранёнными отметками
|
||||
- [x] 4.8 Тест: неразобранный идентификатор отклоняет запрос целиком
|
||||
- [x] 4.9 Тест: пустой выбор — подтверждение не показывается, `Delete` не
|
||||
зовётся
|
||||
- [x] 4.10 Тест: идентификатор без записи назван строкой на подтверждении и в
|
||||
отчёте, остальные загрузки удалены
|
||||
|
||||
## 5. Исполнение и отчёт
|
||||
|
||||
- [x] 5.1 Обработчик `POST /ui/delete`: отказ без признака подтверждения **до**
|
||||
цикла, ни одного вызова `Delete`
|
||||
- [x] 5.2 Тот же разбор входа, что и на подтверждении (пункт 4.1): исполняющий
|
||||
запрос — самостоятельная входная граница
|
||||
- [x] 5.3 Последовательный вызов `Reviewer.Delete` по каждой подтверждённой
|
||||
загрузке, сбор исхода по каждой; контекст исполнения отвязан от `r.Context()`
|
||||
(`context.WithoutCancel`); ошибка — через публичный канал (`userErr`)
|
||||
- [x] 5.3a Потолок времени на проход (`bulkBudget`, 2 минуты), проверяемый
|
||||
между единицами: начатое удаление не обрывается. Остаток — в отчёт строками
|
||||
«не выполнено»
|
||||
- [x] 5.4 Остановка прохода после **трёх подряд** отказов внешнего сервиса
|
||||
(`ErrConflict` системным не считается и счётчик не двигает; успех счётчик
|
||||
сбрасывает); остаток пачки — в отчёт строками «не выполнено» с причиной
|
||||
остановки. Порог — именованная константа
|
||||
- [x] 5.5 Шаблон страницы результата: удалённые поимённо, отказавшие поимённо с
|
||||
причиной, невыполненный остаток и причина остановки, ссылка обратно на
|
||||
`/delete`
|
||||
- [x] 5.6 Тест: POST без признака подтверждения — отказ, ноль вызовов `Delete`
|
||||
- [x] 5.7 Тест: гарды исполняющего запроса не слабее гардов подтверждения —
|
||||
неразобранный идентификатор, пачка сверх предела и пустой набор отклоняются с
|
||||
признаком подтверждения тоже, `Delete` не зовётся
|
||||
- [x] 5.8 Тест: второй `Delete` возвращает ошибку — первая и третья удалены,
|
||||
страница результата называет вторую и её причину
|
||||
- [x] 5.9 Тест: `Delete` для `downloading` возвращает `ErrConflict` — страница
|
||||
показывает отказ по ней, остальные выбранные удалены
|
||||
- [x] 5.10 Тест: все удалены — отчёт называет обе как удалённые, отказов нет
|
||||
- [x] 5.11 Тест: отмена запроса не прекращает пачку — `Delete` вызван по всем
|
||||
подтверждённым
|
||||
- [x] 5.12 Тест: сырой текст ошибки внешнего сервиса в разметку не попадает
|
||||
- [x] 5.13 Тест: страница и действия работают без htmx (обычные POST-формы,
|
||||
`action` рабочий)
|
||||
|
||||
## 6. Сдача
|
||||
|
||||
- [x] 6.1 `openspec validate --strict bulk-delete-page`
|
||||
- [x] 6.2 `task gate` зелёный
|
||||
- [x] 6.3 Поведенческая проверка вживую: поднять `task run`, пройти путь
|
||||
список → подтверждение → результат
|
||||
- [x] 6.4 Три числовых порога (размер пачки, число подряд идущих отказов,
|
||||
потолок времени на проход) — в `docs/database.md`, раздел настроек с
|
||||
числовым значением
|
||||
|
||||
## Критерии приёмки (из постановки)
|
||||
|
||||
- [x] П1 Страница открывается из шапки и показывает только те загрузки, для
|
||||
которых удаление разрешено поштучно (оракул: тест `internal/httpapi` —
|
||||
подставной читатель отдаёт задачи во всех состояниях, в разметке строки есть
|
||||
у `done`/`orphaned`/`target_missing` и нет у остальных)
|
||||
- [x] П2 Удаление уходит только после явного подтверждения, и подтверждение
|
||||
называет каждую выбранную раздачу поимённо (оракул: тест — POST без признака
|
||||
подтверждения отвечает отказом и не делает ни одного вызова `Delete` у
|
||||
подставного воркера; ответ подтверждения содержит заголовки всех выбранных)
|
||||
- [x] П3 Отказ на одной загрузке не отменяет остальных, а результат называет
|
||||
удалённые и отказавшие поимённо с причиной (оракул: тест, где второй `Delete`
|
||||
возвращает ошибку — первая и третья удалены, страница результата называет
|
||||
вторую и её причину)
|
||||
- [x] П4 Групповой путь не расширяет прав поштучного (оракул: тест — `Delete`
|
||||
для `downloading` возвращает `ErrConflict`, страница показывает отказ,
|
||||
остальные выбранные не затронуты)
|
||||
- [x] П5 Поведение страницы записано дельта-спекой и проходит валидацию
|
||||
(оракул: `openspec validate --strict` и `task gate`)
|
||||
|
||||
## Приёмочные критерии из рубрики ревью дизайна
|
||||
|
||||
- [x] Р1 Исполняется ровно подтверждённое множество, названное списком
|
||||
идентификаторов, а не предикатом «всё, что сейчас в состоянии X» (оракул:
|
||||
тест — задача, ставшая разрешённой после показа подтверждения, не удаляется)
|
||||
- [x] Р2 Множество допустимых состояний имеет единственный дом, и групповой путь
|
||||
прав не расширяет (оракул: пункты 1.5, 1.6, 5.9)
|
||||
- [x] Р3 Экран подтверждения называет не только предмет, но и последствие —
|
||||
состояние и снятие последней копии для `orphaned` (оракул: пункт 4.6)
|
||||
- [x] Р4 Вход валидируется целиком до первого эффекта, на обеих границах
|
||||
(оракул: пункты 4.7–4.9, 5.7)
|
||||
- [x] Р5 Частичный отказ не отменяет остальных, повтор идемпотентен (оракул:
|
||||
пункты 5.8, 5.9; повтор — уже `deleted`, `CanDelete` ложно)
|
||||
- [x] Р6 Обрыв или отмена запроса не оставляет пачку на середине, исход каждой
|
||||
единицы переживает потерю ответа (оракул: пункт 5.11 и `logCmd` по каждому
|
||||
вызову)
|
||||
- [x] Р7 Предел пачки виден там, где формируется вход, и отказ по нему не стирает
|
||||
выбор (оракул: пункты 3.6, 4.7)
|
||||
- [x] Р8 Транспорт доменной логики не содержит: зовётся тот же
|
||||
`Reviewer.Delete`, переход состояния — в воркере (оракул: чтение диффа на
|
||||
ревью кода)
|
||||
Reference in New Issue
Block a user