Andrey Epifancev a8f20b232e docs(openspec): архив web-app-followups (#29)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:22:44 +04:00

22 KiB
Raw Blame History

Context

main d38fac8: веб-приложение хаба (web/, React 19 + TS + Vite, TanStack Query, React Router) под /app/, спека openspec/specs/web-app/, хаб раздаёт сборку (h_app в api.py). Отложенные находки change web-app собраны в jira-hub#29 (46 пунктов, маркеры сохраняются из PR #27). Устройство, на которое опираются правки:

  • api/client.ts — единственный HTTP-клиент: call() берёт токен через getToken(), 401 → AuthError, сеть и 5xx → HubDownError, прочие 4xx → ApiError {status, error, detail}. Токен очищается clearToken("bad") (из useBoardSource.report и глобального обработчика ошибок queryClient), AuthGate показывает LoginScreen.
  • board/useBoardSource.ts — источник доски: source (hub | file), event (одна строка состояния), active (опрашиваемый пакет); подхват незавершённого пакета viewer по каждому board.dataUpdatedAt через GET /batches. Состояние локально для BoardPage и пропадает при уходе со страницы; снимок хаба — в кэше TanStack Query, раскладка и фильтры — в localStorage.
  • GET /batches отдаёт пакеты без команд (queue.list_batches); GET /batches/{id} — с commands[] (type).
  • issues/IssuesPage.tsx — фильтры в query-строке, варианты — из filterOptions(board, settings).
  • index.html подключает IBM Plex Sans/Mono с fonts.googleapis.com; npm audit — 1 high в source-map-js@1.2.1 (транзитивная dev-зависимость), исправление — 1.2.2 (2026-09-30).

Goals / Non-Goals

Goals: закрыть jira-hub#29 целиком: каждый пункт — fixed <commit> или rejected <причина>; поведение доски на тех же данных совпадает с текущим, кроме пунктов группы A; make check и e2e зелёные, e2e устойчив к повторам.

Non-Goals: см. proposal.md.

Decisions

  1. Прокрутка доски (M-3). BoardCanvas запоминает window.scrollX/scrollY (и scrollLeft горизонтальной обёртки .wrap, если она прокручивается сама) в переменной модуля по событиям scroll окна и .wrap; при монтировании восстанавливает в useLayoutEffect после placed (первой отрисовки холста с данными). Перезагрузка страницы открывает доску сверху, как сейчас. Состояние источника не поднимается выше страницы — rejected часть M-3 (решение владельца).
  2. Строка состояния при идущем пакете (T10.3). reload() и loadFile() не сбрасывают событие batch, пока active !== null; сообщения о файле (file-error, json-applied) по-прежнему заменяют его (одно событие, M-5 #20), а следующий ответ опроса возвращает строку пакета.
  3. Ошибка хаба при снимке из файла (T10.4). Событие hub-down получает признак fromFile; текст — «хаб не отвечает — на доске снимок из файла». Он ставится, когда снимок хаба не загрузился при показанном файле (перечитывание после завершения пакета) и когда JSON-снимок загружен без данных хаба; hasData в последнем случае — есть ли в модели задачи. «Перечитать» по-прежнему возвращает источник «хаб».
  4. Подхват пакета (T10.6, T10.1). Кандидат из GET /batches (автор viewer, queued/running) проверяется запросом GET /batches/{id}: подхватывается, только если первая команда — refresh. Подхват не запускается по обновлению снимка, которое вызвал сам finish() (флаг «снимок после своего пакета»), — без лишнего GET /batches (T10.1).
  5. Токен в ошибке (T3.5). call() кладёт в AuthError токен, с которым ушёл запрос; clearToken вызывается только если этот токен равен текущему. Запоздавший 401 от прежнего токена игнорируется.
  6. Вход и 4xx (T3.2). LoginScreen: ApiError → «Хаб отверг проверку токена: <detail или error>»; HubDownError — прежний текст «хаб не отвечает».
  7. Фильтр вне вариантов (T13.1). Если значение фильтра из адреса не найдено в opts[name], оно добавляется первым вариантом с подписью «<значение> (нет на доске)» — как текущее значение «вида работы» в заметке.
  8. Карточка задачи (O-10, O-11). Ссылки страниц задач — color: var(--accent) из темы (как в списке); пустой commands — строка «команд по задаче не было» вместо пустой таблицы.
  9. NUL в пути (T1.1). h_app отвечает 404 not_found, если в декодированном пути есть \x00 или разрешение пути падает с ValueError/OSError; остальное поведение h_app не меняется.
  10. Шрифты (T2.2). @fontsource/ibm-plex-sans (400, 500, 600) и @fontsource/ibm-plex-mono (500), импорт CSS в main.tsx; ссылка на Google Fonts из index.html удаляется. Файлы woff2 попадают в assets/, хаб уже отдаёт font/woff2. Версии — точные, npm install --before=2026-10-01 --save-exact.
  11. source-map-js (O-1). overrides: {"source-map-js": "1.2.2"} в package.json, проверка npm audit (0 high) и сборка; если 1.2.2 не закрывает уязвимость — O-1 rejected с текстом аудита.
  12. Эталоны (T5.1). layout.clamp — число строк заголовка при ручной высоте карточки; «unset» — у карточек с автоматической высотой. В scenarios.json добавляется сценарий saved-short с сохранённой раскладкой четырёх карточек разной высоты (120, 136, 200, 320), эталоны перегенерируются npm run golden из links.html@3931a88; прежние сценарии и фикстура hub-basic.board.json не меняются.
  13. e2e (T15.4, T15.6). Порт тестового хаба — одна константа (HUB_PORT) в web/playwright.config.ts, web/e2e/hub.ts импортирует её; scripts/test-hub.sh перед стартом проверяет, что порт свободен, и выходит с понятной ошибкой. Готовность — e2e проходит 10 раз подряд.
  14. Состав плана. 8 задач: хаб; клиент и вход; источник доски; холст и страница доски; задачи и карточка; модель, хранилище, эталоны; окружение и e2e; документация. Исполнение — SDD-контроллер (sonnet), задачи с готовым кодом — haiku, интеграция — sonnet, финальное ревью ветки — opus.

Пункты #29 и их судьба

Маркер Что делаем Задача плана
T1.1 404 на NUL в пути (решение 9) хаб
T1.4 тест Content-Type для svg/png/ico/woff2/map хаб
T16.1 убрать дубль import urllib.* хаб
T3.2 4xx на входе — текст ошибки хаба (решение 6) клиент и вход
T3.3 содержательный тест 401 и тесты queryClient/queries клиент и вход
T3.4 token.test проверяет removeItem при закрытом хранилище клиент и вход
T3.5 токен в AuthError (решение 5) клиент и вход
T3.6 интеграционный тест AuthError → LoginScreen клиент и вход
T10.1 без лишнего GET /batches (решение 4) источник доски
T10.3 строка пакета не стирается (решение 2) источник доски
T10.4 «на доске снимок из файла» (решение 3) источник доски
T10.5 тест «поздний ответ» проверяет, что ответ хаба пришёл после файла источник доски
T10.6 подхват только refresh (решение 4) источник доски
M-3 прокрутка (решение 1); хранение источника — rejected холст и страница доски
T11.1 стабильные пропсы Card, memo срабатывает холст и страница доски
T11.2 отмена requestAnimationFrame в DatesBar холст и страница доски
T11.3 тест перетаскивания дорожки холст и страница доски
T11.4 точные проверки в тестах I-2/I-3 холст и страница доски
T12.1 onOpen() вне updater setOpen холст и страница доски
T12.2 writeFlag/writeHidden вне updater холст и страница доски
T12.3 зависимости useLayoutEffect, сброс --head-h холст и страница доски
T12.4 MultiSelect: role="group" с флажками вместо listbox без option холст и страница доски
T13.1 значение фильтра вне вариантов (решение 7) задачи и карточка
O-10 ссылки цветом темы (решение 8) задачи и карточка
O-11 «команд по задаче не было» (решение 8) задачи и карточка
T14.2 ожидания через waitFor/expect.poll вместо пауз 20 мс задачи и карточка
T14.3 уникальный id datalist (useId) задачи и карточка
T2.1 canon.ts — сравнение по кодам символов модель, хранилище, эталоны
T5.1 сценарий saved-short для layout.clamp (решение 12) модель, хранилище, эталоны
T6.3 прямые тесты util.ts модель, хранилище, эталоны
T7.1 initialWho считает whoItems один раз модель, хранилище, эталоны
T9.1 colWidths() пропускает только конечные числа модель, хранилище, эталоны
T9.3 тесты toggleLane/moveLane/resetLayout/exportFileName/mode=all, закрытое хранилище модель, хранилище, эталоны
T9.4 порядок импортов fromJson.ts модель, хранилище, эталоны
T2.2 шрифты из @fontsource (решение 10) окружение и e2e
O-1 overrides source-map-js (решение 11) окружение и e2e
T4.1 тест «Задачи и обратно» с обратным переходом окружение и e2e
T4.2 тест ссылок страницы «не найдено» окружение и e2e
T4.3 убрать App.Layout, тест берёт Layout окружение и e2e
T4.4 один импорт из @testing-library/react окружение и e2e
T4.5 очистка атрибутов в theme.test окружение и e2e
T15.1 высота карточек в geometry сверяется с эталоном, не с формулой окружение и e2e
T15.2 растяжка через expect.poll, без литерала 240 окружение и e2e
T15.4 порт — одна константа (решение 13) окружение и e2e
T15.6 проверка свободного порта (решение 13) окружение и e2e
M-5 rejected: хаб запускается из checkout, wheel не используется —

Risks / Trade-offs

  • Перегенерация эталонов (T5.1) может показать расхождение порта с links.html в layout.clamp → это дефект порта, чинится в коде, эталон не подгоняется.
  • Стабилизация пропсов Card (T11.1) затрагивает перетаскивание → e2e board/geometry обязательны в задаче.
  • Восстановление прокрутки до отрисовки высот даст прыжок → восстанавливать после первой отрисовки с данными.

Decisions made without discussion

Нумерация R-n — заново в пределах этой фичи.

  • R-1 (решение 1), 15.10.2026. Прокрутка хранится в переменной модуля, не в sessionStorage: переживает переходы внутри вкладки, но не перезагрузку. Обоснование: владелец согласился на «помнить, пока открыта вкладка» в смысле переходов; перезагрузка — новая сессия доски. Цена ошибки: если нужна и после F5 — заменить на sessionStorage, одна строка.
  • R-2 (решение 4), 15.10.2026. Тип пакета определяется дополнительным GET /batches/{id} по кандидату, а не изменением GET /batches в хабе. Обоснование: API хаба в этом change не меняется (Non-Goals web-app). Цена: один лишний запрос при найденном кандидате.
  • R-3 (решение 5), 15.10.2026. Запоздавший 401 со старым токеном молча игнорируется. Обоснование: новый токен уже проверен GET /settings. Цена: если новый токен тоже станет неверным, экран входа появится на следующем запросе.
  • R-4 (решение 7), 15.10.2026. Значение фильтра вне вариантов подписывается «(нет на доске)». Цена — текст подписи.
  • R-5 (решение 11), 15.10.2026. O-1 закрывается overrides, а не обновлением прямых зависимостей. Обоснование: уязвимость только в транзитивной dev-зависимости; обновление vite/vitest шире задачи. Цена: overrides надо снять, когда прямые зависимости подтянут 1.2.2 сами.
  • R-6 (решение 12), 15.10.2026. T5.1 закрывается новым сценарием saved-short с ручными высотами, а не новыми задачами в фикстуре: обрезку заголовка в генераторе определяет только ручная высота (заглушка DOM меряет все карточки одинаково). Цена: длинные темы на реальной вёрстке проверяет e2e, не эталон.
  • R-7 (M-5), 15.10.2026. M-5 rejected — показано владельцу в сводке понимания до утверждения разделов, возражений не было.
  • R-8, 15.10.2026. Коммит 2024a66 (Task 1) с трейлером Claude Haiku 5.5 вместо Opus 5.5 не переписывается: он уже запушен, amend потребовал бы force-push в ветку. Обоснование: трейлер — метаданные, поведение не затронуто. Цена ошибки: в истории ветки один коммит с другим именем модели; при желании владельца правится rebase перед merge. Следующим исполнителям правило трейлера выписано в контракт явно (O-12).
  • R-9, 15.10.2026. T15.1 закрыт утверждением some(h !== 120) вместо new Set(heights).size > 1: на фикстуре hub-basic все 5 карточек имеют высоту 152px, разнообразия высот нет, требование плана невыполнимо. Утверждения fits и кратности 8 оставлены. Обоснование: тавтологичное сравнение с самой собой убрано, проверка осталась содержательной. Цена ошибки: e2e не поймает регресс, при котором все карточки получают одинаковую, но не 120px, высоту; полноценная проверка требует карточки с другим содержимым в фикстуре тестового хаба.
  • R-10, 15.10.2026. Провал ручной проверки прокрутки (Task 8) принят как артефакт сценария, а не дефект продукта: реальный locator.click() по карточке вне окна прокручивает страницу сам, и это законное scroll-событие. В продукте добавлена защита isConnected (запоздавшее scroll после ухода с доски не затирает запомненное), e2e M-3 переписан на клик по видимой карточке без ожидания кадров. Обоснование: воспроизвести потерю прокрутки у пользователя, кликающего по видимой карточке, не удалось ни в e2e, ни вручную. Цена ошибки: если дефект есть у реальных пользователей (например, в браузере с иной раскладкой событий scroll), прокрутка после возврата будет 0; чинится сохранением в cleanup useLayoutEffect (до снятия DOM).
  • R-11, 15.10.2026. O-14 (зависание тестового хаба в e2e) — не дефект приложения, а взаимная блокировка в unix-VFS SQLite 3.51.1 (исправлена в 3.51.2): стеки снятого зависшего хаба — все потоки запросов в sqlite3.connect (findReusableFd) и один в close (sqlite3WalClose → unixLock), ждут мьютексы друг друга. SQLite 3.51.1 — у Python miniconda, на котором стоял .venv (и у живого хаба). Хаб переведён на Python 3.13 от uv (python-build-standalone, SQLite 3.50.4): .python-version = 3.13, [tool.uv] python-preference = "only-managed"; тест test_sqlite_without_unix_vfs_deadlock не даёт вернуться на 3.51.0–3.51.1. Проверка: на старом окружении хаб завис на 3-м и 5-м прогонах e2e, на новом — 20 прогонов подряд без зависаний (и 20 — на Python 3.14 с SQLite 3.53.4). Python 3.14 не взят: в нём Path.is_dir() при отказе доступа возвращает False вместо исключения, и реле пишет «папки нет» вместо «нет доступа» (падает test_permission_denied_warns_once_then_works). Обоснование: правка в границах #29, потому что без неё не выполняется условие I-1 (10 прогонов e2e подряд); код хаба не меняется. Цена ошибки: живой хаб при первом запуске после merge сам пересоздаёт .venv на Python от uv (нужен интернет для загрузки, ~1 мин); если что-то пойдёт не так — убрать [tool.uv] и .python-version, uv sync, перезапуск.