web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
@@ -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` с пропавшими байтами источника» отметки не получает. Дыра
|
||||
названа в спеке прямо, чтобы отметка не читалась как гарантия.
|
||||
- `−` Пока сверка берёт присутствие источника из списка раздач, а не с диска,
|
||||
закрыть дыру нельзя ни на одном экране.
|
||||
@@ -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) | — |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 | «целевое имя слишком длинное…» |
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
находки 2–4
|
||||
- **Почему не поймали:** проходы, находящие этот класс, живут в метке `large`, а
|
||||
задачи, заводившие и правившие `Delete`, шли ниже. Дефект не «пропустил
|
||||
проход» — проход не запускался
|
||||
- **Что меняем:** три записи в беклоге со ссылкой на оракулы; триггер метки
|
||||
дополнен (см. запись выше), чтобы следующая поверхность над необратимой
|
||||
операцией шла сразу с доказательными проходами
|
||||
|
||||
## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]
|
||||
|
||||
- **Где:** `internal/httpapi/live_test.go` — `TestFragProgressStopsWhenNotDownloading`
|
||||
|
||||
+3
-2
@@ -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 и
|
||||
|
||||
Reference in New Issue
Block a user