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

107 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
`layout.BuildLinks` — чистая функция от плана: строит целевые пути, проверяет
выход за библиотеку и не трогает диск. Длину имени она не проверяет, поэтому
слишком длинное название доезжает до `Apply`, где `os.MkdirAll` или `os.Link`
получают от ядра `ENAMETOOLONG`. `worker.linkPlan` разбирает исход `Apply` двумя
ветками: `layout.ErrCollision``review`, всё остальное → `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`:
если суффиксы вырастут, он покраснеет раньше, чем задача упрётся в тупик.