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:
av
2026-08-10 14:02:38 +03:00
parent 969926fae3
commit a5d873b62d
29 changed files with 1575 additions and 99 deletions
@@ -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 карточек