Единое окно: полное пользовательское удаление загрузки (delete)
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted. Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне; инициатор различается через error_code=user_delete. Подтверждение обязательно: веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои ссылки под movies/series). Граф переходов не менялся — рёбра уже были. OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
## Контекст
|
||||
|
||||
Реализуем вторую половину «единого окна» — пользовательское удаление загрузки.
|
||||
Точки подключения (из разведки кода):
|
||||
|
||||
- Методы-действия воркера берут `w.mu.Lock()`, читают `GetDownload`, проверяют
|
||||
предусловие по `d.State`, делают переход. Образцы: `Undo`
|
||||
(`internal/worker/review.go:509`), `Cancel`/`Retry`
|
||||
(`internal/worker/worker.go:772`/`:792`), `Defer` (`review.go:489`).
|
||||
- `Undo` уже снимает хардлинки последнего батча: `LatestBatchID` →
|
||||
`ListFileLinksByBatch` → фильтр `isLaidOut` (пропускаем `superseded`) →
|
||||
`layouter.Undo(links)` → `DeleteFileLinksByBatch` → `transition(...Reverted)`.
|
||||
Гард последней копии — в `layout.Undo` (`internal/layout/layout.go:393`,
|
||||
отказ `ErrLastCopy` при `nlink<=1`).
|
||||
- Инициатор перехода различается по `error_code`: сверка кладёт `"reconcile"`
|
||||
(`reconcile.go:290`), человекочитаемую причину — в `error_msg`; пользовательские
|
||||
действия сейчас передают `"",""`.
|
||||
- Рёбра `done→deleted`, `orphaned→deleted`, `target_missing→deleted` уже есть в
|
||||
`allowedTransitions` (`internal/store/download.go:94,100,101`); `deleted`
|
||||
терминально (`StateDeleted: nil`). **Граф не трогаем.**
|
||||
- Метода удаления в `qbt` нет — добавляем (`Add`/`Torrents`/`Files` — образцы
|
||||
вызова WebUI API с `ExtCall`-логированием).
|
||||
|
||||
## Решения
|
||||
|
||||
### 1. Delete как единая операция «снять обе стороны»
|
||||
|
||||
`Worker.Delete(ctx, id)`:
|
||||
|
||||
1. `Lock`; `GetDownload`; предусловие `d.State ∈ {done, orphaned, target_missing}`,
|
||||
иначе `ErrConflict` (как `Undo` для не-`done`). Из прочих состояний команда
|
||||
недоступна.
|
||||
2. **Снять цель** (наши библиотечные ссылки): как в `Undo` — `LatestBatchID`,
|
||||
`ListFileLinksByBatch`, отфильтровать `isLaidOut` (пропустить `superseded` —
|
||||
путь забрала другая загрузка, её хардлинк не трогаем), снять их **без** гарда
|
||||
последней копии, затем `DeleteFileLinksByBatch`. В `target_missing`/после
|
||||
ручного удаления ссылок список пуст — снятие идемпотентно (нечего снимать).
|
||||
3. **Снять источник** (раздачу): `qbt.Delete(hashes=все известные infohash задачи,
|
||||
deleteFiles=true)`. Идемпотентно: если раздачи нет (`orphaned`), qBittorrent
|
||||
просто не находит хеш — не ошибка. Ошибку сети/API от qBittorrent
|
||||
пробрасываем (не метим `deleted`, если источник реально не смогли снять —
|
||||
иначе соврём про освобождённое место); удаление ссылок при этом уже
|
||||
выполнено — повторный delete идемпотентен и дожмёт источник.
|
||||
4. `transition(ctx, d, StateDeleted, "user_delete", <причина>)` — терминально.
|
||||
Лог перехода несёт `code=user_delete`, отличая от reconcile-`deleted`.
|
||||
|
||||
Порядок «сначала цель, потом источник»: если оборвёмся между шагами (в т.ч. при
|
||||
ошибке `qbt.Delete`), останемся с живым источником и снятой целью. Записанное
|
||||
состояние ещё `done`, а реальность — «источник есть, цель снята», т.е. по матрице
|
||||
это `target_missing` (не `orphaned`!); ближайший тик сверки приведёт запись к
|
||||
`target_missing`. Кратковременное рассогласование `done`↔реальность до тика
|
||||
сверки ожидаемо и безопасно: повторный delete идемпотентно дожмёт, опираясь либо
|
||||
на оставшийся `done`, либо на приведённый сверкой `target_missing` (delete
|
||||
доступен из обоих). Это безопаснее обратного порядка — снести источник, оставив
|
||||
«последнюю копию» в библиотеке при неснятой цели.
|
||||
|
||||
### 2. Гард последней копии — выключаем осознанно
|
||||
|
||||
`Undo` отказывается снимать ссылку при `nlink<=1` (последняя копия). Delete —
|
||||
ровно обратное намерение: освободить место, сняв последнюю копию. Нужен путь
|
||||
снятия ссылок в `layout` **без** `ErrLastCopy`. Вариант: добавить в `Layouter`
|
||||
метод (напр. `Remove(ctx, links)`), делающий `unlink` целевых ссылок безусловно,
|
||||
но по-прежнему **только своих** ссылок под `paths.movies`/`series` (инвариант
|
||||
«трогаем лишь свои ссылки, не `paths.downloads`» сохраняется). Санитизация/
|
||||
проверка «строго под библиотекой» остаётся. Источник (файлы в `downloads/`) мы
|
||||
не трогаем сами — их сносит **qBittorrent** по нашему API-вызову `deleteFiles=true`.
|
||||
|
||||
### 3. qbt.Delete
|
||||
|
||||
`POST /api/v2/torrents/delete`, форма `hashes=<h1>|<h2>|…` (все известные хеши
|
||||
задачи, v1/v2 — qBittorrent матчит присутствующий), `deleteFiles=true`.
|
||||
Логирование — `ExtCall{Operation:"torrents/delete"}`, как у `Add`. Пустой/2xx
|
||||
ответ — успех; отсутствие хеша ошибкой не считается (идемпотентность).
|
||||
|
||||
### 4. Подтверждение (обязательно, во всех транспортах)
|
||||
|
||||
Терминальное необратимое действие с обходом инварианта — только по явному
|
||||
подтверждению (в проекте подтверждений опасных действий ещё нет, вводим впервые).
|
||||
|
||||
- **Веб-UI:** danger-секция в самом низу `download_main.html` (виз. отделена).
|
||||
Кнопка «Удалить» раскрывает confirm (htmx-своп фрагмента: «Точно удалить?
|
||||
Раздача и файлы будут снесены — [Да, удалить] [Отмена]»); фактическое удаление
|
||||
— `POST /ui/downloads/{id}/delete`. Деградация без htmx (web-ui-конвенция):
|
||||
секция — обычная форма с подтверждающей кнопкой на отдельном шаге/`<details>`,
|
||||
ошибка на htmx-пути = 200 + фрагмент.
|
||||
- **Telegram:** двухшаговый inline — `delete:<id>` показывает confirm-keyboard
|
||||
(`delete_confirm:<id>` / «Отмена»), само удаление — на подтверждающем callback.
|
||||
|
||||
Флаг `Deletable` (состояние ∈ {done, orphaned, target_missing}) считается там же,
|
||||
где `Undoable`/`Retriable` (`httpapi.go:648`, `download.go:108`).
|
||||
|
||||
## Что осознанно НЕ делаем
|
||||
|
||||
- **Мультивыбор «удалить выбранное»** — из scope вынесено (задача помечала как
|
||||
опциональное). Остаётся тонкой обёрткой над `Delete` на будущее, если понадобится.
|
||||
- **Новый статус** — не заводим, `deleted` переиспользуется.
|
||||
- **Миграция БД / новое поле инициатора** — не нужны, различаем через `error_code`.
|
||||
- **Дельта `review`** — исходная задача упоминала `review`, но delete не команда
|
||||
экрана ревью (недоступна из `review`); правим только `state-reconciliation`.
|
||||
@@ -0,0 +1,74 @@
|
||||
## Why
|
||||
|
||||
«Единое окно» реализовано наполовину: jellybit распознаёт **ручное** удаление и
|
||||
метит рассинхрон (`state-reconciliation` — `target_missing`/`orphaned`/`deleted`,
|
||||
безопасный `Undo` с гардом последней копии, preflight). Не хватает второй
|
||||
половины — **удалять загрузку из самого jellybit**, не идя руками в
|
||||
qBittorrent/Jellyfin.
|
||||
|
||||
Основной сценарий одного окна — «досмотрел → освободить место»: убрать и раздачу,
|
||||
и файлы в библиотеке разом. Из-за хардлинков иначе место не вернуть — файл в
|
||||
`downloads/` (qBittorrent) и наша ссылка в библиотеке указывают на **один инод**,
|
||||
диск освобождается только когда исчезает последняя ссылка. Значит удаление обязано
|
||||
снять **обе** стороны. Это осознанный выход за инвариант «источник неприкосновенен»,
|
||||
поэтому — с подтверждением и отдельным следом в логе/причине перехода.
|
||||
|
||||
`Undo` и `delete` — разные операции, и это надо зафиксировать:
|
||||
|
||||
- **Undo** — «перераспознать»: снимает только наши ссылки, раздачу в qBittorrent
|
||||
сохраняет, гард последней копии **включён**. Уже готов.
|
||||
- **delete** — «убрать окончательно, освободить место»: снимает наши ссылки И
|
||||
сносит раздачу+файлы из qBittorrent, гард **выключен**, состояние терминальное
|
||||
`deleted`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Ввести пользовательскую команду **«Удалить»** в `worker`, доступную из
|
||||
`done`, `orphaned`, `target_missing` (подчистить остатки за собой из любого
|
||||
состояния «уже разложено / рассинхрон»). Действие: снять наши библиотечные
|
||||
хардлинки последнего батча (переиспользуем механику `Undo`, `superseded`
|
||||
пропускаем) с **выключенным** гардом последней копии **+** удалить раздачу с
|
||||
файлами из qBittorrent. Идемпотентно к отсутствующей стороне (в `orphaned` нет
|
||||
источника, в `target_missing` нет цели).
|
||||
- Переиспользовать существующее терминальное состояние `deleted` — **без** нового
|
||||
статуса; рёбра `done/orphaned/target_missing → deleted` уже есть в
|
||||
`allowedTransitions`. Инициатора-пользователя отличать от сверки через
|
||||
`error_code = "user_delete"` (сверка кладёт `"reconcile"`), человекочитаемую
|
||||
причину — в `error_msg` и лог перехода.
|
||||
- Добавить в `qbt` метод удаления раздачи (`POST /api/v2/torrents/delete`,
|
||||
`deleteFiles=true`) и объявить его в интерфейсе `QBittorrent` воркера.
|
||||
- Добавить в `layout` путь снятия ссылок **без** гарда последней копии (delete
|
||||
сознательно снимает последнюю копию, в отличие от `Undo`).
|
||||
- Обязательное **подтверждение** во всех транспортах: веб-UI — danger-секция с
|
||||
кнопкой в самом низу страницы загрузки; Telegram — двухшаговый inline-confirm.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- нет новых capability -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `state-reconciliation`: добавляется пользовательская команда «Удалить»
|
||||
(снять источник и цель, гард последней копии выключен) с переходом
|
||||
`done`/`orphaned`/`target_missing` → терминальный `deleted`, отличимая от
|
||||
reconcile-`deleted` инициатором, с обязательным подтверждением.
|
||||
|
||||
## Impact
|
||||
|
||||
- Код воркера: `internal/worker/review.go` (новый метод `Delete`; путь снятия
|
||||
ссылок с выключенным гардом), `internal/worker/worker.go` (расширить интерфейс
|
||||
`QBittorrent` методом удаления; проброс в конструктор при необходимости).
|
||||
- Инфраструктура: `internal/qbt/qbt.go` (метод `Delete(hashes, deleteFiles)` →
|
||||
`/api/v2/torrents/delete`), `internal/layout/layout.go` (снятие ссылок без
|
||||
`ErrLastCopy`).
|
||||
- Транспорты: `internal/httpapi` (роут `POST /ui/downloads/{id}/delete` + confirm,
|
||||
флаг `Deletable` по состоянию; опц. JSON-роут), `web/templates/partials`
|
||||
(danger-секция внизу страницы загрузки), `internal/tgbot` (действие `delete` +
|
||||
двухшаговый confirm-keyboard).
|
||||
- Данные: **осознанное** снятие последней копии данных при удалении из
|
||||
`orphaned` — только по явному подтверждению; инвариант «источник
|
||||
неприкосновенен» обходится намеренно и логируется как пользовательское
|
||||
удаление. Схема БД не меняется (переиспользуем `error_code`/`error_msg`).
|
||||
- Тесты: `internal/worker/review_test.go` (delete из каждого из трёх состояний,
|
||||
идемпотентность к отсутствующей стороне, отказ из прочих состояний),
|
||||
`internal/qbt/qbt_test.go` (форма запроса delete), транспортные тесты confirm.
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Полное удаление загрузки пользователем
|
||||
|
||||
Система SHALL предоставлять пользователю команду **«Удалить»** (delete),
|
||||
доступную из состояний `done`, `orphaned` и `target_missing` во всех транспортах
|
||||
(веб-UI и Telegram, опц. REST). Команда SHALL снимать **обе** стороны загрузки —
|
||||
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
|
||||
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.
|
||||
|
||||
Снятие цели 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`.
|
||||
|
||||
В отличие от прочих команд, требующих источника, 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 идемпотентно дожимает удаление
|
||||
@@ -0,0 +1,81 @@
|
||||
## 1. qbt — удаление раздачи
|
||||
|
||||
- [x] 1.1 В `internal/qbt/qbt.go` добавить `Delete(ctx, hashes []string, deleteFiles bool) error`:
|
||||
`POST /api/v2/torrents/delete`, форма `hashes=<h1>|<h2>|…` + `deleteFiles`.
|
||||
Логирование `ExtCall{Operation:"torrents/delete"}` как у `Add`; 2xx/пустой
|
||||
ответ — успех, отсутствие хеша ошибкой не считать.
|
||||
- [x] 1.2 Тест `internal/qbt/qbt_test.go`: форма запроса delete (hashes через `|`,
|
||||
`deleteFiles=true`), обработка ответа.
|
||||
|
||||
## 2. layout — снятие ссылок без гарда последней копии
|
||||
|
||||
- [x] 2.1 Добавить в `Layouter` и реализацию `internal/layout/layout.go` путь
|
||||
снятия целевых ссылок **без** `ErrLastCopy` (напр. `Remove(ctx, links)`):
|
||||
безусловный `unlink` только своих ссылок строго под `paths.movies`/`series`;
|
||||
санитизация/проверка «под библиотекой» сохраняется, `paths.downloads` не
|
||||
трогаем.
|
||||
|
||||
## 3. worker — команда Delete
|
||||
|
||||
- [x] 3.1 Расширить интерфейс `QBittorrent` воркера методом `Delete(...)`
|
||||
(`internal/worker/worker.go`); проброс реализации `qbt.Client`.
|
||||
- [x] 3.2 В `internal/worker/review.go` добавить `Delete(ctx, id string) error`:
|
||||
под `w.mu`; `GetDownload`; предусловие `d.State ∈ {done, orphaned,
|
||||
target_missing}`, иначе `ErrConflict`. Порядок: (а) снять цель как в `Undo`
|
||||
(`LatestBatchID`/`ListFileLinksByBatch`/фильтр `isLaidOut`, `superseded`
|
||||
пропускаем) через новый `layouter.Remove`, затем `DeleteFileLinksByBatch`; (б)
|
||||
`qbt.Delete(d.Infohashes, deleteFiles=true)` (хеши уже загружены с задачей,
|
||||
отдельный store-геттер не нужен) — ошибку присутствующей раздачи пробросить
|
||||
(не метить `deleted`), отсутствие раздачи ошибкой не считать; (в)
|
||||
`transition(ctx, d, StateDeleted, "user_delete", <причина>)`. Delete
|
||||
source-preflight (`ensureSourceReady`/`reconcileToReality`) НЕ вызывает —
|
||||
толерантен к отсутствию источника.
|
||||
- [x] 3.3 Логировать удаление как осознанное пользовательское снятие источника
|
||||
(`slog`, без секретов): id, состояние-источник, снятые ссылки, `code=user_delete`.
|
||||
|
||||
## 4. Транспорт HTTP/веб + подтверждение
|
||||
|
||||
- [x] 4.1 Роут `POST /ui/downloads/{id}/delete` → хендлер (по образцу
|
||||
`handleUndo`), финализация через `surfaceAction`; трансляция `ErrConflict` →
|
||||
409/фрагмент как у прочих действий, а ошибка qBittorrent (не `ErrConflict`) —
|
||||
внятный отказ пользователю (не тихий успех), не 200-без-эффекта.
|
||||
- [x] 4.2 Флаг `Deletable` (состояние ∈ {done, orphaned, target_missing}) в
|
||||
расчётах кнопок (`httpapi.go:648`, `download.go:108`).
|
||||
- [x] 4.3 Danger-секция в самом низу `web/templates/partials/download_main.html`:
|
||||
кнопка «Удалить» → confirm (htmx-своп фрагмента «Точно удалить? …
|
||||
[Да, удалить] [Отмена]») → `POST …/delete`. Деградация без htmx
|
||||
(`<details>`/отдельный шаг), ошибка на htmx-пути = 200 + фрагмент.
|
||||
- [ ] 4.4 (Опц., НЕ в этом change) JSON-роут `POST /api/downloads/{id}/delete`.
|
||||
Осознанно отложено: REST в спеке помечен опциональным, а деструктивный endpoint
|
||||
без естественного шага подтверждения заводим только по реальной нужде.
|
||||
|
||||
## 5. Транспорт Telegram + подтверждение
|
||||
|
||||
- [x] 5.1 В `internal/tgbot` добавить действие `delete`: callback `delete:<id>`
|
||||
показывает confirm-keyboard (`delete_confirm:<id>` / «Отмена»); удаление — на
|
||||
подтверждающем callback (`Reviewer.Delete`). Кнопка «Удалить» — в карточке
|
||||
задачи для состояний done/orphaned/target_missing.
|
||||
- [x] 5.2 Ветка ошибок как у прочих действий (`ErrConflict` → понятный отказ;
|
||||
ошибка qBittorrent → отдельное сообщение об отказе, не тихий успех), после
|
||||
успеха — `answer` + `refreshCard`.
|
||||
|
||||
## 6. Тесты воркера
|
||||
|
||||
- [x] 6.1 Delete из `done`: сняты ссылки + вызван `qbt.Delete(deleteFiles=true)`
|
||||
+ переход `deleted`/`user_delete`.
|
||||
- [x] 6.2 Delete из `orphaned`: последняя копия снята (гард выключен), отсутствие
|
||||
раздачи не ошибка, переход `deleted`.
|
||||
- [x] 6.3 Delete из `target_missing`: снятие цели идемпотентно (нет живых ссылок),
|
||||
раздача снесена, переход `deleted`.
|
||||
- [x] 6.4 Delete из `review`/иного — отказ `ErrConflict`, состояние не меняется.
|
||||
- [x] 6.5 Ошибка `qbt.Delete` при присутствующей раздаче — задача остаётся, в
|
||||
`deleted` не уходит; повторный delete идемпотентен.
|
||||
|
||||
## 7. Ревью и сверка
|
||||
|
||||
- [x] 7.1 `task test` и `task lint` зелёные.
|
||||
- [x] 7.2 Ревью кода (второй чекпоинт) перед archive.
|
||||
- [x] 7.3 `openspec validate download-delete --strict` зелёный.
|
||||
- [x] 7.4 Синк нарратива `docs/specs/workflow.md`: явные пользовательские рёбра
|
||||
`done/orphaned/target_missing → deleted` (delete) и различие Undo vs delete;
|
||||
удалить `docs/backlog/udalenie-edinoe-okno.md` и строку из индекса беклога.
|
||||
Reference in New Issue
Block a user