web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо фазы catched; один поллер на поверхность, интервалы 5 с и 15 с - отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели вместо 404/500, который htmx не свопит - заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел «Живой поллинг» в конвенции веб-UI
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-10
|
||||
@@ -0,0 +1,228 @@
|
||||
## Context
|
||||
|
||||
Живое обновление веб-UI собрано из трёх независимых поллеров, каждый привязан к
|
||||
своей фазе или региону:
|
||||
|
||||
- карточка списка опрашивает себя, пока `SelfPoll` — а это `d.State ==
|
||||
StateCatched` (`internal/httpapi/httpapi.go:664`, `card.html:2`);
|
||||
- вложенный блок прогресса опрашивает себя, пока `Active` — а это `d.State ==
|
||||
StateDownloading` (`internal/httpapi/live.go:88`, `progress.html:1`);
|
||||
- страница `/download/{id}` повторяет первое правило
|
||||
(`internal/httpapi/download.go:116`, `download_main.html:2`), а внутри неё
|
||||
секция «Раздача» опрашивает себя сама (`seeding.html:1`).
|
||||
|
||||
Ни одно из правил не покрывает выход из `downloading`, поэтому дальше задача
|
||||
живёт на экране в прошлом. Домен предикат уже даёт: `State.IsTerminal()`
|
||||
(`internal/store/download.go:63`) со своим единым перечнем терминальных
|
||||
состояний — заводить второй перечень в транспорте нельзя.
|
||||
|
||||
Цена тика у двух поверхностей разная, и это главное ограничение дизайна:
|
||||
|
||||
- фрагмент карточки — `GetDownload` плюс чтение in-memory снимка воркера;
|
||||
- страница загрузки — `Reviewer.ReviewData`, а он на каждом вызове строит
|
||||
предпросмотр раскладки через `layout.BuildLinks`
|
||||
(`internal/worker/review.go:1096`), то есть ходит в файловую систему.
|
||||
|
||||
Ещё одно свойство, из которого растут решения Р2 и Р5: `#seeding-{id}` лежит
|
||||
**внутри** `#download-main` (`download_main.html:61`, корень закрыт на строке
|
||||
129), а страница свопает этот корень целиком.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- смена состояния становится видимой без перезагрузки страницы, кто бы её ни
|
||||
сделал — воркер, веб-UI, Telegram или фоновая сверка;
|
||||
- обновление само прекращается, когда состояние менять больше некому;
|
||||
- число фоновых запросов на открытую страницу известно и обосновано.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- переход на SSE — отдельная задача `sse-live-updates`, и этот change её не
|
||||
приближает и не отменяет;
|
||||
- изменение состава живой телеметрии и `/api/**`;
|
||||
- обновление списка **целиком** (появление новых задач, изменение порядка и
|
||||
групп) — сегодня его нет, и эта задача его не заводит;
|
||||
- экран `/review/{id}`: он сохраняет фазовое самообновление, заказанное спекой
|
||||
`review` («пока загрузка в `recognizing`»), и приводится к общему правилу
|
||||
отдельной задачей.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Р1. Условие обновления — наблюдаемость, предикат общий с доменом
|
||||
|
||||
`SelfPoll` в обоих представлениях перестаёт зависеть от фазы. Наблюдаема
|
||||
задача, состояние которой ещё может измениться без участия этого браузера.
|
||||
|
||||
Одного `!IsTerminal()` для этого мало, и это выяснилось на ревью дизайна.
|
||||
Терминальность в проекте значит «не активна», а не «навсегда»: фоновая сверка
|
||||
двигает часть терминальных сама — `ListRecoverable`
|
||||
(`internal/store/download.go:606`) возвращает в поток `failed`/`stuck` с кодами
|
||||
`magnet_timeout` и `stalled`, а `desyncStates` (`internal/worker/reconcile.go:22`)
|
||||
переоценивает `done`, `target_missing` и `orphaned`. Карточка, застывшая по
|
||||
`IsTerminal`, показывала бы «Ошибка» у задачи, которая уже качается, — ровно тот
|
||||
дефект, ради которого затеян change.
|
||||
|
||||
**Решено на чекпоинте:** наблюдаемы все нетерминальные плюс `failed`,
|
||||
`target_missing`, `orphaned` — те, кого сверка возвращает в поток сама.
|
||||
Замолкают `done`, `cancelled`, `reverted`, `deleted`.
|
||||
|
||||
`done` в этот перечень не входит, хотя формально его тоже переоценивает сверка:
|
||||
переход `done → target_missing`/`orphaned` означает, что файлы удалили руками
|
||||
мимо сервиса, — событие редкое, а карточек `done` в списке больше всех. Платить
|
||||
за редкий случай постоянным фоновым запросом на каждую разложенную задачу
|
||||
дороже, чем показать её новое состояние при следующем заходе. Это принятое
|
||||
ограничение, а не упущение.
|
||||
|
||||
Предикат живёт **в домене**, рядом с `IsTerminal`, а не в `httpapi`: второй
|
||||
перечень состояний в транспорте разъедется на первом же новом состоянии.
|
||||
|
||||
Побочно это чинит и то, чего задача не заказывала: `stuck` и `deferred`
|
||||
нетерминальны, и их карточки тоже перестают застревать.
|
||||
|
||||
**Рассмотрено и отвергнуто:** «поллить, пока задача в активной группе списка» —
|
||||
группа считается из того же `IsTerminal`, то есть это то же условие, названное
|
||||
через представление, а не через домен.
|
||||
|
||||
### Р2. Один источник обновления на поверхность
|
||||
|
||||
Правило общее для карточки и для страницы: вложенные живые регионы своего опроса
|
||||
не ведут.
|
||||
|
||||
- в карточке блок прогресса (`progress.html`) остаётся вложенной разметкой без
|
||||
своего `hx-get`;
|
||||
- на странице секция «Раздача» (`seeding.html`) — тоже.
|
||||
|
||||
Основание фактическое, а не эстетическое. Своп корня меняет `outerHTML` целиком
|
||||
и уносит вложенный узел вместе с его таймером: два опроса на одну поверхность
|
||||
опрашивают одно и то же дважды, подменяют разметку друг друга, а тик корня,
|
||||
попавший в незавершённый запрос региона, роняет его ответ в никуда. До этого
|
||||
change конфликта не было только потому, что поверхности поллились в фазах, где
|
||||
вложенных регионов не существует (`catched` — ни прогресса, ни раздачи).
|
||||
|
||||
Цена названа прямо: цифры сидирования обновлялись раз в 3 с, станут обновляться с
|
||||
тиком страницы. Рейтинг и число пиров — не те величины, которым нужна
|
||||
трёхсекундная свежесть; прогресс качания, которому она нужна, едет с быстрым
|
||||
интервалом карточки.
|
||||
|
||||
**Рассмотрено и отвергнуто:** оставить регионам их опрос, а корень свопать
|
||||
частями (`hx-select` по кускам) — это заводит вторую механику свопа ради
|
||||
сохранения того, что и так не нужно с трёхсекундной частотой.
|
||||
|
||||
### Р3. Способ доставки — самополлинг фрагмента, а не сигнал из прогресса
|
||||
|
||||
Вариант «фрагмент прогресса, заметив уход из `downloading`, просит браузер
|
||||
обновить карточку» (`HX-Trigger` или `hx-swap-oob`) дешевле по запросам, но
|
||||
покрывает ровно один переход — тот, у которого был поллер. Переходы
|
||||
`review → linking → done`, сделанные из Telegram, остались бы невидимыми, а
|
||||
именно они дают самое долгое расхождение: задача стоит в ревью часами.
|
||||
|
||||
Вариант «поллить список одним запросом целиком» дал бы заодно появление новых
|
||||
задач, но перерисовывал бы всю страницу, ломая фильтр, поиск и прокрутку, —
|
||||
спека `live-status` это прямо запрещает. Это направление принадлежит SSE-задаче.
|
||||
|
||||
Следствие принятого варианта, названное сценарием спеки: карточка, дошедшая до
|
||||
конца на глазах у смотрящего, остаётся на своём месте в прежней группе списка —
|
||||
группы и фильтр считаются на рендере страницы и пересчитываются навигацией.
|
||||
|
||||
### Р4. Две частоты, потому что цена тика разная
|
||||
|
||||
Интервал зависит от того, несёт ли **поверхность** блок живых цифр качания:
|
||||
|
||||
- **быстрый** — карточка задачи в `downloading`: цифры меняются непрерывно, и
|
||||
это единственное место, где реже значит хуже;
|
||||
- **медленный** — все прочие наблюдаемые поверхности, включая **страницу
|
||||
`/download/{id}` в любом состоянии**: блока прогресса на ней нет вовсе
|
||||
(`grep progress web/templates/partials/download_main.html` пуст), а цифры
|
||||
раздачи трёхсекундной свежести не требуют.
|
||||
|
||||
Критерий именно «есть блок живых цифр», а не «состояние `downloading`»: на
|
||||
странице эти два признака расходятся, и по второму она перерисовывалась бы 20
|
||||
раз в минуту, не показывая ни одной изменившейся величины.
|
||||
|
||||
Арифметика, ради которой это и сделано. Открытая страница `/download/{id}` в
|
||||
`review` при быстром интервале звала бы `BuildLinks` 20 раз в минуту всё время,
|
||||
что вкладка открыта; при медленном — 4 раза. Для списка из N наблюдаемых
|
||||
карточек быстрый интервал везде дал бы `20 × N` запросов в минуту, разный —
|
||||
`20` за качающиеся и `4 × N` за остальные.
|
||||
|
||||
Уточнение после ревью кода: один тик карточки — это **два** обращения к
|
||||
хранилищу (`GetDownload` и `LayoutSizeByDownload`, оба по первичному ключу и
|
||||
индексу `idx_file_link_download`), а не одно. То есть открытый список даёт
|
||||
`8 × N` чтений в минуту вместо `4 × N`. Полный рендер страницы берёт размеры
|
||||
одним батчем, самообновление — по карточке: это цена того, что список не
|
||||
пересобирается целиком (см. Non-Goals). Замера под нагрузкой нет; порог, при
|
||||
котором это перестанет быть бесплатным, ищет задача `scale-100-downloads`.
|
||||
|
||||
**Решено на чекпоинте:** быстрый интервал — 5 с, вровень с
|
||||
`[worker].poll_interval` (`docs/database.md:201`), медленный — 15 с. Сегодняшние
|
||||
3 с обгоняют источник: воркер снимает телеметрию раз в 5 с, поэтому примерно два
|
||||
тика из пяти возвращают тот же кадр. Выравнивание убирает холостые запросы, а
|
||||
свежесть цифр не портит — она и так ограничена тиком воркера, что спека
|
||||
`live-status` прямо и требует («Свежесть не выше тика поллинга»).
|
||||
|
||||
Обе константы живут в одном месте кода рядом с представлениями и записываются в
|
||||
`docs/database.md` в таблицу настроек с числовым значением, там же — связь
|
||||
быстрого интервала с частотой опроса qBittorrent.
|
||||
|
||||
**Рассмотрено и отвергнуто:** единая частота — проще на один параметр, но делает
|
||||
открытую вкладку с ревью источником постоянных обращений к файловой системе;
|
||||
частота из конфига — настройка, которую никто не будет крутить, а
|
||||
`config.example.toml` и документацию она утяжелит.
|
||||
|
||||
### Р5. Фрагменты прогресса и раздачи остаются — и гасят разметку прошлой версии
|
||||
|
||||
Оба маршрута (`/fragments/downloads/{id}/progress`, `.../seeding`) после Р2
|
||||
остаются без потребителя в новой разметке. Удалить их сразу нельзя: htmx **не
|
||||
свопит** ответы 4xx/5xx и не снимает с узла `hx-trigger`, поэтому вкладка,
|
||||
открытая до деплоя, слала бы запросы на удалённый маршрут каждые 3 секунды до
|
||||
самого закрытия — молча для смотрящего и десятками тысяч строк в журнале
|
||||
доступа.
|
||||
|
||||
Оставленные маршруты отдают те же партиалы, которые после правки поллинга не
|
||||
несут, — то есть первый же тик старой вкладки гасит её собственный опрос.
|
||||
Уборка этих двух обработчиков — отдельная мелкая задача после деплоя; она
|
||||
уезжает в урожай ревью, а не остаётся обещанием в комментарии.
|
||||
|
||||
`buildProgress` и `buildSeeding` остаются в любом случае: ими собираются виды,
|
||||
вложенные в карточку и страницу.
|
||||
|
||||
### Р6. Самообновление не теряет полей полного рендера
|
||||
|
||||
`handleFragCard` сегодня зовёт `buildCardView` с `layoutSize = 0` — упрощение
|
||||
времени, когда фрагмент обслуживал только `catched`, где раскладки не бывает.
|
||||
После расширения тот же обработчик обслуживает `linking`, `deferred` и прочие
|
||||
состояния с уже разложенными файлами, и при отсутствии раздачи в снимке
|
||||
самообновление подменило бы показанный размер прочерком.
|
||||
|
||||
Фрагмент читает размер раскладки так же, как это делает своповый путь действия
|
||||
(`renderCardFragment` → `LayoutSizeByDownload`).
|
||||
|
||||
### Р7. Отказ тика самозавершается
|
||||
|
||||
Тик, не сумевший прочитать задачу (записи нет — например, её убрала уборка; или
|
||||
отказало хранилище), отвечает `200` и фрагментом без `hx-*`. Иначе htmx не
|
||||
свопит ответ, поверхность остаётся прежней навсегда, а опрос продолжается: при
|
||||
затяжном отказе хранилища одна открытая вкладка даёт `4 × N` записей в журнале в
|
||||
минуту. Форма ответа согласована с уже записанной конвенцией для htmx-пути
|
||||
действий (`docs/conventions/web-ui.md`).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Своп карточки раз в интервал попадает в момент, когда человек ведёт мышь к
|
||||
кнопке** → поведение уже существует у карточек в `catched`; кнопки — обычные
|
||||
формы, потеря фокуса восстанавливается повторным наведением. Отдельного
|
||||
гашения свопа при наведении не делаем: заметная механика ради редкого случая.
|
||||
- **Сообщение об ошибке действия (`ActionError`) живёт до следующего тика** →
|
||||
сегодня оно живёт до любого следующего свопа, и в `catched` уже так. Отдельно
|
||||
не удерживаем: место для устойчивого объяснения — страница загрузки.
|
||||
- **Открытая на ночь вкладка держит опрос, пока есть хоть одна наблюдаемая
|
||||
задача** → ограничено медленным интервалом и прекращается само. Полный отказ
|
||||
от фонового опроса — предмет SSE-задачи.
|
||||
- **Цифры раздачи стали обновляться реже** → принято осознанно в Р2; величины
|
||||
медленные, а альтернатива — вложенный поллер внутри свопаемого корня.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет. Обе развилки решены на чекпоинте и записаны в Р1 и Р4: наблюдаемы
|
||||
нетерминальные плюс `failed`/`target_missing`/`orphaned`; интервалы — 5 с и 15 с.
|
||||
@@ -0,0 +1,66 @@
|
||||
## Why
|
||||
|
||||
Загрузка докачалась, воркер увёл её в распознавание и дальше в ревью — а в
|
||||
списке она по-прежнему «Загружается», без кнопки «Ревью →». Верное состояние
|
||||
появляется только после того, как человек сам перезагрузит страницу.
|
||||
|
||||
Живое обновление сегодня привязано к двум отдельным фазам: карточка опрашивает
|
||||
себя, пока задача в `catched`, а прогресс — пока она в `downloading`. Выйдя из
|
||||
`downloading`, задача не опрашивается ничем, хотя сменить состояние ей предстоит
|
||||
ещё не раз (распознавание, ревью, раскладка) и часть этих смен идёт вообще без
|
||||
участия того, кто смотрит на список: их делает воркер или человек из Telegram.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Самообновление карточки списка привязывается к **нетерминальности** задачи, а
|
||||
не к фазе `catched`: карточка обновляется, пока задача жива, и перестаёт —
|
||||
когда та встала окончательно.
|
||||
- У карточки остаётся **один** источник обновления. Сейчас в `downloading` их
|
||||
было бы два (сама карточка и вложенный фрагмент прогресса), и они опрашивали
|
||||
бы одно и то же дважды, подменяя разметку друг друга. Живые цифры прогресса
|
||||
приходят вместе с карточкой.
|
||||
- Страница `/download/{id}` живёт по тому же правилу: самообновляется, пока
|
||||
задача нетерминальна.
|
||||
- Частота обновления перестаёт быть одинаковой: карточка с живыми цифрами
|
||||
(скорость, ETA) обновляется чаще, чем карточка, у которой меняется только
|
||||
состояние. Цена тика у второй поверхности выше — сборка страницы загрузки
|
||||
считает предпросмотр раскладки и ходит в файловую систему.
|
||||
- Секция «Раздача» на странице загрузки перестаёт опрашивать сервер сама — она
|
||||
лежит внутри области, которую страница обновляет целиком, и два опроса на одну
|
||||
поверхность мешали бы друг другу.
|
||||
- Тик самообновления, не сумевший прочитать задачу, перестаёт быть молчаливым:
|
||||
он объясняет положение дел и прекращает опрос вместо бесконечного стука в
|
||||
сервер.
|
||||
- **BREAKING** для внутреннего контракта фрагментов: фрагменты прогресса и
|
||||
раздачи перестают быть самостоятельными поллерами. Наружного API это не
|
||||
касается — `/api/**` не меняется.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `web-ui`: требование «Отображение промежуточного состояния catched»
|
||||
обобщается — самообновление интерфейса перестаёт быть свойством одной фазы и
|
||||
становится свойством живой задачи; условие остановки — терминальное
|
||||
состояние.
|
||||
- `live-status`: требование «Живой прогресс активных загрузок» — сценарий
|
||||
«Завершение останавливает поллинг» сегодня описывает наблюдаемый дефект как
|
||||
норму. Прекращаться должен показ живых цифр, а не обновление карточки.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/httpapi`: `toView` и `buildDownloadView` (условие самообновления),
|
||||
обработчики фрагментов карточки и прогресса;
|
||||
- `web/templates/partials/card.html`, `progress.html`, `download_main.html`;
|
||||
- нагрузка: число фоновых запросов на открытую страницу меняется — считается в
|
||||
`design.md`;
|
||||
- вне scope: переход на SSE (задача `sse-live-updates`), любые изменения
|
||||
`/api/**` и состава живой телеметрии;
|
||||
- вне scope и названо сознательно: экран `/review/{id}` сохраняет фазовое
|
||||
самообновление, заказанное спекой `review` («пока загрузка в `recognizing`»).
|
||||
Третья поверхность приводится к общему правилу отдельной задачей — иначе
|
||||
change тянет за собой ещё одну capability.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Ревью изменения `card-live-refresh` — сводный отчёт триажа
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Размер / сложность / метка:** среднее / знакомое / `medium`.
|
||||
- **Режим прогона:** по графу. База диффа `969926f`.
|
||||
- **Гейт:** зелёный. `BASE=969926f task gate` прогнан проходом `autotests` независимо: 14 шагов
|
||||
`OK`, ни одного `SKIP`/`WARN`; `-race` реально исполнен, флаки-прогон побайтово совпал,
|
||||
diff-coverage 24/24, `gitleaks` по 187 коммитам чисто, `govulncheck` — 0 достижимых.
|
||||
- **Находок на входе:** 15 (autotests 2, specs 3, code 7, basics 4) + 3 наблюдения «вне спеки»
|
||||
+ 3 «дешевле переделать до мерджа». **На выходе:** 2 блокирующие + 4 «сейчас» + 4 гипотезы
|
||||
+ 5 promote.
|
||||
|
||||
### План разметки с исходом по темам
|
||||
|
||||
| Тема | Дом | Глубина | Кто закрывает | Исход |
|
||||
|---|---|---|---|---|
|
||||
| requirements | `openspec/specs/{web-ui,live-status}` + дельты | разбор | `specs` | закрыта, 3 находки |
|
||||
| autotests | CLAUDE.md «Гейт» | прогон | `autotests` | закрыта, гейт прогнан, 2 находки |
|
||||
| conventions | `docs/conventions/{README,web-ui,logging}.md` | разбор | `code` | закрыта, 3 находки |
|
||||
| architecture | `docs/architecture.md` + `passport.md` | разбор | `basics` | закрыта, 1 находка |
|
||||
| security | `docs/security.md` | разбор | `basics` | закрыта, 0 находок, все 5 вопросов отвечены |
|
||||
| operations | `docs/architecture.md` «Эксплуатация» + `docs/database.md` | разбор | `basics` | закрыта, 2 находки (обе понижены) |
|
||||
| техника (без темы) | — | разбор | `code` | 4 находки, 3 дедуплицированы |
|
||||
|
||||
**Тем без отчёта нет.** Своих тем проекта план не называл.
|
||||
|
||||
### Сигнал о заниженной метке
|
||||
|
||||
`review-code` — сигнала нет, возражений против `medium` не подаёт. `review-basics` строки о
|
||||
метке не прислал; «возражений нет» и «не проверял» по молчанию не различаются — сигнал по
|
||||
этому проходу считается неполученным, а не отрицательным.
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. Тик страницы `/download/{id}` при отказе чтения оставляет её навсегда устаревшей и стучит в сервер до закрытия вкладки
|
||||
|
||||
- Файл: `internal/httpapi/download.go:84-102`; `web/templates/partials/download_main.html:2`
|
||||
- Severity: major. Confidence: high.
|
||||
- Оракул: временный падающий тест триажа (прогнан, файл удалён): `page tick (not found)
|
||||
status = 404, want 200`; `page tick (store failure) status = 500`; «ответ тика не несёт корня
|
||||
`#download-main`». Плюс дельта `web-ui`: требование написано для обеих поверхностей.
|
||||
- Последствие: htmx не свопит 4xx/5xx и не снимает `hx-trigger` — страница показывает состояние,
|
||||
которого уже нет, человеку не сообщается ничего, вкладка стучит каждые 15 с бесконечно,
|
||||
добавляя строку ERROR за тик. До change это было незаметно: `SelfPoll` стоял только на
|
||||
короткоживущем `catched`, теперь наблюдаемых состояний 11.
|
||||
- Найдено четырьмя проходами независимо (`specs`, `basics`, `code`, `autotests`).
|
||||
- **Действие: развилка** (общая со следующей находкой).
|
||||
|
||||
### 2. Фрагмент отказа с чужим корневым `id` сносит `#review-main` и `#download-main` — экран мёртв до F5
|
||||
|
||||
- Файл: `internal/httpapi/live.go:175-182`; `web/templates/partials/frag_note.html:1`
|
||||
- Severity: major. Confidence: high.
|
||||
- Оракул: падающий тест триажа (тик `/review` при не-`ErrNotFound` отказе отдал
|
||||
`<article class="card" id="card-…">`), плюс `docs/conventions/web-ui.md:36-41` дословно:
|
||||
«корень `{{define}}` — это элемент с целевым `id` … если ответный фрагмент не несёт тот же
|
||||
корневой `id`, следующее действие/поллер не найдёт таргет».
|
||||
- Последствие: `fragErr` зовут из шести мест с четырьмя разными целями свопа, а отдаёт он всегда
|
||||
карточку. Транзиентный `SQLITE_BUSY` на тике `recognizing` (самый частый тик проекта, 2 с)
|
||||
заменяет весь `#review-main` карточкой списка — экран ревью теряет якорь и все действия.
|
||||
**Регрессия против базы:** раньше 500 не свопился и экран оставался рабочим.
|
||||
- **Действие: развилка** (тот же вопрос, отвечать один раз на обе).
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 3. Тик страницы затирает то, что человек в этот момент читает
|
||||
|
||||
- Файл: `web/templates/partials/download_main.html:2,6,99-128`; `internal/httpapi/httpapi.go:515-526`
|
||||
- Severity: minor. Confidence: high.
|
||||
- Два проявления одной причины: (1) сообщение об отказе действия живёт ≤15 с и исчезает, а в
|
||||
`error_msg` штатный конфликт не пишется и в логе он `DEBUG` — причина не остаётся нигде;
|
||||
(2) раскрытая «Опасная зона» захлопывается каждый тик, пока человек читает текст про
|
||||
необратимое удаление раздачи с файлами. Пересечение `Dismissable` с `IsObservable` —
|
||||
`failed`, `orphaned`, `target_missing`.
|
||||
- **Действие: развилка.**
|
||||
|
||||
### 4. Отказ на повторяющемся тике пишется ERROR — шторм в журнале ровно тогда, когда хранилищу плохо
|
||||
|
||||
- Файл: `internal/httpapi/live.go:139-143`, `internal/httpapi/live.go:175-180`
|
||||
- Severity: minor. Confidence: high.
|
||||
- Оракул: `docs/conventions/logging.md:164-171` дословно: «Повторяющийся сбой фонового цикла
|
||||
(поллинг/сверка) — `WARN`, не `ERROR` … уровень задаёт не текст ошибки, а наличие штатного
|
||||
ретрая».
|
||||
- **Действие: инлайн.**
|
||||
|
||||
### 5. Конвенция и комментарии описывают поллер, снятый этим же диффом
|
||||
|
||||
- Файл: `docs/conventions/web-ui.md:104-129`; `internal/httpapi/live.go:121-124`;
|
||||
`internal/httpapi/httpapi.go:314-318`
|
||||
- Severity: minor. Confidence: high.
|
||||
- Раздел «Живой поллинг» утверждает три неверных вещи: пример разметки с `hx-*` в `progress`;
|
||||
«эталон — `progress`/`seeding`»; «данные тика — из in-memory снимка, без БД/сети на каждый
|
||||
тик». Следующий автор возьмёт за образец снятое и заведёт второй поллер на поверхность.
|
||||
- **Действие: инлайн.**
|
||||
|
||||
### 6. Тест `TestFragProgressStopsWhenNotDownloading` больше не может упасть
|
||||
|
||||
- Файл: `internal/httpapi/live_test.go:35-48`
|
||||
- Severity: minor. Confidence: high.
|
||||
- `hx-*` сняты с партиала безусловно, поэтому проверка ложна при любом состоянии: тест зелен
|
||||
независимо от логики, которую называет. diff-coverage меряет исполнение, а не проверку.
|
||||
- **Действие: инлайн.**
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- Тик списка шлёт N HTTP-запросов и 2N запросов в SQLite там, где полный рендер обходится одним
|
||||
батчем (`live.go:133-146`). Понижено: замера нет; по-карточное обновление заказано дельтой,
|
||||
запрос идёт по индексу `idx_file_link_download`. Остаётся верным одно: арифметика Р4 в
|
||||
`design.md` занижена по числу обращений к БД.
|
||||
- Отказ чтения размеров подменяет известный размер прочерком (`live.go:136-144`). Понижено:
|
||||
дельта про отказ вспомогательного чтения молчит — вопрос к тексту дельты, не дефект кода.
|
||||
- Перечень «кого фон возвращает сам» разошёлся на три ручные копии
|
||||
(`store/download.go:67-84`, `worker/reconcile.go:22-26`). Понижено: расхождения и последствия
|
||||
сегодня нет, риск чисто будущий.
|
||||
- `pollFast = "5s"` — второй дом настройки `[worker].poll_interval`. Понижено: связь держится на
|
||||
прозе, сегодняшнее значение верно.
|
||||
|
||||
**Отсеяно как вкусовщина:** «имя `IsObservable` и комментарий расходятся с поведением на
|
||||
`review`/`deferred`» — предикат ровно такой, каким его определила дельта-спека поимённо.
|
||||
|
||||
**Проектных ложноположительных не сработало.**
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- Правило «ответ-фрагмент несёт корневой `id` того узла, в который свопится» — записано в
|
||||
конвенции, но не механизировано; кандидат в табличный тест или `internal/archrules`.
|
||||
- Задача на уборку маршрутов-гасителей `/fragments/downloads/{id}/progress` и `/seeding`.
|
||||
- Правило «константа, дублирующая значение настройки конфига, считается из конфига».
|
||||
- Тест-связка `worker.desyncStates` ↔ `store.selfHealingStates`.
|
||||
- Правило «тест, который не может упасть, — дефект теста»; класс ловится мутационной проверкой,
|
||||
diff-coverage его не видит по устройству.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
- Запускались на метке `medium`, режим «по графу»: `autotests`, `specs`, `code`, `basics`. Все
|
||||
четыре вернули отчёт. Триаж — сток.
|
||||
- **Не запускались** (нет на `medium`): враждебный проход с построенным путём атаки,
|
||||
эксплуатационный постмортем с замерами, независимая реализация. Их даёт только `large`.
|
||||
- **Потолки:** `code/conventions` 3 из 4, срез не сработал; `basics` 4 из 4 — срез сработал, за
|
||||
ним осталось наблюдение про ERROR на каждом тике (выведено отдельной находкой) и три пункта
|
||||
«дешевле переделать до мерджа». `specs`, `autotests`, `code/техника` потолков не сообщили —
|
||||
это находка о самом прогоне.
|
||||
- **На `small` и `medium` ничего не проверяется запуском сверх гейта:** построенный путь атаки,
|
||||
поведение библиотеки и драйвера в вырожденном случае, любые числа (время удержания блокировки,
|
||||
пик кучи, темп роста журнала). `basics` задаёт часть тех же вопросов чтением — его ответы
|
||||
слабее и выше гипотезы не поднимаются.
|
||||
- **Решения проекта не сверялись:** `docs/adr/` — процессный документ, прогон его не открывает;
|
||||
расхождение с записанным решением ловит `av-dev-docs:healthcheck`.
|
||||
- **Записанные наблюдения не использовались:** `docs/research/` — тоже процессный; всякое число
|
||||
в отчёте снято на этом прогоне.
|
||||
- **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом** — проход
|
||||
`idiom` упразднён (ADR-2026-08-04).
|
||||
- **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.**
|
||||
- Ни один тик в этом прогоне не исполнялся браузером: выводы о свопе и `hx-trigger` сделаны из
|
||||
разметки и текста конвенции.
|
||||
@@ -0,0 +1,68 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Живой прогресс активных загрузок
|
||||
|
||||
Веб-UI SHALL показывать прогресс, скорость и ETA активных (`downloading`)
|
||||
загрузок на главной без перезагрузки страницы, обновляя их тем же
|
||||
самообновлением, которым обновляется сама карточка (см. `web-ui`,
|
||||
«Самообновление живой задачи»). Обновление MUST NOT сбрасывать клиентские
|
||||
фильтр, поиск и прокрутку.
|
||||
|
||||
Когда задача покидает состояние `downloading`, показ скорости и ETA SHALL
|
||||
прекращаться: вне качания эти величины смысла не имеют. Снимок при этом
|
||||
продолжает питать прочие живые значения карточки и страницы — размер и рейтинг
|
||||
раздачи, — и прекращение показа цифр качания MUST NOT означать прекращения
|
||||
обновления поверхности: она продолжает отражать смену состояния, пока задача
|
||||
наблюдаема.
|
||||
|
||||
Живые цифры MUST браться из снимка воркера; при отсутствии данных по задаче
|
||||
поверхность деградирует без них, не ломая остального отображения.
|
||||
|
||||
#### Scenario: Прогресс растёт без перезагрузки
|
||||
|
||||
- **WHEN** загрузка качается и пользователь смотрит на главную
|
||||
- **THEN** её прогресс-бар, скорость и ETA обновляются на месте без
|
||||
перезагрузки страницы
|
||||
|
||||
#### Scenario: Клиентское состояние сохраняется
|
||||
|
||||
- **WHEN** применён фильтр или поиск и происходит фоновое обновление
|
||||
- **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются
|
||||
|
||||
#### Scenario: Завершение убирает цифры качания, но не обновление
|
||||
|
||||
- **WHEN** загрузка переходит из `downloading` в другое наблюдаемое состояние
|
||||
- **THEN** блок прогресса, скорости и ETA с карточки исчезает
|
||||
- **AND** карточка продолжает обновляться и приносит новое состояние
|
||||
- **AND** размер и рейтинг раздачи по-прежнему берутся из снимка
|
||||
|
||||
### Requirement: Секция раздачи на странице загрузки
|
||||
|
||||
Страница `/download/{id}` SHALL показывать секцию «Раздача» с живой статистикой
|
||||
(рейтинг, число сидов и пиров, объём отданного, скорость отдачи) для задач,
|
||||
чей торрент сидирует. Если живых данных по задаче нет, секция SHALL
|
||||
отсутствовать либо явно показывать «нет данных», не ломая остальную страницу.
|
||||
|
||||
Секция MUST NOT опрашивать сервер самостоятельно: она лежит внутри области,
|
||||
которую страница обновляет целиком, и собственный опрос секции подменял бы
|
||||
разметку страницы. Её цифры SHALL приходить с тиком самообновления страницы
|
||||
(см. `web-ui`, «Самообновление живой задачи»), а частота их обновления
|
||||
SHALL совпадать с частотой обновления страницы.
|
||||
|
||||
#### Scenario: Сидирующая задача показывает раздачу
|
||||
|
||||
- **WHEN** открыта страница задачи, торрент которой раздаётся
|
||||
- **THEN** в секции «Раздача» видны рейтинг, сиды/пиры, отдано и скорость отдачи
|
||||
|
||||
#### Scenario: Нет живых данных — секция деградирует
|
||||
|
||||
- **WHEN** открыта страница задачи, торрента которой нет в qBittorrent
|
||||
- **THEN** секция «Раздача» отсутствует или показывает «нет данных», а
|
||||
распознавание, файлы и история отображаются нормально
|
||||
|
||||
#### Scenario: Секция обновляется тиком страницы
|
||||
|
||||
- **GIVEN** открыта страница наблюдаемой задачи, чья раздача сидирует
|
||||
- **WHEN** страница отрисована
|
||||
- **THEN** секция «Раздача» не несёт собственного опроса
|
||||
- **AND** её цифры обновляются вместе с остальной страницей
|
||||
@@ -0,0 +1,200 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Самообновление живой задачи
|
||||
|
||||
Карточка списка и страница `/download/{id}` SHALL самообновляться, пока задача
|
||||
**наблюдаема**, и SHALL прекращать самообновление, как только она наблюдаемой
|
||||
быть перестала. Наблюдаемы все нетерминальные задачи, а из терминальных — те,
|
||||
которые фоновая сверка возвращает в поток сама: `failed`, `target_missing`,
|
||||
`orphaned`. Задача, которую с места двигает только человек (`done`, `cancelled`,
|
||||
`reverted`, `deleted`), наблюдаемой не является. Признак SHALL жить в домене
|
||||
рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT
|
||||
заводить.
|
||||
|
||||
Самообновление SHALL приносить смену состояния целиком — бейдж статуса,
|
||||
заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT
|
||||
сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия
|
||||
этого браузера (переход воркера, действие из Telegram, фоновая сверка), MUST
|
||||
становиться видимой тем же способом, пока задача наблюдаема: интерфейс не знает,
|
||||
кто изменил состояние.
|
||||
|
||||
У одной поверхности SHALL быть **ровно один** источник самообновления. Вложенные
|
||||
живые регионы (прогресс качания в карточке, секция раздачи на странице) MUST NOT
|
||||
опрашивать сервер самостоятельно: своп корня уносит вложенный узел вместе с его
|
||||
поллером, поэтому два опроса на одну поверхность подменяют разметку друг друга и
|
||||
опрашивают одно и то же дважды.
|
||||
|
||||
Интервал самообновления SHALL зависеть от того, несёт ли поверхность блок живых
|
||||
цифр качания: у поверхности с таким блоком интервал SHALL быть **строго меньше**,
|
||||
чем у поверхности без него. Числовые значения интервалов живут в документации
|
||||
проекта, не в спеке.
|
||||
|
||||
Тик самообновления, не сумевший прочитать задачу (записи нет, хранилище
|
||||
отказало), SHALL отвечать успехом и фрагментом, который объясняет положение дел
|
||||
и **не несёт** самообновления: неуспешный ответ не заменяет разметку, поэтому
|
||||
поверхность осталась бы прежней, а опрос продолжался бы бесконечно.
|
||||
|
||||
#### Scenario: Завершение качания видно без перезагрузки
|
||||
|
||||
- **GIVEN** открыт список загрузок и в нём есть задача в `downloading`
|
||||
- **WHEN** qBittorrent довёл раздачу до конца и воркер увёл задачу в
|
||||
`recognizing` и дальше в `review`
|
||||
- **THEN** карточка без перезагрузки страницы показывает бейдж ревью и кнопку
|
||||
«Ревью →»
|
||||
- **AND** блок живого прогресса с неё исчезает
|
||||
|
||||
#### Scenario: Переход, сделанный не из этого браузера
|
||||
|
||||
- **GIVEN** открыт список загрузок и в нём есть задача в `review`
|
||||
- **WHEN** человек подтвердил план из Telegram и задача прошла `linking` в `done`
|
||||
- **THEN** карточка без перезагрузки страницы показывает бейдж `done` и действия
|
||||
терминальной задачи
|
||||
|
||||
#### Scenario: Ненаблюдаемая задача не опрашивается
|
||||
|
||||
- **WHEN** задача находится в `done`, `cancelled`, `reverted` или `deleted`
|
||||
- **THEN** её карточка и страница `/download/{id}` не несут самообновления, и
|
||||
фоновых запросов по ним не уходит
|
||||
|
||||
#### Scenario: Задача, оживлённая сверкой, видна без перезагрузки
|
||||
|
||||
- **GIVEN** открыт список, и в нём есть задача в `failed` (магнет не добрал
|
||||
метаданные за отведённое время)
|
||||
- **WHEN** источник ожил и фоновая сверка вернула задачу в `downloading`
|
||||
- **THEN** карточка без перезагрузки страницы показывает состояние качания
|
||||
|
||||
#### Scenario: Один источник обновления на поверхность
|
||||
|
||||
- **WHEN** отрисована карточка задачи в `downloading` или страница задачи, чья
|
||||
раздача сидирует
|
||||
- **THEN** самообновление объявлено ровно в одном месте поверхности, а вложенные
|
||||
живые регионы своего опроса не ведут
|
||||
|
||||
#### Scenario: Быстрее обновляется то, где есть живые цифры
|
||||
|
||||
- **WHEN** рядом отрисованы карточка задачи в `downloading` и карточка задачи в
|
||||
`review`
|
||||
- **THEN** объявленный интервал самообновления первой строго меньше, чем у второй
|
||||
|
||||
#### Scenario: Тик, который не смог прочитать задачу
|
||||
|
||||
- **GIVEN** открыта карточка наблюдаемой задачи
|
||||
- **WHEN** очередной тик самообновления не нашёл записи или получил отказ
|
||||
хранилища
|
||||
- **THEN** ответ успешен и несёт фрагмент с объяснением
|
||||
- **AND** фрагмент не несёт самообновления, поэтому опрос прекращается
|
||||
|
||||
#### Scenario: Группа и фильтр списка пересчитываются навигацией
|
||||
|
||||
- **GIVEN** открыт список и в нём есть задача в `downloading`
|
||||
- **WHEN** задача дошла до терминального состояния на глазах у смотрящего
|
||||
- **THEN** карточка показывает новое состояние и остаётся на своём месте в
|
||||
прежней группе списка
|
||||
- **AND** группа и фильтр пересчитываются при следующей навигации или
|
||||
перезагрузке — список целиком самообновлением не пересобирается
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Отображение промежуточного состояния catched
|
||||
|
||||
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
|
||||
(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь
|
||||
понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а
|
||||
загрузка в `catched` SHALL относиться к **активной** группе списка.
|
||||
|
||||
Пока отображаемое имя ещё не выведено (в `catched` `download.display_name`
|
||||
пуст), заголовок загрузки SHALL деградировать по существующему фолбеку
|
||||
(распознанное название или усечённый источник) — см. «Заголовок загрузки из
|
||||
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
|
||||
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
|
||||
|
||||
Самообновление карточки и страницы в `catched` — частный случай требования
|
||||
«Самообновление живой задачи»: `catched` нетерминален, поэтому интерфейс
|
||||
подхватывает переход в `downloading` (бейдж, выведенное имя, появившийся живой
|
||||
прогресс) без перезагрузки страницы. Отдельного правила самообновления для этой
|
||||
фазы веб-UI MUST NOT иметь: фаза перестала быть единственной, где интерфейс
|
||||
обновляется сам.
|
||||
|
||||
#### Scenario: Бейдж и группа для catched
|
||||
|
||||
- **WHEN** загрузка находится в состоянии `catched`
|
||||
- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched`
|
||||
- **AND** загрузка попадает в активную группу списка
|
||||
|
||||
#### Scenario: Заголовок catched без имени
|
||||
|
||||
- **GIVEN** загрузка в `catched` с пустым `download.display_name`
|
||||
- **WHEN** рендерится карточка/страница загрузки
|
||||
- **THEN** заголовок берётся из фолбека (распознанное название или усечённый
|
||||
источник), без ошибок отображения
|
||||
- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет)
|
||||
|
||||
#### Scenario: Самообновление при переходе в downloading
|
||||
|
||||
- **GIVEN** открытая карточка загрузки в `catched`
|
||||
- **WHEN** worker перевёл загрузку в `downloading`
|
||||
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
|
||||
(бейдж, имя, живой прогресс)
|
||||
- **AND** самообновление продолжается, потому что задача осталась наблюдаемой
|
||||
|
||||
### Requirement: Обзор жизненного цикла в карточке списка
|
||||
|
||||
Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о
|
||||
судьбе раздачи: метку `ID:` перед копируемым идентификатором загрузки, дату
|
||||
добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки
|
||||
MUST NOT показываться в карточке списка — он доступен на странице `/download/{id}`.
|
||||
|
||||
Дата добавления SHALL показываться всегда как абсолютная дата и относительная
|
||||
давность (например «`2026-06-30 · 5 дней назад`»); источником SHALL быть время
|
||||
добавления раздачи в источник (`source_added_at`, qBittorrent `added_on`) с
|
||||
фолбэком на время создания загрузки (`created_at`), согласованным с порядком
|
||||
списка.
|
||||
|
||||
Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в
|
||||
снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».
|
||||
|
||||
Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при
|
||||
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
|
||||
если неизвестно ни то, ни другое — прочерк «—».
|
||||
|
||||
Карточка, пришедшая **самообновлением**, SHALL показывать те же значения, что и
|
||||
карточка в полном рендере списка: фоновое обновление MUST NOT подменять
|
||||
известное значение прочерком.
|
||||
|
||||
#### Scenario: Метка идентификатора
|
||||
|
||||
- **WHEN** рендерится карточка загрузки в списке
|
||||
- **THEN** перед значением `download.id` показана метка «ID:», а кнопка
|
||||
копирования копирует именно `download.id`
|
||||
|
||||
#### Scenario: Дата добавления показана всегда
|
||||
|
||||
- **WHEN** рендерится любая карточка списка
|
||||
- **THEN** в ней показана дата добавления раздачи абсолютной датой и
|
||||
относительной давностью
|
||||
- **AND** если `source_added_at` неизвестно, используется `created_at`
|
||||
|
||||
#### Scenario: Рейтинг из живого снимка
|
||||
|
||||
- **WHEN** торрент загрузки присутствует в живом снимке
|
||||
- **THEN** в карточке показан его рейтинг отдачи
|
||||
- **AND** если торрента в снимке нет, рейтинг показан прочерком «—»
|
||||
|
||||
#### Scenario: Размер с фолбэком на разложенные файлы
|
||||
|
||||
- **WHEN** торрент загрузки присутствует в живом снимке
|
||||
- **THEN** размер раздачи в карточке берётся из общего размера торрента
|
||||
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
|
||||
размер берётся из суммарного размера этих файлов
|
||||
|
||||
#### Scenario: Самообновление не теряет размер
|
||||
|
||||
- **GIVEN** торрента нет в живом снимке, а файлы задачи разложены
|
||||
- **WHEN** карточка пришла самообновлением, а не полным рендером списка
|
||||
- **THEN** размер показан по тому же фолбэку, а не прочерком «—»
|
||||
|
||||
#### Scenario: Контекст не в карточке
|
||||
|
||||
- **WHEN** у загрузки есть переданный контекст
|
||||
- **THEN** он не показывается в карточке списка, но доступен на странице
|
||||
`/download/{id}`
|
||||
@@ -0,0 +1,101 @@
|
||||
## 1. Условие и частота самообновления
|
||||
|
||||
- [x] 1.1 Завести в домене (`internal/store`, рядом с `IsTerminal`) предикат
|
||||
наблюдаемости: нетерминальные плюс `failed`, `target_missing`, `orphaned`;
|
||||
`done`, `cancelled`, `reverted`, `deleted` — не наблюдаемы. Перечень состояний
|
||||
в `httpapi` не заводить
|
||||
- [x] 1.2 Завести рядом с представлениями две константы интервала — быстрый 5 с
|
||||
(вровень с `[worker].poll_interval`, для поверхности с блоком живых цифр
|
||||
качания) и медленный 15 с — и поле вида, которое отдаёт шаблону выбранный
|
||||
интервал
|
||||
- [x] 1.3 `toView` (`internal/httpapi/httpapi.go`): `SelfPoll` — по предикату
|
||||
наблюдаемости, интервал — быстрый только при `IsDownloading`
|
||||
- [x] 1.4 `buildDownloadView` (`internal/httpapi/download.go`): тот же предикат,
|
||||
интервал всегда медленный — блока прогресса на странице нет
|
||||
|
||||
## 2. Шаблоны
|
||||
|
||||
- [x] 2.1 `card.html`: интервал самополлинга берётся из вида, а не зашит в
|
||||
разметку
|
||||
- [x] 2.2 `progress.html`: снять собственный `hx-get`/`hx-trigger` — партиал
|
||||
остаётся вложенной разметкой карточки без своего опроса
|
||||
- [x] 2.3 `download_main.html`: интервал самополлинга берётся из вида
|
||||
- [x] 2.4 `seeding.html`: снять собственный `hx-get`/`hx-trigger` — секция лежит
|
||||
внутри свопаемого `#download-main` и едет с тиком страницы
|
||||
|
||||
## 3. Самообновление не теряет полей полного рендера
|
||||
|
||||
- [x] 3.1 `handleFragCard` читает размер раскладки через `LayoutSizeByDownload`,
|
||||
как это делает `renderCardFragment`, вместо жёсткого `layoutSize = 0`
|
||||
- [x] 3.2 Отказ тика (`fragErr` и путь «записи нет») отвечает `200` и фрагментом
|
||||
без `hx-*`: объяснение вместо молчаливого застывания и вечного опроса
|
||||
|
||||
## 4. Маршруты фрагментов
|
||||
|
||||
- [x] 4.1 `GET /fragments/downloads/{id}/progress` и `.../seeding` оставить
|
||||
живыми: их партиалы теперь без поллера, поэтому ответ гасит разметку вкладок,
|
||||
открытых до деплоя. В комментарии назвать, что потребителей в новой разметке
|
||||
нет и обработчики убираются отдельной уборкой
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
- [x] 5.1 Карточка наблюдаемой задачи несёт самополлинг на
|
||||
`/fragments/downloads/{id}/card` (включая `failed`, `target_missing`,
|
||||
`orphaned`); карточка `done`, `cancelled`, `reverted`, `deleted` — не несёт ни
|
||||
`hx-get`, ни `hx-trigger`
|
||||
- [x] 5.2 Фрагмент карточки после смены состояния отдаёт новый бейдж и новый
|
||||
набор действий: для `review` — кнопку «Ревью →», для `done` — «Откатить»
|
||||
- [x] 5.3 Карточка в `downloading` содержит ровно одно объявление самополлинга, а
|
||||
вложенный блок прогресса — ни одного; страница сидирующей задачи — ровно одно,
|
||||
а секция «Раздача» — ни одного
|
||||
- [x] 5.4 Интервал в разметке: быстрый у карточки в `downloading`, медленный у
|
||||
прочих наблюдаемых карточек и у страницы в любом состоянии
|
||||
- [x] 5.5 `buildDownloadView`: `SelfPoll` истинен для наблюдаемых состояний и
|
||||
ложен для остальных
|
||||
- [x] 5.6 Фрагмент карточки задачи без раздачи в снимке, но с разложенными
|
||||
файлами показывает размер, а не «—»
|
||||
- [x] 5.7 Тик по несуществующей задаче отвечает `200` фрагментом без `hx-*`
|
||||
|
||||
## 6. Документация и приёмка
|
||||
|
||||
- [x] 6.1 `docs/database.md`: обе константы интервала в таблицу настроек с
|
||||
числовым значением
|
||||
- [x] 6.2 Поведенческая проверка на живом стенде: открыть список, довести
|
||||
раздачу до конца и убедиться, что карточка сама показала переход
|
||||
- [x] 6.3 `openspec validate --strict card-live-refresh` и `task gate` зелёные
|
||||
|
||||
## Критерии приёмки (из записи задачи)
|
||||
|
||||
- [x] К1 Карточка нетерминальной загрузки самополлится, и первый ответ фрагмента
|
||||
после смены состояния несёт новый бейдж (оракул: тест `internal/httpapi` —
|
||||
рендер карточки в `downloading` содержит `hx-get` на
|
||||
`/fragments/downloads/{id}/card`, а подставной читатель, сменивший состояние на
|
||||
`review`, отдаёт карточку с бейджем ревью)
|
||||
- [x] К2 Терминальная карточка себя не опрашивает: фоновых запросов после `done`,
|
||||
`cancelled`, `reverted` и `deleted` нет (оракул: тот же тест — в разметке такой
|
||||
карточки нет ни `hx-get`, ни `hx-trigger`). **Уточнён на чекпоинте:** `failed`,
|
||||
`target_missing` и `orphaned` из перечня выведены — их возвращает в поток
|
||||
фоновая сверка, поэтому они наблюдаются медленным интервалом
|
||||
- [x] К3 Появившееся действие видно без перезагрузки: карточка задачи,
|
||||
перешедшей в `review`, несёт кнопку «Ревью →» (оракул: тест фрагмента карточки)
|
||||
- [x] К4 Страница `/download/{id}` обновляет бейдж и блок действий по тому же
|
||||
правилу, что и карточка (оракул: тест `internal/httpapi/download.go` —
|
||||
`SelfPoll` истинен для наблюдаемых состояний и ложен для остальных)
|
||||
- [x] К5 Правило записано в дельта-спеках обеих затронутых capability (оракул:
|
||||
`openspec validate --strict` и шаг канона в `task gate`)
|
||||
|
||||
## Приёмочные критерии из рубрики (ревью дизайна)
|
||||
|
||||
- [x] Р-1 Стоп-условие совпадает с «дальше само ничего не изменится»: для каждого
|
||||
состояния, где опрос прекращается, названо, что его не двигает ни воркер, ни
|
||||
сверка
|
||||
- [x] Р-2 Финальный тик доставляет новое содержимое до остановки: остановка —
|
||||
свойство уже отданного фрагмента, а не отдельное решение
|
||||
- [x] Р-3 Ровно один поллер на обновляемый корень; корень фрагмента-ответа несёт
|
||||
тот же `id`, что и цель свопа
|
||||
- [x] Р-4 Все поля одного ответа посчитаны из одного чтения: бейдж, действия,
|
||||
цифры и интервал не расходятся между собой
|
||||
- [x] Р-5 Отказ тика определён: что видит человек, продолжается ли опрос, на
|
||||
каком уровне пишется лог
|
||||
- [x] Р-6 Стоимость тика посчитана: что делает один тик и сколько запросов даёт
|
||||
открытая страница и список из N карточек
|
||||
@@ -86,10 +86,21 @@ SHALL быть доступен для любой раздачи, присутс
|
||||
|
||||
### Requirement: Живой прогресс активных загрузок
|
||||
|
||||
Веб-UI SHALL обновлять прогресс активных (downloading) загрузок на главной без
|
||||
перезагрузки страницы — поллингом фрагмента через htmx. Обновление MUST NOT
|
||||
сбрасывать клиентские фильтр, поиск и прокрутку. Когда задача покидает
|
||||
состояние downloading, поллинг её прогресса SHALL прекращаться.
|
||||
Веб-UI SHALL показывать прогресс, скорость и ETA активных (`downloading`)
|
||||
загрузок на главной без перезагрузки страницы, обновляя их тем же
|
||||
самообновлением, которым обновляется сама карточка (см. `web-ui`,
|
||||
«Самообновление живой задачи»). Обновление MUST NOT сбрасывать клиентские
|
||||
фильтр, поиск и прокрутку.
|
||||
|
||||
Когда задача покидает состояние `downloading`, показ скорости и ETA SHALL
|
||||
прекращаться: вне качания эти величины смысла не имеют. Снимок при этом
|
||||
продолжает питать прочие живые значения карточки и страницы — размер и рейтинг
|
||||
раздачи, — и прекращение показа цифр качания MUST NOT означать прекращения
|
||||
обновления поверхности: она продолжает отражать смену состояния, пока задача
|
||||
наблюдаема.
|
||||
|
||||
Живые цифры MUST браться из снимка воркера; при отсутствии данных по задаче
|
||||
поверхность деградирует без них, не ломая остального отображения.
|
||||
|
||||
#### Scenario: Прогресс растёт без перезагрузки
|
||||
|
||||
@@ -99,13 +110,15 @@ SHALL быть доступен для любой раздачи, присутс
|
||||
|
||||
#### Scenario: Клиентское состояние сохраняется
|
||||
|
||||
- **WHEN** применён фильтр или поиск и происходит фоновое обновление прогресса
|
||||
- **WHEN** применён фильтр или поиск и происходит фоновое обновление
|
||||
- **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются
|
||||
|
||||
#### Scenario: Завершение останавливает поллинг
|
||||
#### Scenario: Завершение убирает цифры качания, но не обновление
|
||||
|
||||
- **WHEN** загрузка переходит из downloading в другое состояние
|
||||
- **THEN** фоновый поллинг прогресса для этой карточки прекращается
|
||||
- **WHEN** загрузка переходит из `downloading` в другое наблюдаемое состояние
|
||||
- **THEN** блок прогресса, скорости и ETA с карточки исчезает
|
||||
- **AND** карточка продолжает обновляться и приносит новое состояние
|
||||
- **AND** размер и рейтинг раздачи по-прежнему берутся из снимка
|
||||
|
||||
### Requirement: Секция раздачи на странице загрузки
|
||||
|
||||
@@ -114,6 +127,12 @@ SHALL быть доступен для любой раздачи, присутс
|
||||
чей торрент сидирует. Если живых данных по задаче нет, секция SHALL
|
||||
отсутствовать либо явно показывать «нет данных», не ломая остальную страницу.
|
||||
|
||||
Секция MUST NOT опрашивать сервер самостоятельно: она лежит внутри области,
|
||||
которую страница обновляет целиком, и собственный опрос секции подменял бы
|
||||
разметку страницы. Её цифры SHALL приходить с тиком самообновления страницы
|
||||
(см. `web-ui`, «Самообновление живой задачи»), а частота их обновления
|
||||
SHALL совпадать с частотой обновления страницы.
|
||||
|
||||
#### Scenario: Сидирующая задача показывает раздачу
|
||||
|
||||
- **WHEN** открыта страница задачи, торрент которой раздаётся
|
||||
@@ -125,3 +144,10 @@ SHALL быть доступен для любой раздачи, присутс
|
||||
- **THEN** секция «Раздача» отсутствует или показывает «нет данных», а
|
||||
распознавание, файлы и история отображаются нормально
|
||||
|
||||
#### Scenario: Секция обновляется тиком страницы
|
||||
|
||||
- **GIVEN** открыта страница наблюдаемой задачи, чья раздача сидирует
|
||||
- **WHEN** страница отрисована
|
||||
- **THEN** секция «Раздача» не несёт собственного опроса
|
||||
- **AND** её цифры обновляются вместе с остальной страницей
|
||||
|
||||
|
||||
@@ -339,6 +339,10 @@ MUST NOT показываться в карточке списка — он до
|
||||
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
|
||||
если неизвестно ни то, ни другое — прочерк «—».
|
||||
|
||||
Карточка, пришедшая **самообновлением**, SHALL показывать те же значения, что и
|
||||
карточка в полном рендере списка: фоновое обновление MUST NOT подменять
|
||||
известное значение прочерком.
|
||||
|
||||
#### Scenario: Метка идентификатора
|
||||
|
||||
- **WHEN** рендерится карточка загрузки в списке
|
||||
@@ -365,6 +369,12 @@ MUST NOT показываться в карточке списка — он до
|
||||
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
|
||||
размер берётся из суммарного размера этих файлов
|
||||
|
||||
#### Scenario: Самообновление не теряет размер
|
||||
|
||||
- **GIVEN** торрента нет в живом снимке, а файлы задачи разложены
|
||||
- **WHEN** карточка пришла самообновлением, а не полным рендером списка
|
||||
- **THEN** размер показан по тому же фолбэку, а не прочерком «—»
|
||||
|
||||
#### Scenario: Контекст не в карточке
|
||||
|
||||
- **WHEN** у загрузки есть переданный контекст
|
||||
@@ -470,6 +480,99 @@ PRG-редиректом, и действие исполняется тем же
|
||||
- **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до
|
||||
следующей полной загрузки списка, без клиентского переупорядочивания
|
||||
|
||||
### Requirement: Самообновление живой задачи
|
||||
|
||||
Карточка списка и страница `/download/{id}` SHALL самообновляться, пока задача
|
||||
**наблюдаема**, и SHALL прекращать самообновление, как только она наблюдаемой
|
||||
быть перестала. Наблюдаемы все нетерминальные задачи, а из терминальных — те,
|
||||
которые фоновая сверка возвращает в поток сама: `failed`, `target_missing`,
|
||||
`orphaned`. Задача, которую с места двигает только человек (`done`, `cancelled`,
|
||||
`reverted`, `deleted`), наблюдаемой не является. Признак SHALL жить в домене
|
||||
рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT
|
||||
заводить.
|
||||
|
||||
Самообновление SHALL приносить смену состояния целиком — бейдж статуса,
|
||||
заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT
|
||||
сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия
|
||||
этого браузера (переход воркера, действие из Telegram, фоновая сверка), MUST
|
||||
становиться видимой тем же способом, пока задача наблюдаема: интерфейс не знает,
|
||||
кто изменил состояние.
|
||||
|
||||
У одной поверхности SHALL быть **ровно один** источник самообновления. Вложенные
|
||||
живые регионы (прогресс качания в карточке, секция раздачи на странице) MUST NOT
|
||||
опрашивать сервер самостоятельно: своп корня уносит вложенный узел вместе с его
|
||||
поллером, поэтому два опроса на одну поверхность подменяют разметку друг друга и
|
||||
опрашивают одно и то же дважды.
|
||||
|
||||
Интервал самообновления SHALL зависеть от того, несёт ли поверхность блок живых
|
||||
цифр качания: у поверхности с таким блоком интервал SHALL быть **строго меньше**,
|
||||
чем у поверхности без него. Числовые значения интервалов живут в документации
|
||||
проекта, не в спеке.
|
||||
|
||||
Тик самообновления, не сумевший прочитать задачу (записи нет, хранилище
|
||||
отказало), SHALL отвечать успехом и фрагментом, который объясняет положение дел
|
||||
и **не несёт** самообновления: неуспешный ответ не заменяет разметку, поэтому
|
||||
поверхность осталась бы прежней, а опрос продолжался бы бесконечно.
|
||||
|
||||
#### Scenario: Завершение качания видно без перезагрузки
|
||||
|
||||
- **GIVEN** открыт список загрузок и в нём есть задача в `downloading`
|
||||
- **WHEN** qBittorrent довёл раздачу до конца и воркер увёл задачу в
|
||||
`recognizing` и дальше в `review`
|
||||
- **THEN** карточка без перезагрузки страницы показывает бейдж ревью и кнопку
|
||||
«Ревью →»
|
||||
- **AND** блок живого прогресса с неё исчезает
|
||||
|
||||
#### Scenario: Переход, сделанный не из этого браузера
|
||||
|
||||
- **GIVEN** открыт список загрузок и в нём есть задача в `review`
|
||||
- **WHEN** человек подтвердил план из Telegram и задача прошла `linking` в `done`
|
||||
- **THEN** карточка без перезагрузки страницы показывает бейдж `done` и действия
|
||||
терминальной задачи
|
||||
|
||||
#### Scenario: Ненаблюдаемая задача не опрашивается
|
||||
|
||||
- **WHEN** задача находится в `done`, `cancelled`, `reverted` или `deleted`
|
||||
- **THEN** её карточка и страница `/download/{id}` не несут самообновления, и
|
||||
фоновых запросов по ним не уходит
|
||||
|
||||
#### Scenario: Задача, оживлённая сверкой, видна без перезагрузки
|
||||
|
||||
- **GIVEN** открыт список, и в нём есть задача в `failed` (магнет не добрал
|
||||
метаданные за отведённое время)
|
||||
- **WHEN** источник ожил и фоновая сверка вернула задачу в `downloading`
|
||||
- **THEN** карточка без перезагрузки страницы показывает состояние качания
|
||||
|
||||
#### Scenario: Один источник обновления на поверхность
|
||||
|
||||
- **WHEN** отрисована карточка задачи в `downloading` или страница задачи, чья
|
||||
раздача сидирует
|
||||
- **THEN** самообновление объявлено ровно в одном месте поверхности, а вложенные
|
||||
живые регионы своего опроса не ведут
|
||||
|
||||
#### Scenario: Быстрее обновляется то, где есть живые цифры
|
||||
|
||||
- **WHEN** рядом отрисованы карточка задачи в `downloading` и карточка задачи в
|
||||
`review`
|
||||
- **THEN** объявленный интервал самообновления первой строго меньше, чем у второй
|
||||
|
||||
#### Scenario: Тик, который не смог прочитать задачу
|
||||
|
||||
- **GIVEN** открыта карточка наблюдаемой задачи
|
||||
- **WHEN** очередной тик самообновления не нашёл записи или получил отказ
|
||||
хранилища
|
||||
- **THEN** ответ успешен и несёт фрагмент с объяснением
|
||||
- **AND** фрагмент не несёт самообновления, поэтому опрос прекращается
|
||||
|
||||
#### Scenario: Группа и фильтр списка пересчитываются навигацией
|
||||
|
||||
- **GIVEN** открыт список и в нём есть задача в `downloading`
|
||||
- **WHEN** задача дошла до терминального состояния на глазах у смотрящего
|
||||
- **THEN** карточка показывает новое состояние и остаётся на своём месте в
|
||||
прежней группе списка
|
||||
- **AND** группа и фильтр пересчитываются при следующей навигации или
|
||||
перезагрузке — список целиком самообновлением не пересобирается
|
||||
|
||||
### Requirement: Отображение промежуточного состояния catched
|
||||
|
||||
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
|
||||
@@ -483,11 +586,12 @@ PRG-редиректом, и действие исполняется тем же
|
||||
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
|
||||
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
|
||||
|
||||
Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся
|
||||
htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading`
|
||||
интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж,
|
||||
выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL
|
||||
завершаться, как только загрузка её покинула.
|
||||
Самообновление карточки и страницы в `catched` — частный случай требования
|
||||
«Самообновление живой задачи»: `catched` нетерминален, поэтому интерфейс
|
||||
подхватывает переход в `downloading` (бейдж, выведенное имя, появившийся живой
|
||||
прогресс) без перезагрузки страницы. Отдельного правила самообновления для этой
|
||||
фазы веб-UI MUST NOT иметь: фаза перестала быть единственной, где интерфейс
|
||||
обновляется сам.
|
||||
|
||||
#### Scenario: Бейдж и группа для catched
|
||||
|
||||
@@ -509,7 +613,7 @@ htmx-поллингом (см. конвенцию веб-UI): по перехо
|
||||
- **WHEN** worker перевёл загрузку в `downloading`
|
||||
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
|
||||
(бейдж, имя, живой прогресс)
|
||||
- **AND** поллинг фазы `catched` завершается
|
||||
- **AND** самообновление продолжается, потому что задача осталась наблюдаемой
|
||||
|
||||
### Requirement: Загрузка .torrent-файла на форме добавления
|
||||
|
||||
|
||||
Reference in New Issue
Block a user