diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index 189d6b2..885c78d 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -65,8 +65,10 @@ stateDiagram-v2 deleted --> [*] note right of cancelled - «Отклонить» доступно из любого - нетерминального состояния + В cancelled ведут: «Отклонить» + (из нетерминальных) и «Закрыть» + (стоп-кран — из любого состояния, + кроме deleted; только статус) end note ``` @@ -134,6 +136,15 @@ stateDiagram-v2 `error_code`: пользовательское удаление — `user_delete`, вывод сверкой — `reconcile`. Полные требования — `openspec/specs/state-reconciliation/`. +**Закрыть (dismiss).** Универсальный стоп-кран из любого состояния, кроме +`deleted`: переводит запись в терминальный `cancelled` (`error_code = +"user_dismiss"`), **только меняя статус** — ни файлы (библиотечные хардлинки +`done`/`orphaned` остаются на месте), ни раздачу в qBittorrent не трогает, в +отличие от «Удалить». Служит закрытием зависшей/спорной/лишней записи (напр. +дубля-близнеца в `target_missing`); из `cancelled` дальше доступна перепривязка. +Для нетерминальных ту же роль штатно играет «Отменить» — в UI стоп-кран +показывается там, где иного выхода нет (терминальные, кроме `deleted`). + Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный `deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/ `failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при diff --git a/internal/httpapi/action_swap_test.go b/internal/httpapi/action_swap_test.go index 6e23671..b192e56 100644 --- a/internal/httpapi/action_swap_test.go +++ b/internal/httpapi/action_swap_test.go @@ -26,6 +26,7 @@ type actionReviewer struct { stubReviewer undoErr error deleteErr error + dismissErr error relinkErr error rerecognizeErr error refineErr error @@ -34,6 +35,7 @@ type actionReviewer struct { func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr } func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr } +func (a actionReviewer) Dismiss(context.Context, string) error { return a.dismissErr } func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr } func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr } func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error { diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 6d74026..01f5c84 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -59,6 +59,11 @@ type downloadDetailView struct { Relinkable bool Retriable bool Deletable bool // полное удаление доступно (done/orphaned/target_missing) + // Dismissable — доступен стоп-кран «Закрыть» (перевод в cancelled без + // действий над файлами/раздачей). Показываем в danger-зоне для терминальных, + // кроме deleted (строго терминален) и cancelled (уже закрыта, no-op); у + // не-терминальных ту же роль играет обычная «Отменить» — не дублируем. + Dismissable bool } // detailTitle — заголовок страницы просмотра: имя раздачи (display_name) → @@ -119,6 +124,8 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet Retriable: d.State == store.StateFailed || d.State == store.StateStuck, Deletable: d.State == store.StateDone || d.State == store.StateOrphaned || d.State == store.StateTargetMissing, + Dismissable: d.State.IsTerminal() && + d.State != store.StateDeleted && d.State != store.StateCancelled, } // Дата добавления рядом с шапкой (source_added_at → фолбэк created_at, // как в порядке и карточках списка); неразбираемое время просто опускаем. diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 161e071..3804930 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -140,6 +140,7 @@ func NewRouter(d Deps) (http.Handler, error) { r.Post("/ui/downloads/{id}/undo", s.handleUndo) r.Post("/ui/downloads/{id}/relink", s.handleRelink) r.Post("/ui/downloads/{id}/delete", s.handleDelete) + r.Post("/ui/downloads/{id}/dismiss", s.handleDismiss) // REST API. r.Route("/api", func(r chi.Router) { @@ -432,6 +433,14 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, userErr(r, err, res.DownloadID)) return } + if res.Deduplicated { + // Приём привязался к существующей записи (активной или «спящей» desync — + // target_missing/orphaned): ведём пользователя на её страницу, а не на + // список. Так видно, что нового не завели, и доступны действия записи + // (привязать заново / danger-зона «Закрыть»). + http.Redirect(w, r, "/download/"+res.DownloadID, http.StatusSeeOther) + return + } http.Redirect(w, r, "/", http.StatusSeeOther) } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 31468f8..f5f735f 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -490,6 +490,7 @@ type fakeReviewer struct { deferred []string undone []string deleted []string + dismissed []string relinked []string rerecognized []string cleared []string @@ -532,6 +533,10 @@ func (f *fakeReviewer) Delete(_ context.Context, id string) error { f.deleted = append(f.deleted, id) return nil } +func (f *fakeReviewer) Dismiss(_ context.Context, id string) error { + f.dismissed = append(f.dismissed, id) + return nil +} func (f *fakeReviewer) Relink(_ context.Context, id string) error { f.relinked = append(f.relinked, id) return nil @@ -614,6 +619,28 @@ func noRedirectClient() *http.Client { }} } +// Веб-приём при дедупе (в т.ч. на «спящую» desync-запись) ведёт на страницу +// существующей записи, а не на список — пользователь видит, что нового не завели. +func TestUIAddDeduplicatedRedirectsToRecord(t *testing.T) { + ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}} + srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}}) + + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads", + strings.NewReader("source=magnet:?xt=urn:btih:abc")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := noRedirectClient().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/download/"+tid { + t.Errorf("Location = %q, want /download/%s", loc, tid) + } +} + func TestReviewRenders(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, @@ -765,6 +792,45 @@ func TestRefreshNameHTMXSwapsMain(t *testing.T) { } } +// Стоп-кран «Закрыть» рендерится в danger-зоне для терминального состояния +// (target_missing) и постит на /dismiss. +func TestDownloadPageShowsDismissButtonOnTerminal(t *testing.T) { + rd := seriesReviewData() + rd.Download.State = store.StateTargetMissing + rv := &fakeReviewer{data: rd} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + resp, err := http.Get(srv.URL + "/download/" + tid) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "/dismiss") || !strings.Contains(string(body), "Закрыть загрузку") { + t.Error("кнопка «Закрыть» не показана в danger-зоне для target_missing") + } +} + +// POST /ui/downloads/{id}/dismiss вызывает Reviewer.Dismiss. +func TestUIDismiss(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/dismiss", "application/x-www-form-urlencoded", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200 + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if len(rv.dismissed) != 1 || rv.dismissed[0] != tid { + t.Errorf("Dismiss вызван неверно: %v", rv.dismissed) + } +} + func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) { // Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном // done (есть план) она всё равно доступна. diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index 67a4240..0dd7fed 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -50,6 +50,7 @@ func (stubReviewer) IgnoreFile(context.Context, string, string) error func (stubReviewer) Defer(context.Context, string) error { return nil } func (stubReviewer) Undo(context.Context, string) error { return nil } func (stubReviewer) Delete(context.Context, string) error { return nil } +func (stubReviewer) Dismiss(context.Context, string) error { return nil } func (stubReviewer) Relink(context.Context, string) error { return nil } func (stubReviewer) Rerecognize(context.Context, string) error { return nil } func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil } diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index f542304..1b02890 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -22,6 +22,7 @@ type Reviewer interface { Defer(ctx context.Context, id string) error Undo(ctx context.Context, id string) error Delete(ctx context.Context, id string) error + Dismiss(ctx context.Context, id string) error Relink(ctx context.Context, id string) error Rerecognize(ctx context.Context, id string) error ChooseCandidate(ctx context.Context, id, candidateID string) error @@ -370,6 +371,17 @@ func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) { s.surfaceAction(w, r, id, s.deps.Reviewer.Delete(r.Context(), id)) } +// handleDismiss — универсальный стоп-кран: перевод задачи в cancelled только +// сменой статуса (файлы/раздачу не трогает), из danger-секции с подтверждением. +func (s *server) handleDismiss(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + redirectErr(w, r, "некорректный id") + return + } + s.surfaceAction(w, r, id, s.deps.Reviewer.Dismiss(r.Context(), id)) +} + // handleRelink повторно привязывает откатанную задачу: перезапускает // распознавание, задача пройдёт recognizing → review для подтверждения. func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 81cdd2c..1b74bd1 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -23,9 +23,11 @@ const capIngest = "ingest" // Store — нужная ingest часть хранилища. type Store interface { - // FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка — - // внутри CreateDownloadIfNoActive. - FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error) + // FindReingestBlockingByInfohash — быстрый читающий дедуп-чек: активная задача + // ЛИБО удерживающая источник desync-запись (target_missing/orphaned). Активный + // инвариант «≤1 активной» авторитетно держит CreateDownloadIfNoActive; desync — + // устойчивый пред-рид, коротко замыкающий приём на возврат существующей записи. + FindReingestBlockingByInfohash(ctx context.Context, hashes ...string) (*store.Download, error) // CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная // загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil — // дедуп на активную задачу (недостающие хеши вызова метод доносит сам). @@ -87,15 +89,18 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { log := s.log.With("capability", capIngest, "infohash", src.infohashes[0]) ctx = logctx.With(ctx, log) - // Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри - // CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника: - // гибридный несёт и v1, и v2. - if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil { + // Быстрый дедуп-чек по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2): + // активная задача ЛИБО удерживающая источник desync-запись + // (target_missing/orphaned) блокируют повторный приём. Для активной + // авторитетная (атомарная) проверка — внутри CreateDownloadIfNoActive ниже; + // desync-ветка сюда и завершается (в active-гард desync не заводим, чтобы не + // размыть инвариант «≤1 активной»). + if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil { // Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR. - log.Error("ingest failed", "stage", "lookup-active", "error", err) - return Result{}, fmt.Errorf("ingest: lookup active: %w", err) + log.Error("ingest failed", "stage", "lookup-blocking", "error", err) + return Result{}, fmt.Errorf("ingest: lookup blocking: %w", err) } else if existing != nil { - log.Info("download attached to active", "download_id", existing.ID, "state", existing.State) + log.Info("download attached", "download_id", existing.ID, "state", existing.State) return s.attached(ctx, src, existing), nil } @@ -204,11 +209,13 @@ func mergeContext(userText, synth string) string { return strings.Join(parts, "\n") } -// attached — итог дедупа на быстром чеке: присоединились к уже активной -// задаче и доносим ей недостающие хеши источника (гибридный magnet мог -// принести хеш, которого задача ещё не знает; guarded-путь через -// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт -// хеша с другой активной задачей логируется, приём не валится. +// attached — итог дедупа на быстром чеке: присоединились к блокирующей записи +// (активной ЛИБО удерживающей источник desync — target_missing/orphaned) и +// доносим ей недостающие хеши источника (гибридный magnet мог принести хеш, +// которого задача ещё не знает; guarded-путь через CreateDownloadIfNoActive сюда +// не доходит). Для desync-записи состояние не меняем (возвращаем «спящей» — relink +// или закрытие делает пользователь). Донос — best-effort: конфликт хеша с другой +// активной задачей логируется, приём не валится. func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result { if len(src.infohashes) > len(existing.Infohashes) { if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil { diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 607c146..590b292 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -26,7 +26,7 @@ type fakeStore struct { upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent } -func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) { +func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) { return f.active, nil } @@ -159,6 +159,31 @@ func TestIngestIdempotent(t *testing.T) { } } +// Повторный приём привязывается к удерживающей источник desync-записи +// (target_missing/orphaned) вместо создания близнеца: возвращается существующая +// «спящей» (её состояние не меняется, к qBittorrent не ходим), Deduplicated=true. +func TestIngestAttachesToDesyncRecord(t *testing.T) { + for _, s := range []store.State{store.StateTargetMissing, store.StateOrphaned} { + t.Run(string(s), func(t *testing.T) { + existing := &store.Download{ID: "01hzzzexisting000000000000", State: s} + fs := &fakeStore{active: existing} + res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet}) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if !res.Deduplicated || res.DownloadID != existing.ID { + t.Errorf("ожидалось присоединение к desync-записи: %+v", res) + } + if res.State != s { + t.Errorf("состояние существующей записи должно вернуться как есть (%s), got %s", s, res.State) + } + if len(fs.created) != 0 { + t.Error("не должно создаваться новой задачи (близнеца)") + } + }) + } +} + // Быстрый дедуп-путь доносит существующей задаче недостающие хеши // гибридного magnet (иначе последующий приём по второму хешу создал бы // вторую активную задачу). diff --git a/internal/store/download.go b/internal/store/download.go index 69da064..15b84f5 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -78,9 +78,14 @@ func (s State) IsTerminal() bool { // (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в // setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive». // Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется. -// - cancelled/deferred — легальная цель из КАЖДОГО не-терминального состояния -// (Cancel/Defer проверяют лишь IsTerminal); инвариант закреплён тестом, а не -// ручной аккуратностью. +// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer +// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной +// аккуратностью. +// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо +// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный +// и из терминальных (done/failed/reverted/target_missing/orphaned) — только +// смена статуса, файлы/раздачу не трогает (см. state-reconciliation «Ручное +// закрытие загрузки»). deleted строго терминален и цель cancelled не получает. // // Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе // setState отклонит переход (0 строк UPDATE → ошибка). @@ -91,14 +96,14 @@ var allowedTransitions = map[State][]State{ StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred}, StateReview: {StateLinking, StateRecognizing, StateCancelled, StateDeferred, StateOrphaned, StateDeleted}, StateLinking: {StateDone, StateReview, StateFailed, StateCancelled, StateDeferred}, - StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted}, + StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted, StateCancelled}, StateDeferred: {StateLinking, StateRecognizing, StateCancelled, StateOrphaned, StateDeleted}, StateStuck: {StateDownloading, StateCompleted, StateCancelled, StateDeferred}, - StateFailed: {StateDownloading, StateCompleted}, - StateReverted: {StateRecognizing, StateOrphaned, StateDeleted}, + StateFailed: {StateDownloading, StateCompleted, StateCancelled}, + StateReverted: {StateRecognizing, StateOrphaned, StateDeleted, StateCancelled}, StateCancelled: {StateRecognizing, StateOrphaned, StateDeleted}, - StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted}, - StateOrphaned: {StateDone, StateTargetMissing, StateDeleted}, + StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted, StateCancelled}, + StateOrphaned: {StateDone, StateTargetMissing, StateDeleted, StateCancelled}, StateDeleted: nil, // окончательно терминально: сверка его не переоценивает } @@ -616,6 +621,68 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Do return d, nil } +// reingestHoldingStates — desync-состояния, которые удерживают источник ради +// незакрытого намерения и потому БЛОКИРУЮТ повторный приём наравне с активными: +// target_missing (источник жив, ждёт relink) и orphaned (источник пропал, запись +// держит претензию на последнюю копию). Прочие терминальные (done/cancelled/ +// failed/reverted/deleted) повторный приём НЕ блокируют — это осознанная свежая +// попытка. «Блокирующие» = активные (не-терминальные) ∪ reingestHoldingStates. +var reingestHoldingStates = []State{StateTargetMissing, StateOrphaned} + +// FindReingestBlockingByInfohash возвращает задачу, блокирующую повторный приём +// любого из hashes: активную (строго не-терминальную) ЛИБО удерживающую источник +// desync-запись (target_missing/orphaned), приоритет — активной. Либо (nil, nil). +// Читающая основа расширенного дедупа приёма (пред-рид ДО создания); инвариант +// «≤1 активной на infohash» держит отдельный active-гард CreateDownloadIfNoActive, +// в который desync-состояния НЕ заводятся. +func (s *Store) FindReingestBlockingByInfohash(ctx context.Context, hashes ...string) (*Download, error) { + norm := normalizeHashes(hashes) + // Активная имеет приоритет: если по хешу есть и активная, и desync-запись + // (инвариант это допускает), присоединяемся к активной. + d, err := findActiveByInfohash(ctx, s.DB, norm, "") + if err != nil { + return nil, err + } + if d == nil { + d, err = findByInfohashInStates(ctx, s.DB, norm, reingestHoldingStates) + if err != nil { + return nil, err + } + } + if d != nil { + if err := attachInfohashesOne(ctx, s.DB, d); err != nil { + return nil, err + } + } + return d, nil +} + +// findByInfohashInStates — выборка «задача по любому из хешей в одном из states» +// (позитивный фильтр `state IN (...)`, в отличие от findActiveByInfohash с +// `NOT IN terminalStates`). hashes уже нормализованы; хеши найденной загрузки НЕ +// подгружаются. Пустые hashes/states → (nil, nil). +func findByInfohashInStates(ctx context.Context, q sqlx.QueryerContext, hashes []string, states []State) (*Download, error) { + if len(hashes) == 0 || len(states) == 0 { + return nil, nil + } + var args []any + hashPh := placeholders(&args, hashes) + statePh := placeholders(&args, states) + query := `SELECT download.* FROM download +JOIN download_infohash dh ON dh.download_id = download.id +WHERE dh.infohash IN (` + hashPh + `) AND download.state IN (` + statePh + `) +ORDER BY download.id DESC LIMIT 1` + var d Download + err := sqlx.GetContext(ctx, q, &d, query, args...) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find by infohash in states: %w", err) + } + return &d, nil +} + // findActiveByInfohash — общая выборка «активная задача по любому из хешей» // (для guarded-методов — внутри их транзакции). hashes уже нормализованы; // excludeID исключает саму проверяемую задачу (она может быть активной, diff --git a/internal/store/download_test.go b/internal/store/download_test.go index 7a74346..ed05d16 100644 --- a/internal/store/download_test.go +++ b/internal/store/download_test.go @@ -260,6 +260,71 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) { } } +// Дедуп повторного приёма блокируют не только активные, но и удерживающие +// источник desync-записи (target_missing/orphaned): по ним приём привязывается к +// существующей, а не плодит близнеца. Прочие терминальные (done/cancelled/failed/ +// reverted/deleted) повторный приём НЕ блокируют — осознанная свежая попытка. +func TestFindReingestBlockingByInfohash(t *testing.T) { + ctx := context.Background() + + blocking := []State{ + StateCatched, StateDownloading, StateReview, // активные (примеры) + StateTargetMissing, StateOrphaned, // desync, удерживающие источник + } + for _, s := range blocking { + t.Run("blocking/"+string(s), func(t *testing.T) { + st := newTestStore(t) + ih := hashN(1) + id := mustCreate(t, st, ih) + forceState(t, st, id, s) + d, err := st.FindReingestBlockingByInfohash(ctx, ih) + if err != nil { + t.Fatal(err) + } + if d == nil || d.ID != id { + t.Fatalf("%s должна блокировать приём, получили %v", s, d) + } + if len(d.Infohashes) != 1 { + t.Fatalf("хеши не подгружены: %v", d.Infohashes) + } + }) + } + + nonBlocking := []State{ + StateDone, StateCancelled, StateFailed, StateReverted, StateDeleted, + } + for _, s := range nonBlocking { + t.Run("non-blocking/"+string(s), func(t *testing.T) { + st := newTestStore(t) + ih := hashN(2) + id := mustCreate(t, st, ih) + forceState(t, st, id, s) + d, err := st.FindReingestBlockingByInfohash(ctx, ih) + if err != nil || d != nil { + t.Fatalf("%s блокировать приём не должна, получили (%v,%v)", s, d, err) + } + }) + } + + t.Run("priority-active-over-desync", func(t *testing.T) { + st := newTestStore(t) + ih := hashN(3) + // Старая запись ушла в target_missing (terminal освобождает хеш), затем по + // тому же хешу завелась новая активная — инвариант «≤1 активной» это + // допускает. Дедуп обязан присоединиться к активной, а не к desync. + oldID := mustCreate(t, st, ih) + forceState(t, st, oldID, StateTargetMissing) + newID := mustCreate(t, st, ih) + d, err := st.FindReingestBlockingByInfohash(ctx, ih) + if err != nil { + t.Fatal(err) + } + if d == nil || d.ID != newID { + t.Fatalf("ожидалась активная %s (приоритет над desync %s), получили %v", newID, oldID, d) + } + }) +} + // Терминальное состояние освобождает infohash: тот же хеш заводится заново // новой задачей (повторная закачка спустя время) — активность выводится // только из state. diff --git a/internal/store/transition_test.go b/internal/store/transition_test.go index 5f12338..ead7539 100644 --- a/internal/store/transition_test.go +++ b/internal/store/transition_test.go @@ -74,19 +74,12 @@ func TestTransitionGraphWellFormed(t *testing.T) { } } -// Инвариант generic-команд Cancel/Defer: cancelled и deferred — легальная цель -// из КАЖДОГО не-терминального состояния (кроме самого deferred для deferred — -// это самопереход). Ловит класс дыры «забыли состояние» (напр. linking после -// краха процесса). -func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) { +// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального +// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли +// состояние» (напр. linking после краха процесса). +func TestDeferReachableFromEveryNonTerminal(t *testing.T) { for _, s := range allStates { - if s.IsTerminal() { - continue - } - if !slices.Contains(transitionSources[StateCancelled], s) { - t.Errorf("%s → cancelled не легально (Cancel допускает любое не-терминальное)", s) - } - if s == StateDeferred { + if s.IsTerminal() || s == StateDeferred { continue // deferred → deferred покрыт самопереходом } if !slices.Contains(transitionSources[StateDeferred], s) { @@ -95,6 +88,22 @@ func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) { } } +// Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из +// ЛЮБОГО состояния, кроме deleted (строго терминален) и самого cancelled +// (самопереход). Шире инварианта Defer: покрывает и терминальные +// done/failed/reverted/target_missing/orphaned. НЕ объединять с проверкой +// deferred — у них разные множества источников. +func TestCancelledReachableFromEveryStateButDeleted(t *testing.T) { + for _, s := range allStates { + if s == StateDeleted || s == StateCancelled { + continue // deleted строго терминален; cancelled → cancelled — самопереход + } + if !slices.Contains(transitionSources[StateCancelled], s) { + t.Errorf("%s → cancelled не легально (Dismiss/Cancel допускают любое состояние, кроме deleted)", s) + } + } +} + // Объявленные не-revive рёбра проходят через SetDownloadState. func TestSetStateAllowsDeclaredEdges(t *testing.T) { edges := []struct{ from, to State }{ @@ -110,6 +119,14 @@ func TestSetStateAllowsDeclaredEdges(t *testing.T) { {StateDone, StateReverted}, {StateStuck, StateCancelled}, {StateReview, StateDeferred}, + // Стоп-кран Dismiss: терминал → cancelled идёт обычным SetDownloadState + // (цель cancelled терминальна → гард терминальности не мешает, revive не + // нужен). + {StateDone, StateCancelled}, + {StateFailed, StateCancelled}, + {StateReverted, StateCancelled}, + {StateTargetMissing, StateCancelled}, + {StateOrphaned, StateCancelled}, } for i, e := range edges { st := newTestStore(t) diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index a5d9369..0577b25 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -17,6 +17,7 @@ import ( "git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/logging" + "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) @@ -47,6 +48,7 @@ type Reviewer interface { Cancel(ctx context.Context, id string) error Retry(ctx context.Context, id string) error Delete(ctx context.Context, id string) error + Dismiss(ctx context.Context, id string) error } // Config — параметры бота. @@ -267,8 +269,20 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque return } if res.Deduplicated { - // Дубль на уже активную задачу: новую загрузку не заводим, лишь сообщаем. - b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil) + // Дубль: новую загрузку не заводим. Различаем активную задачу и «спящую» + // desync-запись (target_missing/orphaned) — у последней действие вперёд + // не «ждите», а «привяжите заново или закройте». + switch res.State { + case store.StateTargetMissing: + // Источник жив, цель удалена — из target_missing доступна перепривязка. + b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как запись #%s без цели — привяжите заново или закройте её.", res.DownloadID), nil) + case store.StateOrphaned: + // Источник пропал: relink из orphaned нет, рабочий путь — закрыть и + // добавить заново (тогда приём заведёт свежую загрузку). + b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как осиротевшая запись #%s — закройте её, затем добавьте заново.", res.DownloadID), nil) + default: + b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil) + } return } b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", res.DownloadID), nil) @@ -327,6 +341,19 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) { case "delete_confirm": err = b.reviewer.Delete(ctx, id) note = "Удаляю…" + case "dismiss": + // Первый шаг: подтверждение. Стоп-кран лишь меняет статус (файлы/раздачу + // не трогает), но убирает запись из активной — подтверждаем сознательно. + b.answer(cq.ID, "") + b.editMarkup(chatID, msgID, b.dismissConfirmKeyboard(id)) + return + case "dismiss_cancel": + b.answer(cq.ID, "Отменено") + b.refreshCard(ctx, chatID, msgID, id) + return + case "dismiss_confirm": + err = b.reviewer.Dismiss(ctx, id) + note = "Закрываю…" case "type": err = b.reviewer.SetType(ctx, id, val) note = "Меняю тип…" diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index b7d7b92..7b24603 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -62,14 +62,15 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res } type fakeReviewer struct { - data *worker.ReviewData - applied []string - refined map[string]string - typed map[string]string - deferred []string - canceled []string - retried []string - deleted []string + data *worker.ReviewData + applied []string + refined map[string]string + typed map[string]string + deferred []string + canceled []string + retried []string + deleted []string + dismissed []string } func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) { @@ -109,6 +110,10 @@ func (f *fakeReviewer) Delete(_ context.Context, id string) error { f.deleted = append(f.deleted, id) return nil } +func (f *fakeReviewer) Dismiss(_ context.Context, id string) error { + f.dismissed = append(f.dismissed, id) + return nil +} // tid — валидный lowercase-ULID (callback-data валидируется как ULID). const tid = "01arz3ndektsv4rrffq69g5fav" @@ -179,6 +184,23 @@ func TestBot_IngestDeduplicated(t *testing.T) { } } +// Дедуп на «спящую» desync-запись (target_missing) → сообщение зовёт привязать +// заново/закрыть, а не «дубль активной». +func TestBot_IngestDeduplicatedDesync(t *testing.T) { + b, api, ing, _ := newTestBot(t, []int64{7}) + ing.res = ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true} + + b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC")) + + if len(api.sent) != 1 { + t.Fatalf("sent = %+v", api.sent) + } + txt := api.sent[0].text + if !strings.Contains(txt, "без цели") || !strings.Contains(txt, tid) { + t.Errorf("ожидалось сообщение о записи без цели с #%s, got %q", tid, txt) + } +} + func TestBot_DeniesUnknownUser(t *testing.T) { b, api, ing, _ := newTestBot(t, []int64{7}) b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC")) diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 11ab37f..b210a65 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -160,6 +160,8 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup { row := []tgbotapi.InlineKeyboardButton{ tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id), + // Стоп-кран: закрыть зависшую задачу, не трогая файлы/раздачу. + tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id), } if url := b.reviewURL(id); url != "" { row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url)) @@ -169,14 +171,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup { } // deletableKeyboard — клавиатура состояний, откуда доступно полное удаление -// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление -// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard). +// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь +// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка +// ведёт на подтверждение. func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup { var row []tgbotapi.InlineKeyboardButton if url := b.reviewURL(id); url != "" { row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url)) } - row = append(row, tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id)) + row = append(row, + tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id), + tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id), + ) kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...)) return &kb } @@ -191,6 +197,16 @@ func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup { return &kb } +// dismissConfirmKeyboard — шаг подтверждения закрытия (стоп-кран): перевод в +// «отменено» без действий над файлами/раздачей. Явное «Да» отделено от отмены. +func (b *Bot) dismissConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup { + kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("✖️ Да, закрыть", "dismiss_confirm:"+id), + tgbotapi.NewInlineKeyboardButtonData("Отмена", "dismiss_cancel:"+id), + )) + return &kb +} + func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup { url := b.reviewURL(id) if url == "" { diff --git a/internal/worker/worker.go b/internal/worker/worker.go index f75f7af..4f3d689 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -897,6 +897,37 @@ func (w *Worker) Cancel(ctx context.Context, id string) (err error) { return nil } +// Dismiss — универсальный стоп-кран: переводит задачу в терминальный cancelled из +// ЛЮБОГО состояния, кроме deleted, ТОЛЬКО меняя статус. В отличие от Delete не +// трогает ни файлы (библиотечные хардлинки done/orphaned остаются на месте), ни +// раздачу в qBittorrent, ни цель; source-preflight не делает. Служит закрытием +// зависшей/спорной/лишней записи (в т.ч. дубля-близнеца в target_missing). Из +// cancelled — идемпотентный no-op БЕЗ setState: иначе перезаписал бы error_code, +// подменив причину прежнего Cancel/Dismiss. Помечает переход user_dismiss +// (отличает от reconcile и от штатного Cancel с пустым кодом). +func (w *Worker) Dismiss(ctx context.Context, id string) (err error) { + defer func() { w.logCmd(ctx, "dismiss", id, err) }() + w.mu.Lock() + defer w.mu.Unlock() + + d, err := w.store.GetDownload(ctx, id) + if err != nil { + return fmt.Errorf("dismiss: %w", err) + } + if d.State == store.StateDeleted { + return fmt.Errorf("dismiss: download %s is deleted (strictly terminal): %w", id, ErrConflict) + } + if d.State == store.StateCancelled { + return nil // уже закрыта — no-op, error_code прежней отмены не трогаем + } + if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "user_dismiss", "закрыто пользователем"); err != nil { + return fmt.Errorf("dismiss: %w", err) + } + logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition", + "from", d.State, "to", store.StateCancelled, "code", "user_dismiss") + return nil +} + // Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в // qBittorrent и возвращает в downloading. func (w *Worker) Retry(ctx context.Context, id string) (err error) { diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 5e65a96..a75d066 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -446,6 +446,58 @@ func TestCancel(t *testing.T) { } } +// Dismiss — стоп-кран: переводит в cancelled только сменой статуса, не трогая +// раздачу и файлы, из любого состояния, кроме deleted; из cancelled — no-op без +// перезаписи error_code. +func TestDismiss(t *testing.T) { + t.Run("from-done-keeps-source-and-files", func(t *testing.T) { + st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) + st.downloads["1"].State = store.StateDone + qb := &fakeQbt{} + w := newTestWorker(st, qb) + if err := w.Dismiss(context.Background(), "1"); err != nil { + t.Fatalf("Dismiss: %v", err) + } + if st.downloads["1"].State != store.StateCancelled { + t.Errorf("state = %q, want cancelled", st.downloads["1"].State) + } + if got := st.downloads["1"].ErrorCode.String; got != "user_dismiss" { + t.Errorf("error_code = %q, want user_dismiss", got) + } + if len(qb.deleted) != 0 { + t.Errorf("раздачу трогать не должны, Delete вызван %d раз", len(qb.deleted)) + } + }) + + t.Run("from-deleted-rejected", func(t *testing.T) { + st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) + st.downloads["1"].State = store.StateDeleted + w := newTestWorker(st, &fakeQbt{}) + if err := w.Dismiss(context.Background(), "1"); err == nil { + t.Error("ожидалась ошибка Dismiss из deleted") + } + if st.downloads["1"].State != store.StateDeleted { + t.Errorf("state = %q, want deleted (не изменилось)", st.downloads["1"].State) + } + }) + + t.Run("from-cancelled-noop-keeps-code", func(t *testing.T) { + st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) + st.downloads["1"].State = store.StateCancelled + st.downloads["1"].ErrorCode = store.NullString("prior_reason") + w := newTestWorker(st, &fakeQbt{}) + if err := w.Dismiss(context.Background(), "1"); err != nil { + t.Fatalf("Dismiss no-op: %v", err) + } + if got := st.downloads["1"].ErrorCode.String; got != "prior_reason" { + t.Errorf("error_code = %q, no-op не должен его переписывать", got) + } + if len(st.transitions) != 0 { + t.Errorf("no-op не должен писать переход, got %d", len(st.transitions)) + } + }) +} + func TestRetry(t *testing.T) { st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) st.downloads["1"].State = store.StateStuck diff --git a/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/.openspec.yaml b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/.openspec.yaml new file mode 100644 index 0000000..eb5fa80 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/design.md b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/design.md new file mode 100644 index 0000000..41f6153 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/design.md @@ -0,0 +1,204 @@ +## Context + +Дедуп приёма (`ingest`) держит инвариант «не более одной активной загрузки на +infohash», где «активная» = `state NOT IN terminalStates`. Терминальный набор +(`done, cancelled, failed, reverted, target_missing, orphaned, deleted`) смешивает +две разные ситуации: + +- **«Отработали, забыли»** — `done`/`cancelled`/`failed`/`reverted`/`deleted`. + Повторный приём такого инфохэша — осознанное «хочу заново», новая загрузка + легитимна (`ingest/spec.md`, сценарий «Повторный приём после завершения»). +- **«Держим источник ради незакрытого намерения»** — `target_missing` (источник + жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись держит + претензию на последнюю копию данных). + +Дедуп трактует обе группы одинаково: терминально → не активна → плоди новую. Во +второй группе это рождает близнеца (реальный прод-случай: `target_missing` + +`done` на один торрент). Причём усыновление раздачи воркером +(`download-tracking`, change `catched-promote-without-readd`) делает близнеца +«боевым» — он раскладывается и занимает целевой путь, из-за чего у осиротевшей +записи `Привязать заново` упирается в коллизию владения путём. + +Отдельно: закрыть лишнюю `target_missing`-запись сейчас нечем. Единственная +команда, убирающая её из активного внимания, — «Удалить», но она **сносит +раздачу** в qBittorrent (`deleteFiles=true`), а этого как раз не нужно: раздача +общая, её ведёт `done`-близнец. + +## Goals / Non-Goals + +**Goals:** + +- Повторный приём инфохэша, удерживаемого записью в `target_missing`/`orphaned`, + не создаёт новую загрузку, а возвращает существующую (attach), сохраняя приём + быстрым и без обращения к qBittorrent. +- Пользователь может вручную закрыть **любую** зависшую/спорную загрузку + (стоп-кран), ничего не делая с файлами и раздачей. +- Инвариант «≤1 активной на infohash» и его атомарные гарды не ослабляются. + +**Non-Goals:** + +- Авто-схлопывание/слияние уже существующих дублей фоновой сверкой — сознательно + ручной путь. +- Изменение логики усыновления раздачи в воркере и общей матрицы «источник × + цель» сверки. +- Авто-relink при повторном приёме (это обращение к qBittorrent — противоречит + «быстрому приёму без сети»); relink остаётся отдельным явным действием. +- Разбор коллизии владения целевым путём при relink `target_missing`, чьи файлы + уже разложены другой записью, — эту ситуацию закрывает команда «Закрыть», а не + relink. +- Любое удаление/создание файлов или снятие раздачи командой «Закрыть» — для + этого есть «Удалить». «Закрыть» — чисто смена статуса. + +## Decisions + +### Р1. Дедуп: reingest-blocking states = active ∪ {target_missing, orphaned} + +Вводим понятие «состояний, блокирующих повторный приём»: активные состояния +**плюс** `target_missing` и `orphaned`. Поиск дедупа при приёме +(`FindActiveByInfohash` → по сути `FindReingestBlockingByInfohash`) ищет запись в +любом из этих состояний по любому из хешей и, найдя, возвращает её вместо +создания новой. Приоритет — активная (если вдруг есть и активная, и desync-запись +на один хеш, что допускает текущий инвариант), иначе desync-запись. + +- Почему не «сделать `target_missing`/`orphaned` активными»: сломает семантику + «активность выводится только из state» и потянет за собой сверку, healing, + выборки активных. Дедуп — единственное место, которому нужна расширенная + оптика; локализуем изменение там. +- Атомарность: гард создания (`CreateDownloadIfNoActive`) остаётся про активные — + он бэкстоп инварианта «≤1 активной». Расширенная проверка — это read-ветка + дедупа ДО создания; она короткозамыкает на attach. Гонка «два приёма + одновременно на свежий target_missing» в худшем случае даёт одну лишнюю + попытку create, которую по-прежнему отсекает активный гард; близнец на + `target_missing` при этом не создаётся, т.к. обе ветки видят одну и ту же + desync-запись (она уже в БД, коммитнута ранее). + +### Р2. Attach для desync-записи не воскрешает и не доносит хеши сам по себе + +Ветка attach для `target_missing`/`orphaned` возвращает запись как «спящую, +требует relink» (флаг в результате приёма), НЕ переводя её в активное состояние и +НЕ вызывая qBittorrent. Донесение недостающих хешей (гибридный торрент) для +desync-записи допустимо и безопасно (терминальная запись не «активна», гонки за +хеш нет), но подчиняется тому же правилу «не красть хеш у другой активной» +(`ingest/spec.md`, «Атомарность возврата…»). Бот/веб сообщают: запись существует, +приложите relink или закройте. + +- Почему не авто-relink: relink делает синхронный source-preflight (обращение к + qBittorrent) — это нарушает инвариант «синхронный приём не ходит в + qBittorrent». Явный relink пользователем сохраняет разделение шагов. + +Почему `failed`/`reverted` НЕ блокирующие (в отличие от `target_missing`/ +`orphaned`): у них нет удерживаемого источника ради незакрытого намерения — +повторный приём осознанно трактуется как **свежая попытка**. Новая активная +загрузка забирает хеш, старая терминальная им не владеет; её фоновое +самовосстановление (revive `failed`) корректно отклонится активным гардом «infohash +занят». Дубля-призрака (как с `target_missing`) при этом не возникает: старая +запись остаётся терминальной и не раскладывается повторно. Реализация не +переиспользует общий active-хелпер для desync-проверки — расширенная оптика нужна +только дедуп-пред-риду (см. Р1, Б-развязка с `ActivateIfNoOtherActive`/ +`AddInfohashes`). + +### Р3. «Закрыть» = переход в cancelled, без нового статуса (принято) + +Команда «Закрыть» (dismiss) переводит запись в **существующее терминальное +`cancelled`** с `error_code`-дискриминатором (`user_dismiss`), человекочитаемой +причиной в `error_msg` и логом перехода. + +Почему `cancelled`, а не новый `dismissed`: + +- Прецедент в коде: Delete переиспользует `deleted` + `error_code="user_delete"` + и явно постулирует «новый статус вводить SHALL NOT» — терминальный набор + завязан на семантику активности, любой новый статус её разъедает и тянет + правки во все выборки/сверку. +- `cancelled` уже значит «пользователь отказался от этой записи, источник не + трогаем», из него доступен relink — естественный safety valve, если передумал. +- `cancelled` не входит в reingest-blocking (Р1) → после «Закрыть» повторный + приём заведёт свежую загрузку. Это осознанно: запись закрыта, дубля-призрака + больше нет. + +Различение причины отмены (закрытие стоп-краном vs. отклонение на ревью) несёт +`error_code`, а не отдельный статус. + +### Р4. «Закрыть» — универсальный стоп-кран из любого состояния, кроме deleted (принято) + +Команда доступна из **любого** состояния, кроме `deleted` (строго терминально, +сверка его не переоценивает — не воскрешаем граф). На `cancelled` — идемпотентный +no-op (самопереход). Инвариант команды: **только меняет статус**, файлы под +`paths.*` и раздачу в qBittorrent НЕ трогает. + +- Из `target_missing` (исходный прод-случай) — закрытие инертно (целевых ссылок + нет, источник жив и остаётся раздаваться). +- Из `done`/`orphaned` — библиотечные хардлинки **сознательно остаются** на месте + (не удаляем: «Закрыть» ≠ «Удалить»). Запись перестаёт отслеживаться. +- Из активных/`stuck`/`failed`/`deferred` — источник в qBittorrent остаётся как + есть (докачивается/раздаётся); мы лишь снимаем запись из внимания. + +Отличие от существующего Cancel/«Отклонить» (review-флоу, только из +нетерминальных): «Закрыть» — универсальный стоп-кран, доступный и из терминальных +`done`/`failed`/`reverted`/`target_missing`/`orphaned`, и живёт в отдельной danger +zone внизу страницы. Оба ведут в `cancelled`; различаются гардом источника и +`error_code`. + +Рёбра `allowedTransitions`, которые нужно добавить (у нетерминальных `cancelled` +как цель уже есть): `done → cancelled`, `failed → cancelled`, +`reverted → cancelled`, `target_missing → cancelled`, `orphaned → cancelled`. +После этого `cancelled` — легальная цель из любого состояния, кроме `deleted`. + +## Risks / Trade-offs + +- **Relink `target_missing` при существующем `done`-близнеце всё ещё упрётся в + коллизию пути.** → Ожидаемо и допустимо: правильное действие для лишней + записи — «Закрыть», а не relink; коллизия владения путём (`state-reconciliation`, + «Занятый путь даёт коллизию») отрабатывает штатно и не портит данные. +- **Reingest-blocking расширен → пользователь, реально желающий переснять + `target_missing`-торрент заново, получит attach, а не новую загрузку.** → + Приемлемо: у него есть relink (вперёд) и «Закрыть» (закрыть и, при желании, + переслать снова — новая загрузка заведётся из `cancelled`). +- **Гонка двух одновременных приёмов на свежую desync-запись.** → Оба видят уже + коммитнутую desync-запись → attach; активный гард отсекает случайный create. + Близнец не рождается. +- **`error_code=user_dismiss` в `cancelled` смешивает две причины отмены.** → + Дискриминатор в `error_code` + `error_msg`/лог различают их; телеметрия по + причине доступна без нового статуса. +- **«Закрыть» из `done`/`orphaned` оставляет неотслеживаемые хардлинки** под + `paths.movies`/`series`, чей `file_link` продолжает «владеть» путём (`cancelled` + сверкой не переоценивается). → Осознанный компромисс стоп-крана «только + статус»: файлы оставляем как есть, реальную зачистку делает «Удалить». Повторная + закачка того же пути упрётся в штатную коллизию владения путём + (`state-reconciliation`, «Занятый путь даёт коллизию»), а не в порчу данных. +- **«Закрыть» из активных состояний рвёт запись из-под воркера** (напр. в + `linking`/`downloading`). → Команды сериализуются воркером под единой + блокировкой (как прочие команды ревью) — «Закрыть» применяется как последняя + валидная команда, а не посреди операции; частично созданные ссылки остаются, что + соответствует контракту «только статус». +- **Восстановление `orphaned` через приём — двухшаговое.** `orphaned` (источник + пропал) блокирует повторный приём (attach), но relink из `orphaned` не + реализован, а приём не добавляет источник в qBittorrent. → Рабочий путь возврата + источника: «Закрыть» (→ `cancelled`) → повторный приём (уже не блокируется) → + свежая активная загрузка, которую воркер добавит и разложит. Прямой приём без + attach создал бы близнеца с коллизией целевого пути (файл `orphaned` ещё на + месте), поэтому attach выбран сознательно; транспорты в `orphaned` формулируют + действие как «закройте, затем добавьте заново» (не «привяжите заново»). Прямой + relink-из-`orphaned` — возможное будущее улучшение вне scope этого change. +- **danger zone скрывает завершённую (`done`) запись одним действием.** → + Разместить «Закрыть» в отдельной danger zone внизу страницы; для необратимо + выглядящих случаев (`done` и прочие терминальные) UI SHOULD запрашивать + подтверждение (относительно дёшево — из `cancelled` доступен relink). + +## Migration Plan + +- Схема БД не меняется (нет таблиц/столбцов/статусов). Миграции не требуются. +- Изменения — код + дельта-спеки; деплой обычным бинарём. Откат — откат бинаря; + данные не мигрированы, несовместимости нет. +- Обновить граф переходов в тесте (`cancelled` как цель из + `done`/`failed`/`reverted`/`target_missing`/`orphaned`) и описание + статусов/переходов в `docs/specs/database.md`. + +## Open Questions + +- Р3 (`cancelled` + `error_code`) и Р4 (универсальный стоп-кран из любого + состояния, кроме `deleted`) — **приняты**. +- Требует ли «Закрыть» из терминальных/`done` подтверждения в UI (см. риск) — + решить на реализации веб-UI. +- Тексты для транспортов: формулировка ответа приёма при attach на desync-запись + («существует как #id без цели — привяжите заново или закройте») и подпись кнопки + «Закрыть» в веб/Telegram. diff --git a/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/proposal.md b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/proposal.md new file mode 100644 index 0000000..cee9b9d --- /dev/null +++ b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/proposal.md @@ -0,0 +1,73 @@ +## Why + +При повторном приёме торрента, у которого уже есть запись в `target_missing` +(«разложено, но файлов в библиотеке нет»), рождается загрузка-близнец: дедуп +приёма ищет только **активную** задачу по инфохэшу, а `target_missing` +терминально — активной нет, заводится новая загрузка, воркер усыновляет +присутствующую в qBittorrent раздачу и раскладывает её. В итоге на один торрент +две записи (`done` + `target_missing`), причём у осиротевшей единственное +действие «Привязать заново» упрётся в уже занятый целевой путь. Пользователю +нечем аккуратно закрыть лишнюю запись, не снося при этом раздачу. + +## What Changes + +- **Предотвращение дубля на приёме.** Повторный приём инфохэша, которым владеет + запись в `target_missing` или `orphaned`, SHALL привязываться к этой записи + (возврат существующей, `Deduplicated`), а не заводить новую загрузку. Критерий + дедупа расширяется с «активной» до «активной **или** удерживающей источник + ради незакрытого намерения» (`target_missing`/`orphaned`). `done` из дедупа + сознательно остаётся размножаемым (повторный приём завершённого = осознанное + «хочу заново»). Приём остаётся быстрым: qBittorrent не трогаем, авто-relink не + запускаем — пользователю сообщается, что запись существует и её нужно привязать + заново. +- **Команда «Закрыть» — универсальный стоп-кран.** Добавляется ручная команда, + доступная из **любого** состояния (кроме `deleted`) во всех транспортах, + переводящая запись в терминальное `cancelled` (с `error_code`-дискриминатором) + и убирающая её из активного списка. Команда **только меняет статус**: файлы под + `paths.*` не трогает (в т.ч. из `done`/`orphaned` библиотечные хардлинки + сознательно остаются на месте) и раздачу в qBittorrent не снимает (в отличие от + «Удалить»). Размещается в отдельной danger zone внизу страницы. Так + пользователь закрывает лишнего близнеца, а заодно получает страховку для любой + зависшей/спорной загрузки. + +Явно вне scope: авто-схлопывание дублей в фоновой сверке (выбран ручной путь); +изменение логики усыновления в воркере; введение нового статуса (переиспользуем +`cancelled`, как Delete переиспользует `deleted`); удаление/создание каких-либо +файлов или раздач командой «Закрыть». + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `ingest`: критерий дедупликации приёма расширяется — блокирующими повторный + приём становятся не только активные, но и `target_missing`/`orphaned` записи + (attach вместо создания новой); повторный приём завершённой (`done`) остаётся + созданием новой. Модифицируются требования «Дедупликация приёма по любому из + хешей» и «Приём источника и заведение загрузки» (терминология «блокирующей» + задачи). Требование «Приём из .torrent-файла» текст НЕ правит: оно уже явно + делегирует критерий модифицированному требованию через inline-ссылку — оба + дедуп-упоминания там наследуют расширенный критерий без риска дрейфа. +- `state-reconciliation`: добавляется пользовательская команда «Закрыть» + (dismiss) из любого состояния (кроме `deleted`) в терминальное `cancelled`, + ничего не делающая с файлами и раздачей; фиксируется её отличие от «Удалить» и + новые рёбра перехода `<любое> → cancelled` (в т.ч. из терминальных + `done`/`failed`/`reverted`/`target_missing`/`orphaned`). + +## Impact + +- Код: `internal/ingest/ingest.go` (`Ingest`/`attached` — ветка attach для + desync-записей, флаг «нужен relink»), `internal/store/download.go` + (критерий поиска дедупа: reingest-blocking states = active ∪ + `{target_missing, orphaned}`; новое ребро `allowedTransitions` + `target_missing → cancelled`; `error_code`-дискриминатор dismiss), + `internal/worker/review.go` (обработчик команды «Закрыть» рядом с + Delete/Undo/Relink — только setState, без файлов и qBittorrent), веб-UI + (danger zone внизу страницы с кнопкой «Закрыть»), `internal/tgbot`. +- Данные: новых таблиц/столбцов нет; терминальный набор не меняется (dismiss → + существующее `cancelled`). Обновляется граф переходов и, при необходимости, + описание статусов в `docs/specs/database.md`. +- Инварианты безопасности данных: «Закрыть» источник неприкосновенен — + qBittorrent не вызывается, файлы под `paths.downloads`/`movies`/`series` не + трогаются. diff --git a/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/specs/ingest/spec.md b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/specs/ingest/spec.md new file mode 100644 index 0000000..acf81df --- /dev/null +++ b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/specs/ingest/spec.md @@ -0,0 +1,113 @@ +## MODIFIED Requirements + +### Requirement: Приём источника и заведение загрузки + +Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP, +Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL +синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети), +дедуплицировать по **блокирующей повторный приём** задаче (активной либо +удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`; +см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести +загрузку (`download` в состоянии **`catched`** + записи `download_infohash`), +после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её +хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное +состояние»). + +Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить +отображаемое имя (потенциально медленный LLM): и добавление источника в +qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины +состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в +qBittorrent». + +`catched` — нетерминальное активное состояние: оно участвует в инварианте «не +более одной активной загрузки на infohash» наравне с прочими активными. + +#### Scenario: Быстрый приём magnet + +- **GIVEN** валидная magnet-ссылка и контекст +- **WHEN** вызывается приём +- **THEN** создаётся `download` в состоянии `catched` с записями + `download_infohash` +- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени + +#### Scenario: Дубль по активной задаче на быстром пути + +- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash +- **WHEN** вызывается приём +- **THEN** новая загрузка не создаётся, возвращается существующая + +### Requirement: Дедупликация приёма по любому из хешей + +При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по +любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой. +Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии +**либо** удерживающие источник ради незакрытого намерения — `target_missing` +(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись +держит претензию на последнюю копию). Прочие терминальные состояния (`done`, +`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT: +повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить +новую загрузку. + +Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`), +приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система +SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent +(перепривязка — отдельное явное действие пользователя, а не побочный эффект +приёма); ответ транспорту SHALL сообщать, что запись существует и требует +перепривязки либо закрытия. + +Атомарный инвариант касается **активной** составляющей: проверка отсутствия +другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами +SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной +загрузки на infohash» (тот же общий active-гард, что у прочих путей активации). +Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть +устойчивым пред-ридом до создания, коротко замыкающим приём на возврат +существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT +(их терминальность оставляет `state`-инвариант «активности» нетронутым). +Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL +NOT — активность выводится только из `state`. + +#### Scenario: Повторный приём при активной загрузке + +- **GIVEN** активная загрузка с infohash `h` +- **WHEN** принимается magnet с тем же `h` +- **THEN** новая загрузка не создаётся, возвращается существующая + +#### Scenario: Повторный приём при записи без цели + +- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель + удалена) +- **WHEN** принимается magnet с тем же `h` +- **THEN** новая загрузка не создаётся, возвращается существующая запись как + `Deduplicated` +- **AND** её состояние остаётся `target_missing` (в активное не переводится, к + qBittorrent обращения нет) +- **AND** ответ транспорту указывает, что запись существует и её нужно привязать + заново или закрыть + +#### Scenario: Повторный приём при осиротевшей записи + +- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал) +- **WHEN** принимается magnet/torrent с тем же `h` +- **THEN** новая загрузка не создаётся, возвращается существующая запись как + `Deduplicated` + +#### Scenario: Повторный приём после завершения + +- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done` +- **WHEN** принимается magnet с тем же `h` +- **THEN** создаётся новая загрузка со своим ULID и записью `h` + +#### Scenario: Повторный приём после закрытия записи + +- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из + `target_missing`) +- **WHEN** принимается magnet с тем же `h` +- **THEN** создаётся новая загрузка со своим ULID и записью `h` + +#### Scenario: Повторный приём при прочих терминальных состояниях + +- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает + источник ради незакрытого намерения) +- **WHEN** принимается magnet/torrent с тем же `h` +- **THEN** создаётся новая загрузка со своим ULID и записью `h` (повторный приём — + свежая попытка; старая терминальная запись хешем не владеет) diff --git a/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..d2088b6 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/specs/state-reconciliation/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: Ручное закрытие загрузки (стоп-кран) + +Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss), +доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и +Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное +`cancelled`, убирая её из активного списка/внимания, и SHALL служить +универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего +дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для +загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в +`cancelled` команда SHALL быть идемпотентным no-op. + +Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с +файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не +снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки +под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие +библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight +«Закрыть» выполнять SHALL NOT (источник в действии не участвует). + +Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина — +в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от +удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется +существующее терминальное `cancelled` (сверка его не переоценивает). Из +`cancelled` пользователю остаётся доступной перепривязка (relink), если он +передумает. + +В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу +страницы загрузки), обособленно от штатных действий. + +#### Scenario: Закрытие записи без цели не трогает раздачу + +- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent, + целевых хардлинков нет (напр. её файлы разложены другой загрузкой) +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** раздача с файлами в qBittorrent не удаляется +- **AND** запись пропадает из активного списка + +#### Scenario: Закрытие done оставляет библиотечные файлы на месте + +- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** библиотечные хардлинки не удаляются +- **AND** раздача в qBittorrent не снимается + +#### Scenario: Закрытие зависшей загрузки + +- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`) +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` +- **AND** источник в qBittorrent не трогается + +#### Scenario: «Закрыть» недоступна для deleted + +- **GIVEN** загрузка в `deleted` +- **WHEN** пользователь пытается вызвать «Закрыть» +- **THEN** команда недоступна, состояние остаётся `deleted` + +#### Scenario: Закрытую запись можно привязать заново + +- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled` +- **WHEN** пользователь даёт команду «Привязать заново» +- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink + из `cancelled`) diff --git a/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/tasks.md b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/tasks.md new file mode 100644 index 0000000..ddffd04 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/tasks.md @@ -0,0 +1,58 @@ +## 1. Дедуп: reingest-blocking states (предотвращение дубля) + +- [x] 1.1 В `internal/store/download.go` ввести множество reingest-blocking + состояний (active ∪ `{target_missing, orphaned}`) и **отдельный** метод + поиска по любому из хешей (напр. `FindReingestBlockingByInfohash`) с + приоритетом активной записи над desync-записью. НЕ расширять общий + `findActiveByInfohash` — на нём стоят `ActivateIfNoOtherActive`/ + `AddInfohashes`, где «активная» ОБЯЗАНА значить строго не-терминальную + (иначе ломается инвариант «≤1 активной»). Desync-проверка — устойчивый + пред-рид ДО create, активный гард (`CreateDownloadIfNoActive`) остаётся про + строго активные. +- [x] 1.2 В `internal/ingest/ingest.go` (`Ingest`/`attached`) при найденной + desync-записи (`target_missing`/`orphaned`) вернуть её как `Deduplicated` + «спящей»: без перевода в активное, без обращения к qBittorrent; добавить + в результат приёма признак «требует relink/закрытия». +- [x] 1.3 Убедиться, что донесение недостающих хешей и атомарный гард создания + (`CreateDownloadIfNoActive`) не крадут хеш у другой активной загрузки и + что гонка двух приёмов на свежую desync-запись не рождает близнеца. +- [x] 1.4 Обновить ответы транспортов при attach на desync-запись: бот + (`internal/tgbot`) и HTTP/веб — текст «существует как #id без цели — + привяжите заново или закройте». + +## 2. Команда «Закрыть» (dismiss) — универсальный стоп-кран + +- [x] 2.1 В `internal/store/download.go` добавить рёбра `allowedTransitions` в + `cancelled` из терминальных `done`/`failed`/`reverted`/`target_missing`/ + `orphaned` (у нетерминальных цель уже есть; `deleted` исключён). Обновить + граф-тесты (`internal/store/transition_test.go`): (а) РАСЩЕПИТЬ + `TestCancelDeferReachableFromEveryNonTerminal` — `cancelled` теперь цель из + любого состояния кроме `deleted` (в т.ч. терминальных), `deferred` — + по-прежнему только из не-терминальных (не расширять общий цикл наивно, иначе + ложно потребует `deferred` из терминалов или оставит дырявый гард); + (б) в `TestSetStateAllowsDeclaredEdges` добавить новые терминал→`cancelled` + рёбра (переход обычным `SetDownloadState`, без `ActivateIfNoOtherActive`). +- [x] 2.2 В `internal/worker/review.go` реализовать **отдельный** обработчик + «Закрыть» (не ветка `Cancel`, который отклоняет терминальные): перевод + `<любое, кроме deleted> → cancelled` с `error_code = "user_dismiss"` и + причиной в `error_msg`/логе. Только setState: qBittorrent не вызывать, + хардлинки не трогать (в т.ч. из `done`/`orphaned`), source-preflight не + выполнять; из `deleted` — отказ; из `cancelled` — короткозамкнуть без + `setState` (иначе идемпотентный no-op перезапишет `error_code`, подменив + причину прежнего `Отклонить`). +- [x] 2.3 Веб-UI: отдельная danger zone внизу страницы загрузки с кнопкой + «Закрыть» (доступна из любого состояния, кроме `deleted`; htmx, деградация + без JS, ошибка на htmx-пути = 200 + фрагмент). Рассмотреть подтверждение + для `done`/терминальных. +- [x] 2.4 Telegram (`internal/tgbot`): добавить действие «Закрыть» для загрузок. + +## 3. Спеки, документация, проверка + +- [x] 3.1 Обновить описание статусов/переходов в `docs/specs/database.md` + (рёбра `<терминальные> → cancelled`, `error_code = "user_dismiss"`). +- [x] 3.2 `task test` и `task lint` зелёные; добавить тесты: дедуп-attach на + `target_missing`/`orphaned`, повторный приём после `cancelled`/`done` + заводит новую, команда «Закрыть» переводит в `cancelled` из разных + состояний и НЕ трогает qBittorrent/хардлинки (в т.ч. из `done`), отказ из + `deleted`. +- [x] 3.3 `openspec validate dedup-target-missing-and-dismiss --strict`. diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index 1a9795b..c1d8fc5 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -179,10 +179,12 @@ SHALL NOT проваливать обновление — `download.display_name Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP, Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети), -дедуплицировать по активной задаче и при отсутствии дубля завести загрузку -(`download` в состоянии **`catched`** + записи `download_infohash`), после чего -**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL -выполняться атомарно (см. «Атомарность возврата загрузки в активное +дедуплицировать по **блокирующей повторный приём** задаче (активной либо +удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`; +см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести +загрузку (`download` в состоянии **`catched`** + записи `download_infohash`), +после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её +хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное состояние»). Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить @@ -238,13 +240,33 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40 ### Requirement: Дедупликация приёма по любому из хешей -При приёме система SHALL искать **активную** (нетерминальную) загрузку по -любому из известных хешей и, найдя, SHALL возвращать её вместо создания -новой. Проверка активности и вставка новой загрузки с её хешами SHALL -выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не -более одной активной загрузки на infohash». Отдельного снимаемого/ -восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность -выводится только из `state`. +При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по +любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой. +Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии +**либо** удерживающие источник ради незакрытого намерения — `target_missing` +(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись +держит претензию на последнюю копию). Прочие терминальные состояния (`done`, +`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT: +повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить +новую загрузку. + +Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`), +приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система +SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent +(перепривязка — отдельное явное действие пользователя, а не побочный эффект +приёма); ответ транспорту SHALL сообщать, что запись существует и требует +перепривязки либо закрытия. + +Атомарный инвариант касается **активной** составляющей: проверка отсутствия +другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами +SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной +загрузки на infohash» (тот же общий active-гард, что у прочих путей активации). +Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть +устойчивым пред-ридом до создания, коротко замыкающим приём на возврат +существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT +(их терминальность оставляет `state`-инвариант «активности» нетронутым). +Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL +NOT — активность выводится только из `state`. #### Scenario: Повторный приём при активной загрузке @@ -252,12 +274,46 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40 - **WHEN** принимается magnet с тем же `h` - **THEN** новая загрузка не создаётся, возвращается существующая +#### Scenario: Повторный приём при записи без цели + +- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель + удалена) +- **WHEN** принимается magnet с тем же `h` +- **THEN** новая загрузка не создаётся, возвращается существующая запись как + `Deduplicated` +- **AND** её состояние остаётся `target_missing` (в активное не переводится, к + qBittorrent обращения нет) +- **AND** ответ транспорту указывает, что запись существует и её нужно привязать + заново или закрыть + +#### Scenario: Повторный приём при осиротевшей записи + +- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал) +- **WHEN** принимается magnet/torrent с тем же `h` +- **THEN** новая загрузка не создаётся, возвращается существующая запись как + `Deduplicated` + #### Scenario: Повторный приём после завершения -- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`) +- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done` - **WHEN** принимается magnet с тем же `h` - **THEN** создаётся новая загрузка со своим ULID и записью `h` +#### Scenario: Повторный приём после закрытия записи + +- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из + `target_missing`) +- **WHEN** принимается magnet с тем же `h` +- **THEN** создаётся новая загрузка со своим ULID и записью `h` + +#### Scenario: Повторный приём при прочих терминальных состояниях + +- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает + источник ради незакрытого намерения) +- **WHEN** принимается magnet/torrent с тем же `h` +- **THEN** создаётся новая загрузка со своим ULID и записью `h` (повторный приём — + свежая попытка; старая терминальная запись хешем не владеет) + ### Requirement: Атомарность возврата загрузки в активное состояние Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути, diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md index 049690d..ce8df2e 100644 --- a/openspec/specs/state-reconciliation/spec.md +++ b/openspec/specs/state-reconciliation/spec.md @@ -534,3 +534,68 @@ SHALL задевать раскладку в полёте. - **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех) - **AND** повторный delete идемпотентно дожимает удаление +### Requirement: Ручное закрытие загрузки (стоп-кран) + +Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss), +доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и +Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное +`cancelled`, убирая её из активного списка/внимания, и SHALL служить +универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего +дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для +загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в +`cancelled` команда SHALL быть идемпотентным no-op. + +Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с +файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не +снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки +под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие +библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight +«Закрыть» выполнять SHALL NOT (источник в действии не участвует). + +Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина — +в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от +удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется +существующее терминальное `cancelled` (сверка его не переоценивает). Из +`cancelled` пользователю остаётся доступной перепривязка (relink), если он +передумает. + +В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу +страницы загрузки), обособленно от штатных действий. + +#### Scenario: Закрытие записи без цели не трогает раздачу + +- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent, + целевых хардлинков нет (напр. её файлы разложены другой загрузкой) +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** раздача с файлами в qBittorrent не удаляется +- **AND** запись пропадает из активного списка + +#### Scenario: Закрытие done оставляет библиотечные файлы на месте + +- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** библиотечные хардлинки не удаляются +- **AND** раздача в qBittorrent не снимается + +#### Scenario: Закрытие зависшей загрузки + +- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`) +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` +- **AND** источник в qBittorrent не трогается + +#### Scenario: «Закрыть» недоступна для deleted + +- **GIVEN** загрузка в `deleted` +- **WHEN** пользователь пытается вызвать «Закрыть» +- **THEN** команда недоступна, состояние остаётся `deleted` + +#### Scenario: Закрытую запись можно привязать заново + +- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled` +- **WHEN** пользователь даёт команду «Привязать заново» +- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink + из `cancelled`) + diff --git a/web/templates/partials/download_main.html b/web/templates/partials/download_main.html index f0a8726..ad9af1d 100644 --- a/web/templates/partials/download_main.html +++ b/web/templates/partials/download_main.html @@ -92,13 +92,27 @@ {{end}} - - {{if .Deletable}} + + {{if or .Dismissable .Deletable}}
Опасная зона + {{if .Dismissable}} +

+ Закрытие снимет запись из активного списка (перевод в «отменено») — + файлы и раздачу не трогает. Стоп-кран для зависшей, спорной + или лишней загрузки; при необходимости позже можно привязать заново. +

+
+ + +
+ {{end}} + {{if .Deletable}}

Полное удаление снимет библиотечные хардлинки и снесёт раздачу с файлами из qBittorrent — освободит место. Действие необратимо. @@ -109,6 +123,7 @@ + {{end}}

{{end}}