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,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). Фраза пре-существующая; по решению человека дефект уходит
в беклог отдельной задачей, а текст вливается в спеку как есть.