Files
jellybit/docs/review.md
T
av d081ef1d30 ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add
- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
2026-08-06 18:20:12 +03:00

27 KiB
Raw Blame History

Ревью: настройка и журнал

Проектная часть конвейера ревью: чем jellybit отличается от абстрактного Go-сервиса и что здесь уже проскакивало. Устройство самого конвейера (профили, стадии, контракт находок) живёт в скилле, а не здесь.

Как настроен конвейер

Типовые узлы

Рода узлов проекта и проверяемые свойства к каждому. Род, а не инвентарь пакетов: узел, которого ещё нет, но который проект заведёт, включён намеренно.

Клиент внешнего HTTP-сервиса (qbt, llm, metadata, jellyfin, tgbot)

  • у каждого исходящего вызова свой таймаут из конфига, не дефолт транспорта;
  • context доходит до запроса и отменяет его, а не игнорируется;
  • ошибка зависимости отличима от ошибки нашей логики на приёме результата;
  • секреты (пароль, ключ, токен) не попадают ни в лог, ни в текст ошибки;
  • недоступность опциональной зависимости (метабаза, Jellyfin, Telegram) не двигает состояние загрузки и не краснеет ERROR-ом в фоновом цикле.

Тик воркера и переход состояния

  • переход легален по декларативному графу, а не «просто присвоили state»;
  • работа идёт под per-download блокировкой; два транспорта не гонятся;
  • тик идемпотентен: повтор на том же состоянии не порождает второго эффекта;
  • отмена контекста на середине не оставляет полуприменённого состояния;
  • новое промежуточное состояние имеет выход и предохранитель по времени.

Репозиторий store

  • запрос параметризован, время только через store.Now(), id через ident;
  • многошаговое изменение — в одной write-транзакции (_txlock=immediate);
  • инвариант, не выражаемый схемой («одна активная загрузка на infohash»), держится guarded-методом, а не проверкой в вызывающем коде;
  • миграция forward-only и сопровождается правкой database.md.

Операция с файловой системой (layout)

  • целевой путь проверяется после filepath.Clean, на принадлежность библиотеке;
  • существующая цель не перезаписывается ни при каком исходе;
  • под paths.downloads нет ни одной операции записи или удаления;
  • частичный сбой батча оставляет систему в состоянии, из которого повтор доводит начатое или откатывает целиком;
  • удаление снимает только свои ссылки своего батча и не снимает последнюю копию.

Парсер недоверенного входа (magnet, torrent, парсер сообщения бота, разбор ответа LLM)

  • вход враждебный по умолчанию: длина, вложенность, мусорные байты, пустота;
  • разбор не паникует и не аллоцирует по числу из самого входа;
  • невалидный вход даёт доменную ошибку, а не тихий дефолт;
  • результат нормализуется на границе (lowercase hex, trim, ident.Parse).

htmx-хендлер

  • один партиал обслуживает страницу и фрагмент, ветвление по isHTMX;
  • ошибка на htmx-пути — 200 плюс фрагмент, а не 4xx/5xx;
  • страница деградирует без JS;
  • поллинг самозавершается, когда наблюдать больше нечего.

Типовые ложноположительные

Находки, которые здесь выглядят убедительно и всегда неверны.

  • «Веб-UI и REST без авторизации». Принятое решение под сегодняшний периметр — security.md. Дефектом станет только вместе с путём снаружи LAN.
  • «Ошибка на htmx-пути возвращает 200». Так и задумано — conventions/web-ui.md.
  • «Решение auto/review должно опираться на confidence модели». Неверно в форме «вместо матча»: авто только при подтверждённом матче в базе — ADR-2026-06-13-auto-link-requires-db-match. Самооценка LLM плохо откалибрована и поддаётся инъекции. Порог confidence при этом существует дополнительным блокирующим условием поверх матча — его наличие дефектом не является.
  • «Копировать надёжнее, чем хардлинк» / «взять симлинк». Хардлинк — осознанный выбор ради неприкосновенности источника и недублирования диска, ADR-2026-06-13-hardlinks; copy — только фолбэк.
  • «Целочисленный автоинкрементный ключ был бы проще». ULID — требование capability identity; AUTOINCREMENT вдобавок краснит гейт.
  • «Не хватает метрик, трейсинга, health-эндпоинтов по каждой зависимости». «Минимум компонентов» — принцип проекта; глубокий healthcheck заведён задачей и ждёт своей очереди, а не является упущением.
  • «Здесь нужен интерфейс, чтобы это можно было замокать». Единственная реализация за интерфейсом — обычно лишний слой; см. «Честный предел» ниже.
  • «Нет ретрая у вызова в фоновом цикле». Тик повторится сам через poll_interval; ретрай внутри тика чаще вреден.
  • «Оригинальное и локализованное названия дублируются — избыточность». original_title заполняется всегда и при неуверенности дублирует title — это контракт capability recognition, а не недосмотр.

Вопросы к проходам

Форма: <имя прохода>: <вопрос> (<провенанс>). Журнал дефектов пока пуст, поэтому провенанс у всех пунктов — инвариант или ADR, а не пойманный случай; по мере накопления журнала список должен смещаться в сторону реальных промахов.

  • adversary: можно ли, управляя только именами файлов в раздаче и текстом контекста, добиться целевого пути вне paths.movies/series — включая путь через юникод, длину сверх лимита ФС и коллизию после нормализации? (инвариант «целевой путь строго под библиотекой», security.md)
  • adversary: есть ли последовательность команд, после которой снимается последняя копия данных — с учётом superseded-ссылок и гонки со сверкой? (инвариант «источник неприкосновенен»)
  • adversary: что даёт крафт-магнет с чужим или подставным инфохэшем — присоединение к чужой активной загрузке, отравление владения? (открытая задача про идентичность инфохэшей)
  • adversary: где признак «это наше» снимается с одной сущности, а действие применяется к другой — присутствие раздачи в qBittorrent против байтов на диске, запись в БД против файла, инфохэш против содержимого? (журнал, 2026-08-06: уборка своего торрента сносила чужие файлы)
  • ops: что делает эта ветка, когда qBittorrent недоступен несколько минут подряд — сколько ERROR-строк в секунду и меняется ли состояние задач? (задача про ERROR-шторм фоновых циклов)
  • ops: как это ведёт себя при сотне загрузок в базе и десятках тысяч file_link — есть ли запрос без индекса и полный проход по таблице? (задача про масштаб 100/1000, database.md → «Настройки»)
  • ops: что остаётся на диске и в базе, если процесс убит посреди раскладки батча? (состояние linking и его восстановление)
  • code: логирующий чекпоинт один на операцию — или ошибка залогирована и возвращена вверх, где залогирована снова? (conventions/logging.md)
  • code: новое поле конфига появилось в config.example.toml с описанием назначения, диапазона и единиц? (conventions/config.md)
  • specs: не завелось ли поведение, которого спека не заказывала — тихий дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле?
  • architecture: не появился ли второй способ делать то, что уже делается — второе место, где генерится время или id, второй парсер источника, вторая логика целевых имён мимо naming? (architecture.md → «Единые точки проекта»)

Триггеры профиля

Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — standard; миграция схемы и публичный контракт ступень не поднимают: их проверяют проходы, которые в standard и так есть.

Новым понятием или структурной единицей здесь считается (→ wide): новый пакет internal/*; новая capability в openspec/specs/; новый транспорт приёма или уведомлений рядом с REST, веб-UI, ботом и CLI; новый провайдер метабазы за существующим интерфейсом; новое состояние в графе переходов загрузки; перенос ответственности между worker, recognition, layout и store.

Правила идентичности, слияния и разбора живут здесь (→ deep): владение раздачей по инфохэшу и сверка с qBittorrent (ident, internal/store, state-reconciliation); построение целевых путей и санитизация имён (internal/layout, naming); разбор недоверенного входа — bencode, magnet, текст контекста, ответ LLM (internal/torrent, internal/magnet, internal/tgbot/parse.go, разбор ответа модели); выбор кандидата метабазы и слияние его полей с догадкой LLM (internal/metadata, metadata-match); merge-раскладка при повторном добавлении раздачи.

  • «Поведение, видимое снаружи» здесь включает тексты и карточки Telegram — для единственного пользователя это и есть интерфейс.

Место из списка ступень не поднимает — поднимает правило. Перечни выше отвечают «здесь такие правила водятся», а не «любая правка здесь идёт в deep». Ступень поднимает то, что даёт работу новому проходу: заводится ключ сравнения или меняется его состав; у разбора появляется новый вид входа или новая ветка неоднозначности; в слияние добавляется источник или меняется победитель при конфликте.

Отсекающие условия — проверяются до выбора профиля, любое сработавшее держит ступень внизу. Перечень закрытый, каждый пункт проверяется взглядом на дифф и дельта-спеку:

  • дельта-спека называет исход поимённо. Независимая реализация окупается выбором, которого спека не сделала. Если сценарий уже говорит, что даёт вырожденный вход, реализация повторит спеку, и дифф покажет расхождение в форме, а не в решении;
  • новых сценариев в дельта-спеке нет — изменение уточняет уже описанное поведение, а не заказывает новое;
  • правка сообщения, комментария, записи журнала, имени или теста в узле из перечня;
  • сужение уже существующей нормализации без нового вида входа: вход остался тот же, изменился исход на одном его значении.

Ориентир частоты. standard закрывает большинство задач, wide — редкий случай, deep — исключение на крупной функциональности, а не на уборке. Задача типа chore или bugfix, собранная из нитей прошлого ревью, идёт в quick или standard, даже когда трогает файл из перечня deep. Верхняя ступень чаще одной задачи на спринт означает ошибку в критерии, а не спринт из сложных задач.

Недоступно проверке

Не проверит ни один проход — принципиальная граница, по факту промаха не пересматривается.

  • История инцидентов на umbar и то, что уже ломалось в проде.
  • Поведение таблицы SQLite под реальным объёмом и профилем нагрузки: реального профиля нет ни у кого, кроме сервера.
  • Завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее поведение.
  • Качество распознавания как таковое: правильно ли LLM определил фильм — вопрос тюнинга модели и промпта, а не ревью кода. Размеченный корпус, по которому это можно было бы судить числом, решено не собирать (tasks/REJECTED.md, 2026-08-06).
  • Суждение «этой функциональности не должно существовать».

Перестали проверять сознательно — пересматривается первым, как только что-то проскочило.

  • Идиоматичность Go — с 2026-08-04. Проектный проход idiom (поимённая сверка с положениями Effective Go, Go Code Review Comments, стайлгайдов Uber и Google) удалён вместе с проектными копиями агентов при переезде на плагин av-dev-pipeline, который этот проход упразднил. Способные части переселены: эксперимент против поведения библиотеки и драйвера — в ops, «не изобретаем ли то, что уже есть в библиотеке» — в architecture. Различение «идиоматично против распространено» теперь не спрашивает никто. Класс обратимый: портит форму кода, не данные. Пересмотр — задача quality-review-agents.

Журнал дефектов

Запись на каждый воспроизведённый дефект, сразу, а не ретроспективно: со временем теряется не факт, а причина непоймания. Проскочившие — эвал-сет для калибровки конвейера, выборка по пометке.

Форма:

ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]

  • Где: путь:строка либо «конвейер, а не код»
  • Симптом: как обнаружилось, кем и когда
  • Причина: что на самом деле было не так
  • Чем воспроизведён: тест, команда, замер — с числами
  • Почему не поймали: только для проскочивших — какой проход обязан был найти и что ему помешало
  • Что меняем: правило прохода, шаг гейта, конвенция, факт в документе проекта — либо «ничего, цена поимки выше цены дефекта»

Записи

Журнал заведён 2026-07-23 вместе с переработкой конвейера (ADR-2026-07-23-review-pipeline-generative); случаи до этой даты не восстанавливались — восстановленная постфактум причина непоймания недостоверна, а именно она и нужна.

2026-08-06 — уборка своего торрента после отмены сносит чужие файлы [проскочил]

  • Где: internal/worker/worker.go:501-556 — гард :501-509, удаление :550. Норма — openspec/specs/download-tracking/spec.md, требование «Добавление пойманной загрузки в qBittorrent».
  • Симптом: найден проходом adversary на ревью задачи dismiss-marker-lost (2026-08-06), не в эксплуатации. В проде не всплывал.
  • Причина: гард «подтверждённое отсутствие непосредственно перед add» подтверждает отсутствие записи торрента в qBittorrent, но не отсутствие данных на диске. Пользователь, снявший раздачу из qBittorrent с сохранением файлов (download-tracking сама предписывает это как способ восстановления зависшей magnet-раздачи), и подавший тот же торрент заново, получает add, подхватывающий пред-существующие файлы. Отмена в окне между re-read и PromoteCatched даёт torrents/delete с deleteFiles=true по этим файлам. Исключение инварианта «источник неприкосновенен» покрывает «собственный торрент», а признак «своё» подменён на «торрента не было».
  • Чем воспроизведён: тестом на фейковом клиенте qBittorrent во временном каталоге прогона (tmp/, не сохранён): Cancel в окне после add вызывает Delete(hashes, deleteFiles=true) при живом файле под paths.downloads, созданном до add. Тот же путь достижим через Dismiss — гейт Dismiss шире, а уборка срабатывает по состоянию (after.State != catched), а не по команде. Не прогонялся последний шаг — что боевой qBittorrent по deleteFiles=true физически сносит пред-существующий контент: в бой ходить запрещено, отсюда Confidence: medium.
  • Почему не поймали: окно после add разбиралось как гонка (кто успел — отмена или промоушен) и проверялось на «не удалим ли чужой торрент». Вопрос «а если торрента нет, но данные есть» не задавал никто: ни один проход не спрашивал про асимметрию признака владения — признак снимается с одной сущности (запись в qBittorrent), а действие применяется к другой (байты на диске). Враждебный проход до этой задачи на данном коде не гонялся.
  • Что меняем: вопрос adversary в разделе выше дополнен пунктом про асимметрию признака владения. Сам дефект — задачей в беклоге, кандидат critical; спека state-reconciliation в том же изменении перестала утверждать, что уборка «данных пользователя не касается».

2026-08-06 — нормализация имени раздачи сама производила сентинел, который отбрасывала [пойман]

  • Где: internal/torrent/torrent.go, функция displayName (введена изменением ingest-nits). Норма — openspec/specs/ingest/spec.md, требование «Вырожденное имя раздачи не считается именем».
  • Симптом: найден на ревью изменения, до мерджа. В эксплуатации не был.
  • Причина: сравнение с metainfo.NoName стояло до схлопывания пробельного. Имя " - " сравнение не проходило, а oneLine превращал его ровно в -, и вырожденное значение уезжало вниз по потоку всеми тремя путями: строкой названия в контексте распознавания, в source_ref (фолбек на имя присланного файла не срабатывал — строка непуста) и подсказкой вывода отображаемого имени. Против master это регрессия: там стояло strings.TrimSpace(...) перед сравнением с -, и фолбек работал.
  • Чем воспроизведён: двумя независимыми падающими тестами — adversary прогнал приём на входах " - ", "-\n", "\t-", " -" и получил source_ref = "-" вместо Dune.torrent; reimpl принёс свой тест на разбор. В дереве остался табличный TestParseNoNameSentinelDropped.
  • Почему не поймали раньше: ловить было нечему — дефект внесён этим же изменением и пойман тем же прогоном. Отмечено потому, что это эвал-сет наоборот: случай, где верхняя ступень окупилась. Три прохода из семи (specs, adversary, reimpl) нашли его независимо, и двое принесли оракул; проход code (конвенции) и гейт его не видели — порядок двух операций внутри функции не выражается ни правилом линтера, ни конвенцией.
  • Что меняем: ничего в конвейере. Класс «нормализация и сравнение с константой идут в неверном порядке» дешевле ловить тестом на границе разбора, чем правилом; такой тест заведён. Наблюдение о самой библиотеке (что BestName() сентинел не синтезирует — исходное основание нити было неверным) записано в research/torrent-bencode-limits.md.