Files
jellybit/openspec/changes/archive/2026-08-10-long-title-to-review/design.md
T
av b9f0929d0c layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
2026-08-10 12:16:44 +03:00

9.7 KiB
Raw Blame History

Context

layout.BuildLinks — чистая функция от плана: строит целевые пути, проверяет выход за библиотеку и не трогает диск. Длину имени она не проверяет, поэтому слишком длинное название доезжает до Apply, где os.MkdirAll или os.Link получают от ядра ENAMETOOLONG. worker.linkPlan разбирает исход Apply двумя ветками: layout.ErrCollisionreview, всё остальное → failed с текстом ошибки в error_msg. Системная ошибка попадает во вторую.

Путь «отказ построения → review» в проекте уже есть: ошибка BuildLinks уводит задачу в review с кодом build (worker/review.go). То есть чинить надо не маршрут, а место проверки и различимость причины.

Предел длины на ext4/xfs/btrfs — 255 байт на компонент (NAME_MAX), а не символов: кириллическое название в UTF-8 тратит по два байта на букву и упирается вдвое раньше, чем кажется по числу знаков.

Goals / Non-Goals

Goals:

  • Непомещающееся имя даёт review с доменной причиной, а не failed с текстом ядра.
  • Отказ целиком: ни каталога, ни ссылки. Проверка стоит до первой операции с ФС.
  • Причина различима кодом — по ней видно, чем этот вход в review отличается от коллизии и от прочих отказов построения.

Non-Goals:

  • Не обрезаем и не переименовываем название сами. Это решение человека, и подсказка названия на ревью для него уже есть. Автоматическая обрезка дала бы папку, которую правило сходимости потом унаследует навсегда.
  • Не выясняем предел у ядра (pathconf(_PC_NAME_MAX), пробная запись). Проверка обязана отвечать до обращения к диску, а на разных подкаталогах ответы могут различаться.
  • Не проверяем предел длины пути целиком (PATH_MAX, 4096 байт). Корни библиотек задаёт оператор, глубина у нас фиксированная (папка тайтла + папка сезона + файл), и трёх компонентов по 255 байт до 4096 не хватает.

Decisions

Решение 1. Проверка живёт в BuildLinks, по компонентам относительного пути. BuildLinks уже единственное место, где целевой путь собирается целиком и проверяется на выход за библиотеку, — вторая проверка того же пути встаёт рядом с первой. Проверяются компоненты под корнем библиотеки, а не весь путь: корни задаёт оператор, и жаловаться на его каталоги раскладка не вправе.

Решение 2. Предел — константа 255 байт с объяснением, откуда взята. NAME_MAX у ext4, xfs и btrfs — 255 байт; это предел самой распространённой раскладки, а не универсальный закон. Значение стоит именованной константой в internal/layout с комментарием, называющим источник. В конфиг не выносится: настройка, которую никто не знает, как выставить, — это не гибкость.

Решение 3. Своя ошибка layout.ErrNameTooLong, обёрнутая как остальные. errors.Is по ней ловит случай на любой глубине обёртки — так же, как ErrCollision. Текст ошибки называет компонент и его длину в байтах: без числа человек не поймёт, насколько сокращать.

Решение 4. Свой код причины name_too_long рядом с collision. Ошибка BuildLinks сегодня даёт код build — он верен, но объединяет непомещающееся имя с пустым названием и серией без номера. Код причины в этом проекте — корреляционный ключ шага, и слепить три разных отказа в один значит потерять возможность отличить чинимое подсказкой от нечинимого. Ветка встаёт в linkPlan перед общей веткой build.

Решение 5. Проверка длины идёт после проверки выхода за библиотеку. Путь, вылезший из песочницы, — это critical; сообщить о нём длиной имени значило бы скрыть находку безопасности за косметической причиной. Порядок проверок в цикле сохраняет приоритет.

Решение 6. Превью получает проверку даром — и это довод за BuildLinks. Оба предпросмотра ревью (карточка и строка источника) строят пути тем же BuildLinks (worker/review.go), поэтому вердикт на показе и вердикт на применении совпадают по устройству, а не по договорённости. Цена — видимая: непомещающееся имя даёт пустой предпросмотр, а панель действий при пустом предпросмотре прячет «Применить» и печатает общий текст «Подтверди источник». Баннер причины над карточкой при этом верен. Поведение не новое — так уже ведут себя все отказы построения плана, — но это изменение делает его достижимым на штатном входе.

Risks / Trade-offs

  • Предел зашит и может не совпасть с реальной ФС. На ФС с меньшим NAME_MAX (например, некоторые зашифрованные) имя пройдёт проверку и упрётся в ядро — тот же failed, что сегодня, но уже только на редкой раскладке. На ФС с бо́льшим пределом мы отказываем строже, чем нужно. Обе стороны названы в комментарии константы; лечение — правка константы, а не настройка.
  • Причина различима, но не самообъясняема. Человек видит «имя не помещается» и длину — но не видит, на сколько байт сокращать после provider-тега и суффикса серии. Считать это за него — отдельная работа, здесь она не делается.
  • Изменение сдвигает часть нынешних failed в review. Это и есть цель, но задачи, лежащие в failed с этой ошибкой прямо сейчас, сами туда не переедут и сверкой не подхватятся: из failed их выводит только retry, а он тянет полный цикл через downloading и распознавание. Обещания самоизлечения здесь нет.
  • Унаследованная база отказа по длине не даёт, и это арифметика, а не везение. На чекпоинте эта строка стояла как признанный тупик: база берётся с диска, подсказка её не укорачивает, задача циклится. Попытка написать тест показала, что случай недостижим. Папка-якорь лежит на диске, значит её имя уже не длиннее предела; хвост имени файла — " S02E01" плюс расширение, 11 байт — не длиннее хвоста имени папки, " [" плюс provider-тег плюс "]", минимум 11 байт (tmdbid-1). Язык и флаги субтитров и двойные серии в layout.PlanFile из распознавания не приходят вовсе (toLayoutPlan их не заполняет), поэтому длиннее не станет. Свойство держит тест TestApply_InheritedBaseAtLimitStillFits: если суффиксы вырастут, он покраснеет раньше, чем задача упрётся в тупик.