web-ui: добавлена страница группового удаления загрузок

- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
av
2026-08-10 17:43:36 +03:00
parent a7c1efd8eb
commit 288be8ec34
35 changed files with 3185 additions and 31 deletions
@@ -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` A1A3, `adversary` S1S4,
`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 находки (A1A3) |
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 4 находки (S1S4) |
| operations | `docs/architecture.md` «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта**, 3 находки (O1O3) |
| тема проекта | своих тем в `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). Фраза пре-существующая; по решению человека дефект уходит
в беклог отдельной задачей, а текст вливается в спеку как есть.
@@ -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.74.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`, переход состояния — в воркере (оракул: чтение диффа на
ревью кода)