Files
jellybit/openspec/changes/archive/2026-08-10-bulk-delete-page/tasks.md
T
av 288be8ec34 web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
2026-08-10 17:43:36 +03:00

13 KiB
Raw Blame History

1. Единая точка допуска

  • 1.1 Завести (store.State).CanDelete() рядом с IsTerminal/ IsObservable, перечень done/orphaned/target_missing — одним списком
  • 1.2 Перевести switch в worker.Delete на CanDelete (проверку в ядре не снимать)
  • 1.3 Перевести сборку Deletable в internal/httpapi/download.go на CanDelete
  • 1.4 Перевести выбор клавиатуры в internal/tgbot/render.go на CanDelete — иначе Telegram остаётся четвёртым перечнем состояний
  • 1.5 Тест: CanDelete истинно ровно для трёх состояний и ложно для остальных (перебор всех состояний)
  • 1.6 Тест internal/tgbot: клавиатура с действием удаления приходит ровно для тех состояний, где CanDelete истинно

2. Чтение списка разрешённых к удалению

  • 2.1 Добавить в Reader метод выборки загрузок, разрешённых к удалению, без пагинации; порядок — как в основном списке
  • 2.2 Реализовать выборку в store через существующую механику фильтра по состояниям (перечень состояний берётся из единой точки, второго списка не заводить)
  • 2.3 Тест store: выборка возвращает только done/orphaned/ target_missing

3. Страница выбора

  • 3.1 Шаблон страницы /delete: строки с чекбоксами, заголовок, состояние каждой загрузки, кнопка «Удалить выбранные», названный предел размера пачки рядом с кнопкой, пустое состояние
  • 3.2 Ссылка на страницу в web/templates/partials/header.html, активный пункт навигации
  • 3.3 Обработчик GET /delete: сборка представления, без hx-* самообновления
  • 3.4 Тест: подставной читатель отдаёт задачи во всех состояниях — в разметке есть строки только у done/orphaned/target_missing
  • 3.5 Тест: в разметке страницы нет hx-trigger="every
  • 3.6 Тест: страница называет предел размера пачки
  • 3.7 Тест: пустое состояние — нет разрешённых, удаление не предлагается

4. Разбор входа (общий для обеих границ)

  • 4.1 Общая функция разбора пачки: ident.Parse по каждому идентификатору с отказом всего запроса, схлопывание дублей, проверка предела, отказ на пустом наборе. Предел — именованная константа, значение 20
  • 4.2 Обработчик POST /ui/delete/confirm: разбор через общую функцию, чтение выбранных загрузок; ненайденная запись — строка «загрузка не найдена», а не молчаливый пропуск
  • 4.3 Отказ по пределу возвращает страницу выбора с сохранёнными отметками и объяснением
  • 4.4 Шаблон страницы подтверждения: каждая выбранная загрузка названа заголовком, идентификатором и состоянием; для orphaned — отметка «источник пропал, библиотечная ссылка — последняя копия данных»; скрытые поля с идентификаторами, признак подтверждения, кнопка исполнения и ссылка возврата
  • 4.5 Тест: страница подтверждения содержит заголовки всех выбранных и не делает ни одного вызова Delete
  • 4.6 Тест: строка orphaned на подтверждении несёт отметку о последней копии
  • 4.7 Тест: пачка сверх предела отклоняется целиком, Delete не зовётся, а ответ несёт страницу выбора с сохранёнными отметками
  • 4.8 Тест: неразобранный идентификатор отклоняет запрос целиком
  • 4.9 Тест: пустой выбор — подтверждение не показывается, Delete не зовётся
  • 4.10 Тест: идентификатор без записи назван строкой на подтверждении и в отчёте, остальные загрузки удалены

5. Исполнение и отчёт

  • 5.1 Обработчик POST /ui/delete: отказ без признака подтверждения до цикла, ни одного вызова Delete
  • 5.2 Тот же разбор входа, что и на подтверждении (пункт 4.1): исполняющий запрос — самостоятельная входная граница
  • 5.3 Последовательный вызов Reviewer.Delete по каждой подтверждённой загрузке, сбор исхода по каждой; контекст исполнения отвязан от r.Context() (context.WithoutCancel); ошибка — через публичный канал (userErr)
  • 5.3a Потолок времени на проход (bulkBudget, 2 минуты), проверяемый между единицами: начатое удаление не обрывается. Остаток — в отчёт строками «не выполнено»
  • 5.4 Остановка прохода после трёх подряд отказов внешнего сервиса (ErrConflict системным не считается и счётчик не двигает; успех счётчик сбрасывает); остаток пачки — в отчёт строками «не выполнено» с причиной остановки. Порог — именованная константа
  • 5.5 Шаблон страницы результата: удалённые поимённо, отказавшие поимённо с причиной, невыполненный остаток и причина остановки, ссылка обратно на /delete
  • 5.6 Тест: POST без признака подтверждения — отказ, ноль вызовов Delete
  • 5.7 Тест: гарды исполняющего запроса не слабее гардов подтверждения — неразобранный идентификатор, пачка сверх предела и пустой набор отклоняются с признаком подтверждения тоже, Delete не зовётся
  • 5.8 Тест: второй Delete возвращает ошибку — первая и третья удалены, страница результата называет вторую и её причину
  • 5.9 Тест: Delete для downloading возвращает ErrConflict — страница показывает отказ по ней, остальные выбранные удалены
  • 5.10 Тест: все удалены — отчёт называет обе как удалённые, отказов нет
  • 5.11 Тест: отмена запроса не прекращает пачку — Delete вызван по всем подтверждённым
  • 5.12 Тест: сырой текст ошибки внешнего сервиса в разметку не попадает
  • 5.13 Тест: страница и действия работают без htmx (обычные POST-формы, action рабочий)

6. Сдача

  • 6.1 openspec validate --strict bulk-delete-page
  • 6.2 task gate зелёный
  • 6.3 Поведенческая проверка вживую: поднять task run, пройти путь список → подтверждение → результат
  • 6.4 Три числовых порога (размер пачки, число подряд идущих отказов, потолок времени на проход) — в docs/database.md, раздел настроек с числовым значением

Критерии приёмки (из постановки)

  • П1 Страница открывается из шапки и показывает только те загрузки, для которых удаление разрешено поштучно (оракул: тест internal/httpapi — подставной читатель отдаёт задачи во всех состояниях, в разметке строки есть у done/orphaned/target_missing и нет у остальных)
  • П2 Удаление уходит только после явного подтверждения, и подтверждение называет каждую выбранную раздачу поимённо (оракул: тест — POST без признака подтверждения отвечает отказом и не делает ни одного вызова Delete у подставного воркера; ответ подтверждения содержит заголовки всех выбранных)
  • П3 Отказ на одной загрузке не отменяет остальных, а результат называет удалённые и отказавшие поимённо с причиной (оракул: тест, где второй Delete возвращает ошибку — первая и третья удалены, страница результата называет вторую и её причину)
  • П4 Групповой путь не расширяет прав поштучного (оракул: тест — Delete для downloading возвращает ErrConflict, страница показывает отказ, остальные выбранные не затронуты)
  • П5 Поведение страницы записано дельта-спекой и проходит валидацию (оракул: openspec validate --strict и task gate)

Приёмочные критерии из рубрики ревью дизайна

  • Р1 Исполняется ровно подтверждённое множество, названное списком идентификаторов, а не предикатом «всё, что сейчас в состоянии X» (оракул: тест — задача, ставшая разрешённой после показа подтверждения, не удаляется)
  • Р2 Множество допустимых состояний имеет единственный дом, и групповой путь прав не расширяет (оракул: пункты 1.5, 1.6, 5.9)
  • Р3 Экран подтверждения называет не только предмет, но и последствие — состояние и снятие последней копии для orphaned (оракул: пункт 4.6)
  • Р4 Вход валидируется целиком до первого эффекта, на обеих границах (оракул: пункты 4.74.9, 5.7)
  • Р5 Частичный отказ не отменяет остальных, повтор идемпотентен (оракул: пункты 5.8, 5.9; повтор — уже deleted, CanDelete ложно)
  • Р6 Обрыв или отмена запроса не оставляет пачку на середине, исход каждой единицы переживает потерю ответа (оракул: пункт 5.11 и logCmd по каждому вызову)
  • Р7 Предел пачки виден там, где формируется вход, и отказ по нему не стирает выбор (оракул: пункты 3.6, 4.7)
  • Р8 Транспорт доменной логики не содержит: зовётся тот же Reviewer.Delete, переход состояния — в воркере (оракул: чтение диффа на ревью кода)