layout: непомещающееся целевое имя уводит задачу в review вместо failed

- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
This commit is contained in:
av
2026-08-10 12:16:44 +03:00
parent 1710e5a9d5
commit b9f0929d0c
31 changed files with 1511 additions and 17 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-10
@@ -0,0 +1,106 @@
## 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`:
если суффиксы вырастут, он покраснеет раньше, чем задача упрётся в тупик.
@@ -0,0 +1,47 @@
## Why
Название бывает длиннее, чем файловая система разрешает назвать файл. Сегодня
раскладка узнаёт об этом от ядра, уже начав работу: задача падает в `failed`, а
владелец видит вместо карточки ревью английскую строку `file name too long`.
Состояние `failed` терминальное — починить случай нечем, хотя чинится он одной
подсказкой на ревью, ровно как занятый целевой путь.
## What Changes
- Раскладка проверяет длину имени **до** первой операции с файловой системой:
каждый компонент целевого пути (папка тайтла, папка сезона, имя файла) обязан
помещаться в предел, который держат распространённые файловые системы Linux.
- Не помещается — раскладка отказывается целиком, не создав ни одного каталога и
ни одной ссылки, и задача уходит в `review` со своей причиной («имя не
помещается»), а не в `failed` с текстом системной ошибки.
- Владелец правит название подсказкой на ревью и применяет заново — путь тот же,
что при коллизии цели.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `file-layout`: новое требование о непомещающемся целевом имени — проверка до
файловых операций, отказ целиком, уход в `review` с доменной причиной.
- `review`: панель действий при пустом предпросмотре печатает записанную причину
вместо общего «Подтверди источник». Пустой предпросмотр наступает от коллизии,
от непомещающегося имени и от невалидного плана — текст один на всех и в трёх
случаях из четырёх неверен.
## Impact
- `internal/layout` — проверка длины компонентов в `BuildLinks` (чистая функция,
до `mkdir`/`link`) и своя доменная ошибка вместо системной.
- `internal/worker` — ветка перевода задачи в `review` по этой ошибке, рядом с
веткой коллизии цели; свой код причины для корреляции.
- Веб-UI и Telegram — панель действий и карточка ревью называют причину, по
которой плана нет; причина считается на показе, поэтому относится к текущему
плану. Ревью показало, что взять её из записанного поля недостаточно: на самом
частом входе оно пусто.
- `internal/httpapi` — трансляция новой доменной ошибки в конфликт, а не в сбой
сервера; `internal/tgbot` — своя ветка ответа, как у коллизии.
- Внешних зависимостей, схемы БД и конфига изменение не трогает.
@@ -0,0 +1,161 @@
# Ревью изменения `long-title-to-review` — триаж
Отчёт агента `review-triage`. Записан оркестратором: харнесс блокирует запись
файлов подагентами.
## Сводка
- **Размер:** 9 изменённых файлов (+180/−5) плюс новый
`internal/layout/name_length_test.go`. Продуктового кода ~47 строк.
- **Метка:** `medium`, назначена человеком; разметчик дал `small`. Режим — по графу.
- **Сигнал о заниженной метке:** от `review-code` — «`medium` адекватна, `small`
был бы занижен: дифф трогает четыре слоя». Триаж поддержал основанием, которого
сам сигнал не назвал: код причины `name_too_long` оседает в
`downloads.error_code` и обратной правкой после мерджа не откатывается.
- **Гейт:** зелёный целиком, подтверждён `autotests` независимо; ни одного `SKIP`,
`-race` реально прогонялся.
- **Находок на вход:** 14 плюс 1 promote-кандидат. **Осталось:** 5 (2 блокируют,
3 к исправлению), 3 понижены в гипотезы, 2 promote.
### План разметки с исходом
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/` + дельты | разбор | specs | закрыта, 3 находки |
| autotests | `CLAUDE.md` → «Гейт» | — | autotests | закрыта, находок нет |
| conventions | `docs/conventions/` | разбор | code | закрыта, 5 находок (склеены в 3) |
| architecture | `docs/architecture.md` | разбор | basics | закрыта, 1 находка |
| security | `docs/security.md` | разбор | basics | закрыта, находок нет |
| operations | `docs/architecture.md` → «Эксплуатация» | разбор | basics | закрыта, 3 находки |
| темы проекта | — | — | — | своих тем нет |
Тем без отчёта нет.
## Блокирует мердж
### 1. На самом вероятном входе экран ревью не называет причину и теряет «Применить»
- Файл: `internal/worker/review.go:1085-1095`, `:240`,
`web/templates/partials/review_main.html:79`
- Severity: major. Confidence: high.
- Оракул: два дерева (`git archive HEAD` против того же среза с наложенным
`git diff HEAD`), один вход — задача в `review` без записанной причины,
название `strings.Repeat("ы", 200)`:
```
HEAD: preview_links=2 (HasLinks=true) → «Применить» показана
Apply → failed, msg=970 байт "…file name too long"
рабочее дерево: preview_links=0 (HasLinks=false) → формы /apply нет
StateError="" → «Подтверди источник…»
```
- Последствие: путь «распознавание без подтверждённого матча → `review`» —
штатный и самый частый вход в ревью. На нём экран советует подтвердить
источник, который ни при чём. Изменение, чья цель «человек узнаёт причину», на
этом входе делает диагностируемость хуже, чем была.
- **Действие: развилка.** Сюда же сняты два спутника той же причины: текст
печатается дважды (баннер + панель) и после смены источника в панели остаётся
причина от плана, которого больше нет.
- Найдено проходами: `specs`, `basics`; склеено триажем.
**Отработано:** причина пустого предпросмотра считается на показе и приезжает
отдельным полем (`ReviewData.PreviewError`), панель предпочитает её записанной.
Человек выбрал вариант (а) на повторном чекпоинте.
### 2. Карточка Telegram в `review` не называет причину вовсе
- Файл: `internal/tgbot/render.go:70-95`, `internal/tgbot/bot.go:396-415`
- Severity: major. Confidence: high.
- Оракул: рендер карточки на рабочем дереве, задача в `review` с записанной
причиной `name_too_long` и пустым предпросмотром — упоминает причину `false`,
кнопки «Применить» нет. Контроль: та же причина в состоянии `failed` (исход до
изменения) печатала текст и давала кнопку «Повтор».
- Последствие: владелец видит «Нужно подтверждение» без плана, без кнопки и без
слова о длине имени. `proposal.md` обещал обратное; `docs/architecture.md`
«Единые точки» называет обе границы трансляции, обновлена была одна.
- **Действие: развилка.**
**Отработано:** карточка печатает причину для любого случая (вариант (а)), плюс
своя ветка ответа в колбэке, как у коллизии.
## Стоит исправить сейчас
### 3. Штатный отказ краснит логи как сбой, и три предписанных реестра о новой ветке не знают
- Файл: `internal/worker/worker.go:962-968`, `docs/conventions/errors.md:81-97`,
`docs/database.md:210-214`
- Severity: minor. Confidence: high.
- Оракул: прогон ручного `Apply` с логгером Debug — `level=ERROR msg="command
failed"`. `docs/conventions/logging.md:154` относит штатный конфликт к `DEBUG`
и ставит `layout.ErrCollision` поимённо.
- **Действие: инлайн.** **Отработано** целиком: `logCmd`, таблица `errors.md`,
блок констант `database.md`, клауза 409 в дельте `review`.
### 4. Текст причины несёт непомещающееся имя целиком — 494 байта
- Файл: `internal/layout/name.go:29-31`
- Severity: minor. Confidence: high.
- Оракул: `Apply` с названием `strings.Repeat("ы", 200)` → `msg_len_bytes=494`.
- **Действие: инлайн.** **Отработано:** имя усекается серединой (`shorten`, 40
рун), точная длина остаётся числом.
### 5. Два сценария дельты не проверены ничем; комментарий у теста границы описывает не тот вход
- Файл: дельта `file-layout`, `internal/layout/name_length_test.go:109-113`
- Severity: minor. Confidence: high.
- **Действие: инлайн.** **Отработано, и по одному из сценариев — не так, как
предлагал триаж.** Попытка написать тест на «унаследованную базу» показала, что
случай **недостижим**: папка-якорь лежит на диске и потому ≤ предела, а хвост
имени файла (11 байт) не длиннее хвоста имени папки (≥ 11 байт). Сценарий
переписан на верное утверждение, свойство закреплено тестом
`TestApply_InheritedBaseAtLimitStillFits`. Комментарий у границы исправлен,
кириллическая граница добита.
## Гипотезы без доказательства
- Тупик с унаследованной базой не назван в тексте причины (`basics`, B-4).
**Снято разбором:** случай недостижим, см. находку 5.
- Рубрика R6 в `tasks.md` противоречит сценарию S6 дельты. **Снято:** после
переписывания сценария противоречия нет.
- Панель печатает любой `error_msg` без разбора кода, включая сырые тексты ошибок
хранилища. Не отработано: тот же текст уже печатал баннер до изменения, нового
канала не появилось.
## Promote candidates
- **Язык текстов sentinel-ошибок:** русский у тех, что уходят на операторскую
поверхность, английский у прочих; правила в `docs/conventions/errors.md` нет.
- **Внешний текст в персистентной диагностике — только усечённым.** В проекте
есть и `shorten`, и `naming.truncate`, но правила «то, что уезжает в
`error_msg`, усекается на границе» нет; отсюда находка 4.
## Границы покрытия
**Что запускалось.** Метка `medium`, режим по графу: `autotests`, `specs`,
`code`, `basics`, `triage`. Ревью дизайна (`specs` + `rubric`) отработало
отдельным чекпоинтом до кода.
**Что не запускалось и почему.** Всё, что требует запуска — построенные пути
атаки, замеры под нагрузкой, эксплуатационный постмортем, — даёт только метка
`large`. Живые qBittorrent, LLM, метабазы и Telegram не трогались; рабочая БД не
трогалась.
**Чего не мог каждый проход.** `autotests` — отличить исполнение строки от её
проверки. `specs` — судить о качестве распознавания. `basics` на `medium` —
строить пути и снимать замеры. `code` — сверять с руководствами по стилю языка.
Триаж — находить новое: он работает с чужими выводами.
**Потолки проходов.** Ни один проход не сообщил ни своего потолка, ни числа
находок за срезом. Это находка о прогоне: по молчанию нельзя отличить «показал
всё» от «показал верхушку».
**Не проверялось никем.** Решения проекта (`docs/adr/`) и записанные наблюдения
(`docs/research/`) — процессные документы, прогон их не открывает; расхождение с
записанным решением ловит `av-dev-docs:healthcheck`. Поимённая сверка с
руководствами по стилю Go никем не задавалась. Альтернативной реализации, с
которой можно сдиффить решения, у конвейера нет.
**Осталось на человеке.** История инцидентов на umbar; поведение SQLite под
реальным объёмом; завязка внешних потребителей на текущее поведение; суждение
«этой функциональности не должно существовать»; качество распознавания;
идиоматичность Go (проход упразднён 2026-08-04).
@@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Непомещающееся целевое имя уходит в review
Система SHALL отклонять раскладку целиком, если хотя бы один компонент целевого
пути (папка тайтла, папка сезона, имя файла с расширением и суффиксами) длиннее
предела, и SHALL переводить задачу в `review` с доменной причиной, а не в
`failed`. Проверка SHALL выполняться до первой операции с файловой системой: ни
каталога, ни ссылки при отказе не создаётся. Причина SHALL нести собственный код,
отличный и от кода коллизии, и от общего кода отказа построения плана, а
человекочитаемый текст SHALL называть непомещающийся компонент и НЕ SHALL
содержать текста системной ошибки.
Предел SHALL меряться в **байтах** UTF-8-представления имени, а не в символах:
кириллическое название упирается вдвое раньше латинского той же длины в знаках.
Величина — **255 байт** (`NAME_MAX` у ext4/xfs/btrfs); она фиксирована и у ядра не
выясняется, потому что раскладка обязана отказать до обращения к диску. На
файловой системе с меньшим пределом остаётся сегодняшний исход — отказ ядра и
`failed`; это осознанный остаток, а не пробел.
Проверка длины SHALL выполняться **после** проверки нахождения пути под корнем
библиотеки: путь, вышедший за песочницу, SHALL отклоняться как выход за
библиотеку, иначе находка безопасности спряталась бы за косметической причиной.
Самостоятельно обрезать или переименовывать название система НЕ SHALL — это
решение человека.
#### Scenario: Слишком длинное имя файла уходит в review
- **GIVEN** распознавание даёт название, из которого имя целевого файла
складывается длиннее 255 байт
- **WHEN** выполняется раскладка
- **THEN** задача переходит в `review` с причиной «имя не помещается», ни один
каталог и ни одна ссылка не созданы, а текст системной ошибки человеку не
показан
#### Scenario: Предел меряется в байтах
- **GIVEN** два названия одинаковой длины в знаках — латинское и кириллическое
- **WHEN** строятся целевые пути
- **THEN** кириллическое отклоняется вдвое раньше латинского, а граница
проходит между 255 и 256 байтами
#### Scenario: Слишком длинное имя папки тайтла уходит в review
- **GIVEN** название укладывается в имя файла, но папка тайтла с годом и
provider-тегом длиннее предела
- **WHEN** выполняется раскладка
- **THEN** задача переходит в `review` с той же причиной, каталог тайтла не
создан
#### Scenario: Выход за библиотеку важнее длины
- **GIVEN** целевой путь одновременно выходит за корень библиотеки и длиннее
предела
- **WHEN** строятся целевые пути
- **THEN** отказ называет выход за библиотеку, а не длину имени
#### Scenario: Подсказка человека чинит случай
- **GIVEN** задача в `review` с причиной «имя не помещается»
- **WHEN** человек задаёт название короче и применяет раскладку заново
- **THEN** раскладка проходит штатно и задача переходит в `done`
#### Scenario: Унаследованная база в предел помещается всегда
- **GIVEN** база имени унаследована от живой папки-якоря по правилу сходимости
- **WHEN** строятся имена файлов внутри этой папки
- **THEN** они помещаются в предел, потому что папка-якорь лежит на диске и уже
не длиннее предела, а хвост имени файла не длиннее хвоста имени папки
@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: Панель действий при пустом предпросмотре называет причину
Панель действий SHALL называть причину, когда предпросмотр раскладки пуст, а не
печатать общее «Подтверди источник, чтобы получить превью раскладки». Первой
SHALL идти причина, посчитанная **на показе** — отказ построения этого
предпросмотра: она относится к текущему эффективному плану, тогда как записанная
при последнем переходе после смены источника устаревает, а у задачи, пришедшей в
`review` без записанной причины, её нет вовсе. Записанная причина SHALL
использоваться, когда посчитанной нет. Общий текст SHALL оставаться только там,
где нет ни той, ни другой — источник действительно ещё не подтверждён.
Построение предпросмотра НЕ SHALL двигать состояние задачи: причина считается на
чтении и наружу отдаётся значением, а не записью.
Те же две причины в том же порядке SHALL показываться и в карточке Telegram,
когда плана в ней нет: обе поверхности ревью объясняют отсутствие команды
«Применить» одинаково. Команда, упершаяся в непомещающееся имя, SHALL отвечать
конфликтом, а не сбоем сервера.
Требование не трогает доступность команды «Применить»: она по-прежнему следует
наличию предпросмотра. Речь о том, что человеку говорят, когда предпросмотра нет:
пустой предпросмотр наступает и от коллизии путей, и от непомещающегося имени, и
от невалидного плана, а текст сегодня во всех случаях один и в трёх из четырёх
неверен.
#### Scenario: Непомещающееся имя названо в панели действий
- **GIVEN** загрузка в `review` с причиной «имя не помещается», источник
подтверждён, предпросмотр пуст
- **WHEN** человек открывает экран ревью
- **THEN** панель действий печатает причину отказа, а не предложение подтвердить
источник, и команда «Применить» недоступна
#### Scenario: Причина не записана в состоянии — считается на показе
- **GIVEN** загрузка пришла в `review` без записанной причины (нет матча), а её
название не помещается в имя файла
- **WHEN** человек открывает экран ревью
- **THEN** панель действий называет длину имени, хотя в состоянии причины нет, и
состояние при этом не меняется
#### Scenario: После смены источника показывается свежая причина
- **GIVEN** загрузка в `review` с записанной причиной «имя не помещается», и
человек выбрал другой источник
- **WHEN** экран перестраивается
- **THEN** показывается причина, посчитанная для нового плана, а не записанная
при прошлом переходе
#### Scenario: Карточка Telegram называет ту же причину
- **GIVEN** загрузка в `review`, плана в карточке нет
- **WHEN** карточка отправляется или обновляется
- **THEN** в ней есть строка с причиной, по которой план не построился
#### Scenario: Источник не подтверждён — текст прежний
- **GIVEN** загрузка в `review` без записанной причины, без посчитанной и без
предпросмотра
- **WHEN** человек открывает экран ревью
- **THEN** панель действий печатает общее предложение подтвердить источник
@@ -0,0 +1,115 @@
# Задачи
## 1. Код
- [x] 1.1 В `internal/layout` завести константу предела длины компонента (255
байт) с комментарием, называющим источник значения (`NAME_MAX` у
ext4/xfs/btrfs) и обе стороны риска.
- [x] 1.2 Завести `layout.ErrNameTooLong` рядом с `ErrCollision`; текст ошибки
называет непомещающийся компонент и его длину в байтах.
- [x] 1.3 В `BuildLinks` проверять длину компонентов целевого пути **под корнем
библиотеки** — после проверки `underRoot`, до возврата ссылки. Отказ
целиком (`return nil, err`), как для прочей невалидности плана.
- [x] 1.4 В `worker.linkPlan` добавить ветку `errors.Is(err, layout.ErrNameTooLong)`
`review` с новым кодом причины `name_too_long`, перед общей веткой
`reasonBuild`. Код завести в своде причин рядом с `reasonCollision`.
- [x] 1.5 Панель действий ревью при пустом предпросмотре печатает записанную
причину загрузки, а при её отсутствии — прежний общий текст
(`web/templates/partials/review_main.html`, поле вью в
`internal/httpapi/review.go`).
## 2. Тесты
- [x] 2.1 `BuildLinks` с названием, дающим имя файла длиннее предела, возвращает
ошибку, обёртывающую `ErrNameTooLong`; ни одного каталога на диске не
появилось.
- [x] 2.2 То же для папки тайтла: название укладывается в имя файла, но папка с
годом и provider-тегом длиннее предела.
- [x] 2.3 Проверка считает **байты**, а не руны: кириллическое название той же
длины в знаках упирается вдвое раньше латинского. Тест на границе:
помещается ровно 255 байт, 256 — нет.
- [x] 2.4 Путь, выходящий за библиотеку, и одновременно слишком длинное имя дают
ошибку **выхода за библиотеку**, а не длины (приоритет проверок).
- [x] 2.5 `worker`: раскладка с непомещающимся именем уводит задачу в `review` с
кодом `name_too_long`, каталог цели пуст, в `error_msg` нет текста
системной ошибки.
- [x] 2.6 Раскладка нормальной длины ведёт себя как прежде (регрессия на
существующих тестах пакета).
- [x] 2.7 Название не обрезается и не переименовывается: на непомещающемся входе
ни одной ссылки не возвращено, а не возвращена усечённая.
- [x] 2.8 Мерится финальный компонент: имя, проходящее впритык, не растёт ни на
одном шаге сборки — суффикс субтитров (`.ru.forced`) на границе отклоняется.
- [x] 2.9 Вырожденные входы не роняют проверку: пустая строка, одни пробелы,
невалидный UTF-8, очень длинная строка.
- [x] 2.10 Панель действий: с записанной причиной и пустым предпросмотром
печатается причина; без причины — прежний общий текст.
## 4. Правки по ревью кода
- [x] 4.1 Причина пустого предпросмотра считается **на показе**
(`ReviewData.PreviewError`) и предпочитается записанной: задача без
записанной причины иначе оставляет экран без объяснения, а после смены
источника записанная устаревает.
- [x] 4.2 Карточка Telegram называет ту же причину, когда плана в ней нет; в
колбэке — своя ветка ответа для `ErrNameTooLong`, как у коллизии.
- [x] 4.3 `logCmd`: `ErrNameTooLong` — штатный отказ, `DEBUG`, а не `ERROR`.
- [x] 4.4 Реестры: строка в таблицу маппинга `docs/conventions/errors.md`,
константа предела в блок числовых пределов `docs/database.md`, клауза про
409 — в дельту `review`.
- [x] 4.5 Текст причины несёт имя усечённым (`shorten`, 40 рун), точную длину —
числом: имя целиком занимало пол-экрана и оседало в БД.
- [x] 4.6 Сценарий «унаследованная база» переписан: случай недостижим — папка-якорь
≤ предела, а хвост имени файла не длиннее хвоста имени папки. Свойство
закреплено тестом.
- [x] 4.7 Тесты: `PreviewError` при пустой причине состояния; подсказка доводит до
`done`; унаследованная база в предел помещается; карточка Telegram и колбэк;
`shorten`; кириллическая граница добита, неверный комментарий исправлен.
## 3. Гейт и спеки
- [x] 3.1 `openspec validate --strict long-title-to-review` — зелёный.
- [x] 3.2 `task gate` — зелёный, покрытие изменённых строк полное.
## Критерии приёмки (из постановки)
- **A1.** Раздача с именем в 250 байт уходит в `review` с доменной причиной, а не
в `failed` с текстом ядра. **Оракул:** тест на временном каталоге прогона,
воспроизводящий случай из «Воспроизведения», — проверяет состояние задачи и код
причины.
- **A2.** Ни одна ссылка не создана до отказа: частичной раскладки батча не
остаётся. **Оракул:** тот же тест — проверяет, что каталог цели пуст.
- **A3.** Причина, показанная человеку, не содержит текста системной ошибки.
**Оракул:** утверждение теста на текст причины плюс конвенция
`docs/conventions/errors.md` — перевод доменной ошибки на внешней границе.
- **A4.** Проверка длины стоит **до** первой операции с файловой системой.
**Оракул:** чтение диффа на ревью; тест на пустоту каталога цели его
подтверждает.
## Приёмочные критерии из рубрики (ревью дизайна)
Свойства, порождённые до чтения кода. Каждое проверяется тестом или чтением
диффа; те, что уже покрыты задачами выше, названы ссылкой на них.
- **R1.** Единица предела — байты UTF-8, не руны (задача 2.3).
- **R2.** Отказ целиком, без самостоятельного усечения (задачи 2.1, 2.7).
- **R3.** Мерится ровно та строка, что уйдёт в `mkdir`/`link`, — после
санитизации и всех суффиксов (задача 2.8).
- **R4.** Приоритет над проверкой песочницы (задача 2.4).
- **R5.** Исход отказа определён и починим человеком; случай, где подсказка не
помогает, назван спекой отдельным сценарием, а не умолчанием.
- **R6.** Вердикт — функция от уже зафиксированного, а не от порядка загрузок.
Держится арифметикой: унаследованная от живой папки база отказа по длине дать
не может, поэтому наличие якоря на исход не влияет
(`TestApply_InheritedBaseAtLimitStillFits`).
- **R7.** Превью и применение дают одинаковый вердикт по одному входу — потому
что зовут одну функцию, а не потому, что так договорились.
- **R8.** Константа предела несёт провенанс и обе стороны ошибки (задача 1.1).
- **R9.** Предел пути целиком (`PATH_MAX`) назван и исключён с посчитанной
причиной — в `design.md`, Non-Goals.
- **R10.** Детерминизм и чистота: проверка не ходит на диск, не спрашивает ядро,
ни один вход не даёт паники (задача 2.9).
- **R11.** Отказ наблюдаем один раз и без чужого текста: логирующий чекпоинт
один, текст причины без системной ошибки и без секретов (задача 2.5).
- **R12.** Регрессия: нормальная раскладка, идемпотентный повтор и copy-fallback
ведут себя как прежде (задача 2.6).
+69
View File
@@ -283,3 +283,72 @@ Jellyfin на состояние задачи влиять SHALL NOT — оши
- **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`)
- **THEN** система скан не дёргает
### Requirement: Непомещающееся целевое имя уходит в review
Система SHALL отклонять раскладку целиком, если хотя бы один компонент целевого
пути (папка тайтла, папка сезона, имя файла с расширением и суффиксами) длиннее
предела, и SHALL переводить задачу в `review` с доменной причиной, а не в
`failed`. Проверка SHALL выполняться до первой операции с файловой системой: ни
каталога, ни ссылки при отказе не создаётся. Причина SHALL нести собственный код,
отличный и от кода коллизии, и от общего кода отказа построения плана, а
человекочитаемый текст SHALL называть непомещающийся компонент и НЕ SHALL
содержать текста системной ошибки.
Предел SHALL меряться в **байтах** UTF-8-представления имени, а не в символах:
кириллическое название упирается вдвое раньше латинского той же длины в знаках.
Величина — **255 байт** (`NAME_MAX` у ext4/xfs/btrfs); она фиксирована и у ядра не
выясняется, потому что раскладка обязана отказать до обращения к диску. На
файловой системе с меньшим пределом остаётся сегодняшний исход — отказ ядра и
`failed`; это осознанный остаток, а не пробел.
Проверка длины SHALL выполняться **после** проверки нахождения пути под корнем
библиотеки: путь, вышедший за песочницу, SHALL отклоняться как выход за
библиотеку, иначе находка безопасности спряталась бы за косметической причиной.
Самостоятельно обрезать или переименовывать название система НЕ SHALL — это
решение человека.
#### Scenario: Слишком длинное имя файла уходит в review
- **GIVEN** распознавание даёт название, из которого имя целевого файла
складывается длиннее 255 байт
- **WHEN** выполняется раскладка
- **THEN** задача переходит в `review` с причиной «имя не помещается», ни один
каталог и ни одна ссылка не созданы, а текст системной ошибки человеку не
показан
#### Scenario: Предел меряется в байтах
- **GIVEN** два названия одинаковой длины в знаках — латинское и кириллическое
- **WHEN** строятся целевые пути
- **THEN** кириллическое отклоняется вдвое раньше латинского, а граница
проходит между 255 и 256 байтами
#### Scenario: Слишком длинное имя папки тайтла уходит в review
- **GIVEN** название укладывается в имя файла, но папка тайтла с годом и
provider-тегом длиннее предела
- **WHEN** выполняется раскладка
- **THEN** задача переходит в `review` с той же причиной, каталог тайтла не
создан
#### Scenario: Выход за библиотеку важнее длины
- **GIVEN** целевой путь одновременно выходит за корень библиотеки и длиннее
предела
- **WHEN** строятся целевые пути
- **THEN** отказ называет выход за библиотеку, а не длину имени
#### Scenario: Подсказка человека чинит случай
- **GIVEN** задача в `review` с причиной «имя не помещается»
- **WHEN** человек задаёт название короче и применяет раскладку заново
- **THEN** раскладка проходит штатно и задача переходит в `done`
#### Scenario: Унаследованная база в предел помещается всегда
- **GIVEN** база имени унаследована от живой папки-якоря по правилу сходимости
- **WHEN** строятся имена файлов внутри этой папки
- **THEN** они помещаются в предел, потому что папка-якорь лежит на диске и уже
не длиннее предела, а хвост имени файла не длиннее хвоста имени папки
+62
View File
@@ -413,3 +413,65 @@ htmx-фрагмента (`GET /fragments/downloads/{id}/review`) и по зав
- **THEN** обработчик исполняет то же доменное действие и отвечает редиректом на
`/review/{id}`, поведение без JavaScript не ломается
### Requirement: Панель действий при пустом предпросмотре называет причину
Панель действий SHALL называть причину, когда предпросмотр раскладки пуст, а не
печатать общее «Подтверди источник, чтобы получить превью раскладки». Первой
SHALL идти причина, посчитанная **на показе** — отказ построения этого
предпросмотра: она относится к текущему эффективному плану, тогда как записанная
при последнем переходе после смены источника устаревает, а у задачи, пришедшей в
`review` без записанной причины, её нет вовсе. Записанная причина SHALL
использоваться, когда посчитанной нет. Общий текст SHALL оставаться только там,
где нет ни той, ни другой — источник действительно ещё не подтверждён.
Построение предпросмотра НЕ SHALL двигать состояние задачи: причина считается на
чтении и наружу отдаётся значением, а не записью.
Те же две причины в том же порядке SHALL показываться и в карточке Telegram,
когда плана в ней нет: обе поверхности ревью объясняют отсутствие команды
«Применить» одинаково. Команда, упершаяся в непомещающееся имя, SHALL отвечать
конфликтом, а не сбоем сервера.
Требование не трогает доступность команды «Применить»: она по-прежнему следует
наличию предпросмотра. Речь о том, что человеку говорят, когда предпросмотра нет:
пустой предпросмотр наступает и от коллизии путей, и от непомещающегося имени, и
от невалидного плана, а текст сегодня во всех случаях один и в трёх из четырёх
неверен.
#### Scenario: Непомещающееся имя названо в панели действий
- **GIVEN** загрузка в `review` с причиной «имя не помещается», источник
подтверждён, предпросмотр пуст
- **WHEN** человек открывает экран ревью
- **THEN** панель действий печатает причину отказа, а не предложение подтвердить
источник, и команда «Применить» недоступна
#### Scenario: Причина не записана в состоянии — считается на показе
- **GIVEN** загрузка пришла в `review` без записанной причины (нет матча), а её
название не помещается в имя файла
- **WHEN** человек открывает экран ревью
- **THEN** панель действий называет длину имени, хотя в состоянии причины нет, и
состояние при этом не меняется
#### Scenario: После смены источника показывается свежая причина
- **GIVEN** загрузка в `review` с записанной причиной «имя не помещается», и
человек выбрал другой источник
- **WHEN** экран перестраивается
- **THEN** показывается причина, посчитанная для нового плана, а не записанная
при прошлом переходе
#### Scenario: Карточка Telegram называет ту же причину
- **GIVEN** загрузка в `review`, плана в карточке нет
- **WHEN** карточка отправляется или обновляется
- **THEN** в ней есть строка с причиной, по которой план не построился
#### Scenario: Источник не подтверждён — текст прежний
- **GIVEN** загрузка в `review` без записанной причины, без посчитанной и без
предпросмотра
- **WHEN** человек открывает экран ревью
- **THEN** панель действий печатает общее предложение подтвердить источник