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,52 @@
# Отметка «последняя копия» на подтверждении удаления выводится из состояния, а не из файловой системы
- **Дата:** 2026-08-10
- **Источник:** openspec/changes/archive/2026-08-10-bulk-delete-page/design.md
## Решение
Экран подтверждения группового удаления помечает загрузку как последнюю копию
данных по её **состоянию** (`orphaned`), не спрашивая файловую систему. Случай,
когда байты источника исчезли с диска, а раздача осталась в списке qBittorrent,
такой отметки не получает — и это записано границей в спеке `web-ui`, а не
оставлено умолчанием.
## Почему
Гард последней копии в `Delete` выключен сознательно (инвариант «источник
неприкосновенен», исключение 1), поэтому осведомлённость человека — единственный
оставшийся предохранитель. Отсюда решение D2 источника:
> Признак берётся из состояния (`orphaned` по определению значит «источник
> пропал, цель — последняя копия»), а не обходом файловой системы.
Враждебный проход ревью построил путь, где это неверно: сверка берёт присутствие
источника из ответа `torrents/info`, а не с диска, поэтому задача с пропавшими
байтами остаётся `done` сколько угодно долго и отметки не получает. Дыра
признана и оставлена открытой по решению человека: поштучное удаление такой
отметки не несёт **вовсе**, то есть групповой путь не ухудшил положение, а
улучшил его не до конца. Закрывать её обходом файловой системы на экране
подтверждения значит завести чтение диска в транспорте ради предупреждения,
которое и сегодня лучше прежнего.
## Рассмотренные варианты
- **Спрашивать файловую систему на подтверждении** (`nlink` по живым ссылкам
последнего батча) — отметка стала бы правдой, но транспорт начал бы ходить в
файловую систему ради показа, а пачка ограничена двадцатью строками только
сегодня.
- **Вернуть гард последней копии в `Delete`** — отменяет само назначение
команды: она затем и существует, чтобы снять последнюю копию осознанно.
- **Убрать отметку совсем** — честно, но теряет полезный сигнал про пропавший
источник, который в подавляющем большинстве случаев и есть последняя копия.
## Последствия
- `+` Подтверждение предупреждает о последней копии там, где раньше не
предупреждало ничто; признак берётся из домена, второго перечня состояний не
заводится.
- `+` Транспорт не ходит в файловую систему ради показа.
- `` Случай «`done` с пропавшими байтами источника» отметки не получает. Дыра
названа в спеке прямо, чтобы отметка не читалась как гарантия.
- `` Пока сверка берёт присутствие источника из списка раздач, а не с диска,
закрыть дыру нельзя ни на одном экране.
+1
View File
@@ -42,6 +42,7 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-10 | [Отметка «последняя копия» на подтверждении удаления выводится из состояния, а не из файловой системы](ADR-2026-08-10-last-copy-warning-from-state.md) | — |
| 2026-08-10 | [Наблюдаемость поверхности не выводится из терминальности задачи](ADR-2026-08-10-observability-is-not-terminality.md) | — |
| 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — |
| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — |
+1
View File
@@ -118,6 +118,7 @@
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки |
| Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью |
| Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) |
| Условие допуска полного удаления | `store.State.CanDelete()` — «из этого состояния удаление с файлами разрешено»; своего перечня состояний не заводит ни один транспорт (веб-UI, Telegram, страница группового удаления), а проверку в ядре предикат не заменяет: допуск держится без транспорта |
| Условие самообновления веб-UI | `store.State.IsObservable()` — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит **ровно один** поллер на обновляемый корень — [ADR-2026-08-10-observability-is-not-terminality](adr/ADR-2026-08-10-observability-is-not-terminality.md), правило разметки — [conventions/web-ui.md](conventions/web-ui.md) |
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
+1
View File
@@ -86,6 +86,7 @@ jellybit — **приложение, а не библиотека**: внешн
| `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
| `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errBatchEmpty` / `errBatchTooLarge` / `errBatchBadID` (разбор пачки группового удаления, локальные sentinel'ы `httpapi`) | 400 | текст самой ошибки |
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
| `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
+3
View File
@@ -214,6 +214,9 @@ erDiagram
| `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) |
| `httpapi.pollFast` | `5s` | интервал самообновления поверхности с живыми цифрами качания (карточка в `downloading`). Держится вровень с `[worker].poll_interval`: снимок телеметрии обновляется тиком воркера, и опрос чаще возвращает тот же снимок. Меняется `poll_interval` — меняется и эта константа |
| `httpapi.pollSlow` | `15s` | интервал самообновления прочих наблюдаемых поверхностей: карточек вне `downloading` и страницы `/download/{id}` в любом состоянии. Тик страницы считает предпросмотр раскладки и ходит в ФС, поэтому частота у него ниже |
| `httpapi.maxBulkDelete` | `20` загрузок | предел размера одной пачки группового удаления. Подтверждение, перечисляющее больше, человек не читает — то есть перестаёт быть подтверждением; плюс один синхронный запрос упирается в столько же последовательных вызовов qBittorrent. Предел называет сама страница выбора; отказ по пределу возвращает выбор с сохранёнными отметками |
| `httpapi.bulkFailThreshold` | `3` отказа подряд | сколько подряд идущих отказов внешнего сервиса прекращают проход группового удаления. Удаление снимает библиотечные ссылки раньше, чем сносит раздачу: при недоступном qBittorrent каждая единица успевает выполнить необратимый локальный шаг и упасть на внешнем. Счётчик сбрасывается на успехе; конфликт состояния системным отказом не считается |
| `httpapi.bulkBudget` | `2` минуты | потолок времени на один проход группового удаления. Удаление держит общий замок воркера на всё время обращения к qBittorrent, поэтому медленно, но успешно отвечающий сосед остановил бы фоновую работу целиком, а порог отказов такого не ловит. Проверяется между единицами: начатое удаление не обрывается, иначе оно встанет между снятием ссылок и сносом раздачи |
| `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой |
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
+52 -2
View File
@@ -179,8 +179,8 @@ Go-сервиса и что здесь уже проскакивало. Устр
- `requirements`: не завелось ли поведение, которого спека не заказывала — тихий
дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле?
- `security`: читается ли тело ответа внешнего сервиса целиком без предела —
лимита на размер ответа LLM в проекте нет, и это единственный недоверенный
канал, где предел не стоит ([security.md](security.md) → «Что вне модели»)
у LLM (8 MiB) и метабаз (4 MiB) предел стоит, и новый исходящий вызов обязан
заводить свой ([security.md](security.md) → «Что вне модели»)
- `operations`: гарантия, которую вводит изменение, поставлена на запись или на
чтение — и что будет с данными, записанными до деплоя, которые обычный путь
не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
@@ -231,6 +231,13 @@ Go-сервиса и что здесь уже проскакивало. Устр
(`internal/metadata`, `metadata-match`); merge-раскладка при повторном
добавлении раздачи.
- **новая поверхность поверх необратимой операции** — вторая точка входа в
команду, которая удаляет файлы или снимает последнюю копию. Форма решения
здесь нащупывается по ходу: подтверждение, порядок отказов, остаток,
наблюдаемость. Выведено по факту на `bulk-delete-page` (журнал, 2026-08-10):
метка `medium` не дала ни враждебного прохода, ни замера, а именно они нашли
четыре дефекта класса «необратимо».
- «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram**
для единственного пользователя это и есть интерфейс.
@@ -325,6 +332,49 @@ Go-сервиса и что здесь уже проскакивало. Устр
случаи до этой даты не восстанавливались — восстановленная постфактум причина
непоймания недостоверна, а именно она и нужна.
## 2026-08-10 — метка занижена: новая поверхность поверх необратимой операции прошла как среднее знакомое [пойман]
- **Где:** конвейер, а не код — разметка задачи `bulk-delete-page`
- **Симптом:** прогон по метке `medium` закончился шестью находками, из них ни
одной про необратимое. Сигнал «метка, вероятно, занижена» вернули два прохода
из четырёх — `code` и `basics`, с одинаковым основанием: дифф трогает `store`,
`worker`, `httpapi`, `tgbot` и шаблоны и заводит новую точку входа поверх
команды, удаляющей файлы
- **Причина:** обе оси считались по объёму и по знакомости узлов, и по ним
изменение честно выходило средним и знакомым — цикл над готовым `Delete`. Ни
один триггер `large` не описывал случай «поверхность новая, а операция за ней
необратимая»
- **Чем воспроизведён:** повторная разметка после правок дельта-спек вернула
`large`; догнанные проходы дали четыре находки класса «необратимо», из них три
с прогнанными падающими тестами (`TestAdversaryDoneRowIsLastCopyWithoutWarning`,
`TestAdversaryDeleteWipesSourceOfAnotherActiveDownload`) и одна с замером
удержания общего замка воркера (`280.450631ms` при задержке соседа `300ms`)
- **Что меняем:** в «Триггеры метки», ось «незнакомое», добавлен пункт про новую
поверхность поверх необратимой операции
## 2026-08-10 — три дефекта поштучного удаления жили незамеченными, пока рядом не появилась пачка [проскочил]
- **Где:** `internal/worker/review.go` (`Delete`), `internal/worker/worker.go`
(`Poll`)
- **Симптом:** враждебный и эксплуатационный проходы на задаче
`bulk-delete-page` нашли три дефекта, ни один из которых эта задача не
вносила: удаление сносит раздачу, которой владеет **другая активная** загрузка
с тем же инфохэшем; удаление держит общий замок воркера через сетевой вызов и
останавливает фоновую работу на это время; при недоступном qBittorrent задача остаётся `done` весь простой
соседа, потому что сверка возвращается на первой же ошибке и до коррекции не
доходит
- **Причина:** поштучное удаление ни разу не проверялось меткой `large` — ни
построенного пути, ни замера против него не гонял никто
- **Чем воспроизведён:** тесты и замеры перечислены в
`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`,
находки 24
- **Почему не поймали:** проходы, находящие этот класс, живут в метке `large`, а
задачи, заводившие и правившие `Delete`, шли ниже. Дефект не «пропустил
проход» — проход не запускался
- **Что меняем:** три записи в беклоге со ссылкой на оракулы; триггер метки
дополнен (см. запись выше), чтобы следующая поверхность над необратимой
операцией шла сразу с доказательными проходами
## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]
- **Где:** `internal/httpapi/live_test.go``TestFragProgressStopsWhenNotDownloading`
+3 -2
View File
@@ -32,6 +32,7 @@ REST API работают **без авторизации** осознанно;
| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь; чистятся наравне с выходом LLM на каждой точке входа в план ([ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md)) |
| Ответы qBittorrent | HTTP | пути, состояния, размеры |
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
| Пачка идентификаторов и признак подтверждения на групповом удалении | форма веб-UI | необратимое действие сразу по многим загрузкам; разбор, схлопывание дублей и предел размера стоят на **обеих** границах — подтверждении и исполнении, потому что вторая получает пачку формой заново |
**Выход LLM не отвечает за безопасность.** Инъекция в промпт считается
состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути.
@@ -103,8 +104,8 @@ REST API работают **без авторизации** осознанно;
- **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов,
бесконечный ответ LLM — это вопросы устойчивости и ресурсов
([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности.
Лимита на размер ответа LLM нет — известный пробел
([architecture.md](architecture.md) → «Открытые вопросы»).
Тело ответа внешнего сервиса при этом читается с пределом: LLM — 8 MiB
(`internal/llm`), метабазы — 4 MiB (`internal/metadata`).
- **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота.
- **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга.
- **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и