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,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.