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,77 @@
# Причина, по которой человек не видит плана, считается на показе, а не читается из состояния
- **Дата:** 2026-08-10
- **Источник:**
[openspec/changes/archive/2026-08-10-long-title-to-review/design.md](../../openspec/changes/archive/2026-08-10-long-title-to-review/design.md),
Решение 6 и раздел `Risks / Trade-offs`; отчёт триажа того же change,
находка 1
## Контекст
Проверка длины целевого имени встала в `layout.BuildLinks` — туда же, где
собираются оба предпросмотра экрана ревью. Это дало даром совпадение показанного
с применённым, но и вторую половину: непомещающееся имя обнуляет предпросмотр, а
без предпросмотра экран прячет команду «Применить».
Первым решением панель действий брала текст из `error_msg` — причины, записанной
при последнем переходе. Ревью показало, что на самом частом входе этого поля
нет вовсе: задача, пришедшая в `review` из-за отсутствия матча, попадает туда с
пустой причиной и до раскладки не доходит. Замер триажа на двух деревьях:
```
до изменения: предпросмотр строится, «Применить» доступна,
применение доводит до failed с текстом ядра
после: предпросмотр пуст, команды нет, причины нет —
экран печатает «Подтверди источник», хотя источник ни при чём
```
То есть изменение, чья цель — «человек узнаёт причину», на этом входе
диагностируемость ухудшало.
## Решение
**Причина отказа считается в момент показа и отдаётся транспорту значением
(`worker.ReviewData.PreviewError`); записанная в состоянии используется только
когда посчитанной нет.**
Цитата из `design.md`, Решение 6:
> Оба предпросмотра ревью (карточка и строка источника) строят пути тем же
> `BuildLinks`, поэтому вердикт на показе и вердикт на применении совпадают по
> устройству, а не по договорённости.
Отсюда следует и обратное: раз вердикт считается на показе, там же считается и
его причина. Посчитанная предпочитается записанной по двум причинам сразу:
записанной может не быть вовсе, а после смены источника она уже про другой план — команды,
меняющие эффективный источник, поля ошибки не чистят.
**Чтение при этом состояние не двигает.** Построение предпросмотра остаётся без
побочных эффектов; причина уходит наружу возвращаемым значением.
Это второй случай одного класса за день. Первый —
[ADR-2026-08-10-sanitize-at-every-entry](ADR-2026-08-10-sanitize-at-every-entry.md):
гарантия, поставленная на запись, не покрывает то, что записано раньше. Здесь она
не покрывает то, что не записано вовсе.
## Рассмотренные варианты
- **Записывать причину в состояние при построении предпросмотра.** Отвергнуто:
чтение начало бы двигать состояние. Запрет уже стоял в коде отдельным
комментарием — предпросмотр не переводит задачу в `review` при рассинхроне
папок, — и заводить исключение ради текста на экране значило бы снять правило.
- **Оставить как есть, записав остаток сценарием спеки.** Отвергнуто на
чекпоинте: регресс диагностируемости дошёл бы до боевого окружения на самом
частом входе.
- **Печатать причину только в баннере состояния, панель не трогать.** Отвергнуто:
баннер показывает записанное и на этом входе пуст ровно так же.
## Цена
Причина живёт в двух местах — записанная в состоянии и посчитанная на показе, — и
порядок между ними держится на ревью, а не на типе. Взамен экран ревью объясняет
отсутствие команды всегда, а не только когда причину успели записать, и
объяснение относится к текущему плану, а не к прошлому.
Побочно: тот же текст может оказаться и в баннере, и в панели, когда записанная
причина совпала с посчитанной. Дубль признан приемлемым — он честен, а
код, который его снимал бы, дороже.
+1
View File
@@ -42,6 +42,7 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — |
| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — | | 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — |
| 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — | | 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — |
| 2026-08-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — | | 2026-08-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — |
+2
View File
@@ -116,6 +116,8 @@
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI | | Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом | | Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки | | Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки |
| Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью |
| Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) |
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) | | Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) | | Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
| Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям | | Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям |
+8 -1
View File
@@ -88,6 +88,7 @@ jellybit — **приложение, а не библиотека**: внешн
| `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки | | `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» | | `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» | | `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
| `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
| `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» | | `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» | | прочее | 500 | «внутренняя ошибка» |
@@ -116,7 +117,13 @@ jellybit — **приложение, а не библиотека**: внешн
error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
транспорта, несущих URL с секретом); транспорта, несущих URL с секретом);
- это **не** канал для транзиентных отказов команд — те остаются нейтральными - это **не** канал для транзиентных отказов команд — те остаются нейтральными
(см. выше). (см. выше);
- **внешнее значение в тексте усекается на границе, а его размер называется
числом.** `error_msg` уезжает в баннер ревью, в панель действий и в карточку
Telegram; имя файла на 400 байт занимает там экран целиком и оседает в БД
навсегда. Усечение — серединой и по рунам (`layout.shorten`,
`naming.truncate`, `tgbot.shorten`), точная величина остаётся числом рядом:
без неё человек не поймёт, насколько сокращать.
## panic ## panic
+1
View File
@@ -212,6 +212,7 @@ erDiagram
| Константа | Значение | Что означает | | Константа | Значение | Что означает |
| --- | --- | --- | | --- | --- | --- |
| `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) | | `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) |
| `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой |
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет, **Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
кэша метабаз нет; состояние по каждому пробелу и заведённые под них задачи — кэша метабаз нет; состояние по каждому пробелу и заведённые под них задачи —
+51
View File
@@ -185,6 +185,11 @@ Go-сервиса и что здесь уже проскакивало. Устр
чтение — и что будет с данными, записанными до деплоя, которые обычный путь чтение — и что будет с данными, записанными до деплоя, которые обычный путь
не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
очередь ревью её обходила) очередь ревью её обходила)
- `operations`: новая проверка встала в общую точку — что она отняла у тех, кто
зовёт эту точку не ради проверки? Пропала ли команда, стал ли предпросмотр пустым, перестал ли экран объяснять
причину, и узнает ли человек причину на **каждом** входе, а не только
на том, который разбирали? (журнал, 2026-08-10: проверка длины погасила
«Применить» и ничего не объяснила)
- `operations`: не удваивает ли новая ветка расход лимита метабаз и платного - `operations`: не удваивает ли новая ветка расход лимита метабаз и платного
LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет, LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет,
задача `metadata-cache`) задача `metadata-cache`)
@@ -320,6 +325,52 @@ Go-сервиса и что здесь уже проскакивало. Устр
случаи до этой даты не восстанавливались — восстановленная постфактум причина случаи до этой даты не восстанавливались — восстановленная постфактум причина
непоймания недостоверна, а именно она и нужна. непоймания недостоверна, а именно она и нужна.
## 2026-08-10 — проверка встала в общую точку и погасила кнопку, ничего не объяснив [пойман]
- **Где:** `internal/layout/layout.go``BuildLinks`; `internal/worker/review.go`
— сборка предпросмотра; `web/templates/partials/review_main.html` — панель
действий. Норма — `openspec/specs/review/spec.md`, требование «Панель действий
при пустом предпросмотре называет причину».
- **Симптом:** новая проверка длины целевого имени встала в `BuildLinks`
единственную сборку пути, откуда строятся и оба предпросмотра ревью. Отказ
обнулил предпросмотр, а без него экран прячет команду «Применить». Текст
причины брался из `error_msg` последнего перехода, но на самом частом входе
(распознавание без матча) это поле пусто: задача приходит в `review` без
причины и до раскладки не доходит. Владелец видел «Подтверди источник» и не
видел ни кнопки, ни настоящей причины. **До изменения** предпросмотр строился,
кнопка была, и применение доводило хотя бы до `failed` с текстом ядра.
- **Почему не поймали раньше:** обе стороны выглядели верными по отдельности.
Проверка в общей точке — правильное решение, оно и дало совпадение показанного
с применённым. Текст из `error_msg` — тоже правильное, для того входа, который
разбирали на чекпоинте (ручное «Применить» причину записывает). Развилка была в
том, что вход не один, и второй — частотнее.
- **Чем ловится теперь:** причина считается на показе
([ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md)),
тесты `TestReviewData_PreviewErrorWhenStateHasNoReason`,
`TestActionBarNamesReasonWithoutPreview`, `TestReviewCard_NamesPreviewProblem`.
В вопросы темы `operations` добавлен вопрос про сужение видимого.
## 2026-08-10 — спека нормировала случай, которого код произвести не может [пойман]
- **Где:** дельта `openspec/specs/file-layout/spec.md`, сценарий про
унаследованную от папки-якоря базу.
- **Симптом:** на чекпоинте ревью дизайна был назван тупик — при живом якоре база
берётся с диска, подсказка её не укорачивает, задача циклится. Сценарий уехал в
спеку с инструкцией человеку («выбрать источник без базы либо переименовать
папку руками»). Попытка написать под него тест показала, что случай
**недостижим**: папка-якорь лежит на диске и потому уже не длиннее предела, а
хвост имени файла (`" S02E01"` плюс расширение, 11 байт) не длиннее хвоста
имени папки (`" ["` плюс provider-тег плюс `"]"`, минимум 11 байт).
- **Почему не поймали раньше:** случай звучал правдоподобно и опирался на верное
свойство (унаследованная база подсказкой не меняется). Ни один проход ревью
дизайна арифметику не считал — кода на той стадии нет, а сценарий выглядел как
описание существующего поведения, а не как гипотеза.
- **Чем ловится теперь:** сценарий переписан на верное утверждение, свойство
закреплено тестом `TestApply_InheritedBaseAtLimitStillFits` — он покраснеет,
если суффиксы имён вырастут. Урок общий: **сценарий, который нельзя
воспроизвести, обязан получить оракул до того, как попадёт в спеку**; норма,
описывающая недостижимое, не отличается от неверной.
## 2026-08-10 — чистка названия метабазы стояла только на записи, и очередь ревью её обходила [пойман] ## 2026-08-10 — чистка названия метабазы стояла только на записи, и очередь ревью её обходила [пойман]
- **Где:** `internal/worker/review.go``sourcePins`, `applyOverrides`, - **Где:** `internal/worker/review.go``sourcePins`, `applyOverrides`,
+4 -1
View File
@@ -49,7 +49,10 @@ REST API работают **без авторизации** осознанно;
- **Правило:** компоненты санитизируются (убираются разделители пути, `..`, - **Правило:** компоненты санитизируются (убираются разделители пути, `..`,
управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго
под** соответствующей библиотекой, иначе операция отклоняется. Проверка на под** соответствующей библиотекой, иначе операция отклоняется. Проверка на
результате, а не на входе. результате, а не на входе. Следом — длина: каждый компонент обязан помещаться в
255 байт UTF-8, иначе задача уходит в `review`. Порядок значим: путь, вышедший
за песочницу, отклоняется как выход за библиотеку, а не как длинное имя, иначе
находка безопасности спряталась бы за косметической причиной.
- **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из - **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из
`/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и `/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и
линкуем**; писать в `paths.downloads` нельзя вообще. линкуем**; писать в `paths.downloads` нельзя вообще.
+55
View File
@@ -352,3 +352,58 @@ func TestRetryListShowsProgress(t *testing.T) {
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String()) t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String())
} }
} }
// TestActionBarNamesReasonWithoutPreview: при пустом предпросмотре панель
// действий печатает записанную причину, а не общее «Подтверди источник» —
// иначе экран советует подтвердить уже подтверждённое и молчит о настоящей
// причине (см. spec review, «Панель действий при пустом предпросмотре»).
func TestActionBarNamesReasonWithoutPreview(t *testing.T) {
t.Run("причина показа старше записанной", func(t *testing.T) {
rd := reviewDataWithSource(false)
rd.PreviewError = `layout: имя не помещается: "Очень длинное" — 400 байт при пределе 255`
rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "не помещается") {
t.Errorf("панель не назвала причину: %s", body)
}
if strings.Contains(body, "Подтверди источник") {
t.Errorf("панель печатает общий текст вместо причины: %s", body)
}
if strings.Contains(body, "устаревшая") {
t.Errorf("записанная причина перебила посчитанную на показе: %s", body)
}
})
t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
rd := reviewDataWithSource(false)
rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if !strings.Contains(rr.Body.String(), "уже существует") {
t.Errorf("панель не назвала записанную причину: %s", rr.Body.String())
}
})
t.Run("причины нет → прежний общий текст", func(t *testing.T) {
rd := reviewDataWithSource(false)
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "Подтверди источник") {
t.Errorf("без причины ожидался общий текст: %s", rr.Body.String())
}
})
}
+6 -1
View File
@@ -770,7 +770,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и // (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
// некорректный ввод команды (worker.ErrInvalidInput) → // некорректный ввод команды (worker.ErrInvalidInput) →
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели // 400; недокачанный источник (worker.ErrNotReady), коллизия цели
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее // (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и
// конфликт состояния (worker.ErrConflict) → 409; прочее
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только // → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
// сообщение + корреляционный ключ. // сообщение + корреляционный ключ.
func classifyErr(err error) (int, string) { func classifyErr(err error) (int, string) {
@@ -793,6 +794,10 @@ func classifyErr(err error) (int, string) {
// Целевой путь уже занят: задача штатно ушла в review с причиной — // Целевой путь уже занят: задача штатно ушла в review с причиной —
// это не сбой, а требующий разбора конфликт. // это не сбой, а требующий разбора конфликт.
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью" return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
case errors.Is(err, layout.ErrNameTooLong):
// Целевое имя не помещается в файловую систему: задача штатно ушла в
// review, где название правится подсказкой. Не сбой сервера.
return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью"
case errors.Is(err, worker.ErrConflict): case errors.Is(err, worker.ErrConflict):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой. // Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
return http.StatusConflict, "действие недоступно в текущем состоянии" return http.StatusConflict, "действие недоступно в текущем состоянии"
+22
View File
@@ -1098,3 +1098,25 @@ func TestRerecognize(t *testing.T) {
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid) t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
} }
} }
func TestAPICommandNameTooLong(t *testing.T) {
// Непомещающееся целевое имя (layout.ErrNameTooLong) → 409 (штатно ушло в
// review), не 500: это конфликт, требующий разбора, а не сбой сервера.
// Наружу — нейтральное сообщение, сырой текст ошибки остаётся в логах.
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrNameTooLong)}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if msg, _ := got["error"].(string); !strings.Contains(msg, "слишком длинное") {
t.Errorf("error = %q, want содержащее «слишком длинное»", msg)
}
}
+6 -1
View File
@@ -43,6 +43,7 @@ type reviewView struct {
State string State string
Error string // из ?err= Error string // из ?err=
StateError string // error_msg загрузки (напр. причина коллизии) StateError string // error_msg загрузки (напр. причина коллизии)
PreviewError string // почему предпросмотр не построился, посчитано на показе
MediaType string MediaType string
IsSeries bool IsSeries bool
Title string Title string
@@ -113,7 +114,11 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
State: string(rd.Download.State), State: string(rd.Download.State),
Error: errMsg, Error: errMsg,
StateError: rd.Download.ErrorMsg.String, StateError: rd.Download.ErrorMsg.String,
Hints: rd.Hints, // Причина пустого предпросмотра считается на показе и потому всегда про
// текущий план; error_msg остался от последнего перехода и после смены
// источника уже не про него.
PreviewError: rd.PreviewError,
Hints: rd.Hints,
} }
if rec := rd.Recognition; rec != nil { if rec := rd.Recognition; rec != nil {
view.MediaType = string(rd.Plan.Type) view.MediaType = string(rd.Plan.Type)
+10
View File
@@ -171,6 +171,11 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
if !underRoot(root, dst) { if !underRoot(root, dst) {
return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src) return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src)
} }
// Длина проверяется ПОСЛЕ песочницы: путь, вышедший за библиотеку, —
// находка безопасности, и подменять её косметической причиной нельзя.
if err := checkComponentLengths(root, dst); err != nil {
return nil, err
}
links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind}) links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind})
} }
if len(links) == 0 { if len(links) == 0 {
@@ -267,6 +272,11 @@ type Result struct {
// ErrCollision — цель существует и это другой файл (нужен review). // ErrCollision — цель существует и это другой файл (нужен review).
var ErrCollision = errors.New("layout: target collision") var ErrCollision = errors.New("layout: target collision")
// ErrNameTooLong — компонент целевого пути длиннее предела длины имени
// (maxComponentBytes). Проверяется в BuildLinks, до первой операции с ФС:
// задача уходит в review с доменной причиной, а не в failed с текстом ядра.
var ErrNameTooLong = errors.New("layout: имя не помещается")
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных // ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не // (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
// единственный файл (см. state-reconciliation, инвариант безопасного Undo). // единственный файл (см. state-reconciliation, инвариант безопасного Undo).
+47
View File
@@ -6,6 +6,53 @@ import (
"strings" "strings"
) )
// maxComponentBytes — предел длины одного компонента целевого пути в БАЙТАХ
// UTF-8, а не в символах: ядро меряет NAME_MAX в байтах, и кириллическое
// название упирается в предел вдвое раньше латинского той же длины в знаках.
// Значение — NAME_MAX у ext4/xfs/btrfs; у ядра оно не выясняется, потому что
// раскладка обязана отказать до обращения к диску (см. spec file-layout).
// Цена обеих сторон: на ФС с меньшим пределом (часть зашифрованных) имя пройдёт
// проверку и упрётся в ядро — останется сегодняшний failed; на ФС с бо́льшим мы
// откажем строже, чем нужно. Лечение — правка этой константы, а не настройка:
// значение, которое некому выставить осознанно, не гибкость.
const maxComponentBytes = 255
// checkComponentLengths проверяет, что каждый компонент пути dst ПОД корнем
// root помещается в maxComponentBytes. Корень не проверяется: его каталоги задаёт
// оператор, и жаловаться на них раскладка не вправе. Возвращает ошибку,
// обёртывающую ErrNameTooLong и называющую непомещающийся компонент и его длину.
// Чистая функция: к диску не обращается.
func checkComponentLengths(root, dst string) error {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(dst))
if err != nil {
return fmt.Errorf("layout: relative target %q: %w", dst, err)
}
for c := range strings.SplitSeq(rel, string(filepath.Separator)) {
if len(c) > maxComponentBytes {
return fmt.Errorf("%w: %q — %d байт при пределе %d",
ErrNameTooLong, shorten(c), len(c), maxComponentBytes)
}
}
return nil
}
// errNameSample — сколько рун непомещающегося имени показать в тексте ошибки.
// Текст уезжает в error_msg, а оттуда в баннер ревью и в карточку Telegram:
// имя целиком (а оно по условию длиннее 255 байт) заняло бы там весь экран.
// Точную длину несёт число рядом, поэтому образца хватает, чтобы узнать имя.
const errNameSample = 40
// shorten оставляет от имени начало и конец, выкидывая середину. Режет по рунам:
// обрыв посреди многобайтовой буквы дал бы в сообщении мусор.
func shorten(s string) string {
r := []rune(s)
if len(r) <= errNameSample {
return s
}
head := errNameSample / 2
return string(r[:head]) + "…" + string(r[len(r)-head:])
}
// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает // sanitizeComponent чистит один компонент пути (имя папки/файла): убирает
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает // разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри // пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
+250
View File
@@ -0,0 +1,250 @@
package layout
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// countDirEntries считает всё, что появилось под корнем библиотеки: проверка
// длины обязана отказать ДО первой операции с ФС, поэтому пусто — это часть
// утверждения, а не гигиена.
func countDirEntries(t *testing.T, root string) int {
t.Helper()
n := 0
err := filepath.WalkDir(root, func(p string, _ os.DirEntry, err error) error {
if err != nil {
return err
}
if p != root {
n++
}
return nil
})
if err != nil {
t.Fatal(err)
}
return n
}
// 2.1 Имя файла длиннее предела: отказ целиком, ни одного каталога на диске.
func TestBuildLinks_FileNameTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// Название впритык под папку, но имя файла = база + ".mkv".
title := strings.Repeat("a", maxComponentBytes-len(" (1999)"))
plan := Plan{
Type: Movie, Title: title, Year: 1999,
Files: []PlanFile{{Src: src, Role: RoleMain}},
}
links, err := f.l.BuildLinks(plan)
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("err = %v, want ErrNameTooLong", err)
}
if links != nil {
t.Errorf("links = %v, want nil (отказ целиком)", links)
}
if n := countDirEntries(t, f.movies); n != 0 {
t.Errorf("под movies появилось %d записей, ожидалось 0 (проверка до операций с ФС)", n)
}
}
// 2.2 Папка тайтла длиннее предела, хотя имя файла бы поместилось.
func TestBuildLinks_FolderNameTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// База помещается, но provider-тег выталкивает папку за предел; имя файла
// тега не несёт и остаётся коротким.
tag := "tmdbid-693134"
title := strings.Repeat("b", maxComponentBytes-len(" (1999)")-len(" [")-len(tag)-len("]"))
plan := Plan{
Type: Movie, Title: title, Year: 1999, ProviderTag: tag,
Files: []PlanFile{{Src: src, Role: RoleMain}},
}
if _, err := f.l.BuildLinks(plan); err != nil {
t.Fatalf("контроль: имя ровно в предел должно проходить, got %v", err)
}
plan.Title = title + "c" // +1 байт — папка перестаёт помещаться
_, err := f.l.BuildLinks(plan)
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("err = %v, want ErrNameTooLong", err)
}
if n := countDirEntries(t, f.movies); n != 0 {
t.Errorf("под movies появилось %d записей, ожидалось 0", n)
}
}
// 2.3 Предел меряется в БАЙТАХ, а не в рунах: кириллица упирается вдвое раньше.
// Заодно граница 255/256.
func TestBuildLinks_LimitIsBytesNotRunes(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
build := func(title string) error {
_, err := f.l.BuildLinks(Plan{
Type: Movie, Title: title,
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
return err
}
const ext = ".mkv"
// Граница ровно на 255 байтах имени файла.
fit := strings.Repeat("a", maxComponentBytes-len(ext))
if err := build(fit); err != nil {
t.Fatalf("255 байт должны помещаться, got %v", err)
}
if err := build(fit + "a"); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("256 байт: err = %v, want ErrNameTooLong", err)
}
// Столько же ЗНАКОВ кириллицей — вдвое больше байтов, отказ.
cyr := strings.Repeat("я", maxComponentBytes-len(ext))
if err := build(cyr); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("кириллица той же длины в знаках: err = %v, want ErrNameTooLong "+
"(предел меряется в байтах)", err)
}
// Граница кириллицей — тоже по байтам. 125 букв = 250 байт, плюс ".mkv" = 254:
// помещается. Ещё одна буква даёт 256 — не помещается.
cyrFit := strings.Repeat("я", (maxComponentBytes-len(ext))/2)
if err := build(cyrFit); err != nil {
t.Fatalf("254 байта кириллицей должны помещаться, got %v", err)
}
if err := build(cyrFit + "я"); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("256 байт кириллицей: err = %v, want ErrNameTooLong", err)
}
}
// 2.4 Приоритет: путь и вне библиотеки, и слишком длинный → отказ называет
// выход за библиотеку, а не длину. Иначе находка безопасности спрячется за
// косметической причиной.
func TestBuildLinks_OutsideLibraryBeatsTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// Санитизация режет разделители, поэтому traversal через Title недостижим;
// проверяем сам порядок на checkComponentLengths напрямую: путь вне корня
// до неё не доходит, а внутри корня — доходит.
outside := filepath.Join(filepath.Dir(f.movies), strings.Repeat("z", 300))
if underRoot(f.movies, outside) {
t.Fatal("подготовка теста неверна: путь обязан быть вне корня")
}
_, err := f.l.BuildLinks(Plan{
Type: Movie, Title: strings.Repeat("z", 300),
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("внутри корня длинное имя даёт ErrNameTooLong, got %v", err)
}
// Прямая сверка порядка в BuildLinks: underRoot стоит раньше и его отказ
// формулируется своим текстом (см. layout.go).
if err := checkComponentLengths(f.movies, outside); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("checkComponentLengths вне корня: %v", err)
}
}
// 2.7 Название не усекается: на непомещающемся входе ссылок нет вовсе, а не
// возвращена усечённая. Усечение схлопнуло бы два разных названия в один каталог.
func TestBuildLinks_NoTruncation(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
links, err := f.l.BuildLinks(Plan{
Type: Movie, Title: strings.Repeat("d", 400),
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
if err == nil {
t.Fatalf("ожидался отказ, получено %d ссылок", len(links))
}
if len(links) != 0 {
t.Errorf("вернулось %d ссылок — усечение недопустимо", len(links))
}
}
// 2.8 Мерится финальный компонент, а не название: суффикс субтитров
// дописывается после и обязан учитываться.
func TestBuildLinks_SubtitleSuffixCounted(t *testing.T) {
f := newFixture(t)
video := f.srcFile(t, "long/ep.mkv", "x")
sub := f.srcFile(t, "long/ep.ru.srt", "y")
// База подобрана так, что видеофайл помещается, а субтитр с ".ru.forced.srt" —
// уже нет: разница ровно в длине суффикса.
const stem = " S01E02"
title := strings.Repeat("e", maxComponentBytes-len(stem)-len(".mkv"))
plan := Plan{
Type: Series, Title: title,
Files: []PlanFile{
{Src: video, Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
},
}
if _, err := f.l.BuildLinks(plan); err != nil {
t.Fatalf("видеофайл впритык должен проходить, got %v", err)
}
plan.Files = append(plan.Files, PlanFile{
Src: sub, Role: RoleSubtitle, Season: intp(1), Episode: intp(2),
Lang: "ru", Flags: []string{"forced"},
})
if _, err := f.l.BuildLinks(plan); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("субтитр с суффиксом обязан упереться: err = %v", err)
}
}
// 2.9 Вырожденные входы проверку не роняют.
func TestCheckComponentLengths_Degenerate(t *testing.T) {
root := "/srv/media/movies"
cases := []string{
root + "/",
root + "/ ",
root + "/" + string([]byte{0xff, 0xfe, 0xfd}), // невалидный UTF-8
root + "/" + strings.Repeat("x", 1<<16),
root,
}
for _, dst := range cases {
// Требование одно: не паниковать и вернуть значение.
_ = checkComponentLengths(root, dst)
}
if err := checkComponentLengths(root, root+"/"+strings.Repeat("x", 1<<16)); !errors.Is(err, ErrNameTooLong) {
t.Error("очень длинный компонент обязан давать ErrNameTooLong")
}
}
// Корень библиотеки под проверку не попадает: его каталоги задаёт оператор.
func TestCheckComponentLengths_RootNotChecked(t *testing.T) {
root := "/srv/" + strings.Repeat("r", 300)
if err := checkComponentLengths(root, root+"/Dune (2024)/Dune (2024).mkv"); err != nil {
t.Errorf("длинный корень не должен считаться отказом: %v", err)
}
}
// Нерасчислимый относительный путь (корень абсолютный, цель относительная) —
// не отказ по длине, а отдельная ошибка: путать их нельзя, иначе диагноз соврёт.
func TestCheckComponentLengths_UnrelatablePath(t *testing.T) {
err := checkComponentLengths("/srv/media/movies", "relative/path.mkv")
if err == nil {
t.Fatal("want error for unrelatable path")
}
if errors.Is(err, ErrNameTooLong) {
t.Errorf("нерасчислимый путь не должен выдаваться за отказ по длине: %v", err)
}
}
// shorten держит текст причины коротким: имя в сообщении усечено серединой, а
// точную длину несёт число рядом. Короткое имя не трогается.
func TestShorten(t *testing.T) {
short := strings.Repeat("a", errNameSample)
if got := shorten(short); got != short {
t.Errorf("имя в предел образца не должно меняться: %q", got)
}
long := strings.Repeat("я", 200)
got := shorten(long)
if r := []rune(got); len(r) != errNameSample+1 { // +1 — многоточие
t.Errorf("длина образца = %d рун, want %d", len(r), errNameSample+1)
}
if !strings.Contains(got, "…") {
t.Errorf("усечённое имя должно нести многоточие: %q", got)
}
// Режем по рунам: обрыв посреди буквы дал бы мусор вместо кириллицы.
if strings.ContainsRune(got, '') {
t.Errorf("усечение разорвало руну: %q", got)
}
}
+9
View File
@@ -400,6 +400,15 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil) b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
return return
} }
if errors.Is(err, layout.ErrNameTooLong) {
// Имя не помещается в файловую систему: задача штатно ушла в review,
// где название правится подсказкой. Карточку обновляем — в ней теперь
// причина.
b.answer(cq.ID, "Имя не помещается")
b.send(chatID, opErr("Целевое имя слишком длинное — задача отправлена в ревью", id), nil)
b.refreshCard(ctx, chatID, msgID, id)
return
}
if errors.Is(err, layout.ErrCollision) { if errors.Is(err, layout.ErrCollision) {
// Коллизия цели: задача штатно ушла в review с причиной — показываем // Коллизия цели: задача штатно ушла в review с причиной — показываем
// конкретно и обновляем карточку (в ней теперь причина коллизии). // конкретно и обновляем карточку (в ней теперь причина коллизии).
+23 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"strings" "strings"
"testing" "testing"
@@ -78,6 +79,7 @@ type fakeReviewer struct {
deleted []string deleted []string
dismissed []string dismissed []string
chosen map[string]string // downloadID → выбранный candidateID chosen map[string]string // downloadID → выбранный candidateID
applyErr error // чем отвечает Apply (nil — успехом)
} }
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) { func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
@@ -85,7 +87,7 @@ func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData,
} }
func (f *fakeReviewer) Apply(_ context.Context, id string) error { func (f *fakeReviewer) Apply(_ context.Context, id string) error {
f.applied = append(f.applied, id) f.applied = append(f.applied, id)
return nil return f.applyErr
} }
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error { func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
if f.refined == nil { if f.refined == nil {
@@ -614,3 +616,23 @@ func TestBot_IngestErrorPromisesNoDownloadID(t *testing.T) {
t.Errorf("в отказе обещан идентификатор загрузки: %q", got) t.Errorf("в отказе обещан идентификатор загрузки: %q", got)
} }
} }
// Непомещающееся целевое имя — штатный отказ, чинимый на ревью: бот отвечает
// конкретно и обновляет карточку (в ней теперь причина), как для коллизии, а не
// падает в общее «Не удалось выполнить действие».
func TestBot_CallbackApplyNameTooLong(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.applyErr = fmt.Errorf("apply: %w", layout.ErrNameTooLong)
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "не помещается") {
t.Errorf("answers = %+v, ожидался конкретный ответ", api.answers)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "слишком длинное") {
t.Errorf("sent = %+v, ожидалось конкретное сообщение", api.sent)
}
if len(api.edits) != 1 {
t.Errorf("edits = %v, карточка должна обновиться причиной", api.edits)
}
}
+15
View File
@@ -89,11 +89,26 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
} }
if n := len(rd.Preview); n > 0 { if n := len(rd.Preview); n > 0 {
fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst))) fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst)))
} else if why := previewProblem(rd); why != "" {
// Плана нет — без этой строки карточка молчит о том, почему нет и «Применить».
// Причина показа (PreviewError) старше записанной: она про текущий план, а
// error_msg остался от последнего перехода.
fmt.Fprintf(&sb, "Не удаётся построить план: %s", esc(shorten(why, 120)))
} }
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd) return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
} }
// previewProblem — почему у задачи в review нет плана: причина, посчитанная на
// показе, либо записанная при последнем переходе. Пусто — объяснять нечего
// (источник ещё не подтверждён).
func previewProblem(rd *worker.ReviewData) string {
if rd.PreviewError != "" {
return rd.PreviewError
}
return rd.Download.ErrorMsg.String
}
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup { func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
id := rd.Download.ID id := rd.Download.ID
sid := id sid := id
@@ -0,0 +1,52 @@
package tgbot
import (
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// Карточка ревью без плана называет причину: иначе владелец видит «Нужно
// подтверждение» без плана и без кнопки «Применить» и не знает, что чинить.
// Причина показа старше записанной — она про текущий план.
func TestReviewCard_NamesPreviewProblem(t *testing.T) {
t.Run("причина показа", func(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := reviewData(store.StateReview)
rd.Preview = nil
rd.PreviewError = `layout: имя не помещается: "ЫЫЫ…ЫЫЫ" — 400 байт при пределе 255`
rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
text, _ := b.renderCard(rd)
if !strings.Contains(text, "не помещается") {
t.Errorf("карточка не назвала причину:\n%s", text)
}
if strings.Contains(text, "устаревшая") {
t.Errorf("записанная причина не должна перебивать посчитанную на показе:\n%s", text)
}
})
t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := reviewData(store.StateReview)
rd.Preview = nil
rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
text, _ := b.renderCard(rd)
if !strings.Contains(text, "уже существует") {
t.Errorf("карточка не назвала записанную причину:\n%s", text)
}
})
t.Run("причины нет — строки нет", func(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := reviewData(store.StateReview)
rd.Preview = nil
text, _ := b.renderCard(rd)
if strings.Contains(text, "Не удаётся построить план") {
t.Errorf("без причины строки быть не должно:\n%s", text)
}
})
}
+30 -10
View File
@@ -325,7 +325,15 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase)) links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase))
if err != nil { if err != nil {
w.transition(ctx, *d, store.StateReview, reasonBuild, err.Error()) // Непомещающееся имя — свой код причины: этот отказ человек чинит
// подсказкой названия, а прочие отказы построения (пустое название,
// серия без номера) — нет, и слепить их в один код значило бы потерять
// эту разницу.
code := reasonBuild
if errors.Is(err, layout.ErrNameTooLong) {
code = reasonNameTooLong
}
w.transition(ctx, *d, store.StateReview, code, err.Error())
return fmt.Errorf("build links: %w", err) return fmt.Errorf("build links: %w", err)
} }
@@ -953,14 +961,21 @@ func candMediaType(rec *store.Recognition) recognize.MediaType {
type ReviewData struct { type ReviewData struct {
Download store.Download Download store.Download
Recognition *store.Recognition Recognition *store.Recognition
Plan recognize.Plan // эффективный (с применёнными правками) Plan recognize.Plan // эффективный (с применёнными правками)
Preview []layout.Link // целевые пути активного источника (Src — относительный) Preview []layout.Link // целевые пути активного источника (Src — относительный)
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора // PreviewError — почему предпросмотр не построился, человекочитаемо (пусто —
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты) // построился либо строить было нечего). Считается НА ПОКАЗЕ, поэтому всегда
Provider string // эффективный провайдер (с учётом выбора) // относится к текущему эффективному плану — в отличие от error_msg загрузки,
ProviderID string // эффективный id в базе // который остался от последнего перехода и после смены источника устаревает.
Hints []string // Задача в review без записанной причины (обычный «нет матча») иначе оставила
Overrides map[string]string // бы экран без объяснения, почему пропала кнопка «Применить».
PreviewError string
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string
Overrides map[string]string
} }
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий // MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
@@ -1082,8 +1097,12 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
rd.Preview = links rd.Preview = links
} else { } else {
// Видимая деградация: без превью на экране ревью пропадает // Видимая деградация: без превью на экране ревью пропадает
// кнопка «Применить» — не рядовой Debug, а WARN. // кнопка «Применить» — не рядовой Debug, а WARN. Причину
// отдаём транспорту: молча пропавшая кнопка неотличима от
// поломки, а у задачи без записанной причины взять её больше
// неоткуда.
log.Warn("review data build preview failed", "error", lerr) log.Warn("review data build preview failed", "error", lerr)
rd.PreviewError = lerr.Error()
} }
} }
// Единый список источников: нейронка + кандидаты, каждый с // Единый список источников: нейронка + кандидаты, каждый с
@@ -1267,6 +1286,7 @@ const (
reasonBuild = "build" // не удалось построить план ссылок reasonBuild = "build" // не удалось построить план ссылок
reasonPersist = "persist" // ссылки на диске, но учёт не записан reasonPersist = "persist" // ссылки на диске, но учёт не записан
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision) reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
reasonNameTooLong = "name_too_long" // компонент целевого пути длиннее предела (layout.ErrNameTooLong)
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
) )
+139
View File
@@ -2406,3 +2406,142 @@ func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
t.Errorf("plan.Title = %q, want название распознавания", got.Title) t.Errorf("plan.Title = %q, want название распознавания", got.Title)
} }
} }
// Раскладка с непомещающимся именем уводит задачу в review со своим кодом
// причины, а не в failed с текстом ядра. Каталог библиотеки при этом пуст:
// проверка стоит до первой операции с ФС (критерии приёмки A1, A2, A4).
func TestApply_NameTooLongGoesReview(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200) // 400 байт — не помещается ни в имя, ни в папку
f := newApplyFixture(t, plan)
err := f.w.Apply(context.Background(), "1")
if !errors.Is(err, layout.ErrNameTooLong) {
t.Fatalf("Apply err = %v, want ErrNameTooLong", err)
}
d := f.st.downloads["1"]
if d.State != store.StateReview {
t.Errorf("state = %q, want review (а не failed)", d.State)
}
if d.ErrorCode.String != reasonNameTooLong {
t.Errorf("error_code = %q, want %q", d.ErrorCode.String, reasonNameTooLong)
}
// A3: причина — наш доменный текст, а не текст ядра.
if msg := d.ErrorMsg.String; strings.Contains(msg, "file name too long") ||
strings.Contains(msg, "ENAMETOOLONG") {
t.Errorf("error_msg несёт текст системной ошибки: %q", msg)
}
if !strings.Contains(d.ErrorMsg.String, "не помещается") {
t.Errorf("error_msg не называет причину по-человечески: %q", d.ErrorMsg.String)
}
// A2/A4: под библиотекой не появилось ничего.
entries, rerr := os.ReadDir(f.series)
if rerr != nil {
t.Fatal(rerr)
}
if len(entries) != 0 {
t.Errorf("под series появилось %d записей, ожидалось 0", len(entries))
}
if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (частичной раскладки не остаётся)", len(f.st.links))
}
}
// Прочие отказы построения плана свой код сохраняют: непомещающееся имя не
// поглотило соседний случай.
func TestApply_EmptyTitleKeepsBuildReason(t *testing.T) {
plan := seriesResult().Plan
plan.Title = "..." // санитизация схлопывает в пустое
f := newApplyFixture(t, plan)
if err := f.w.Apply(context.Background(), "1"); err == nil {
t.Fatal("want build error")
}
if got := f.st.downloads["1"].ErrorCode.String; got != reasonBuild {
t.Errorf("error_code = %q, want %q", got, reasonBuild)
}
}
// Экран ревью обязан назвать причину, даже когда в состоянии её нет. Самый
// частый вход в review — распознавание без подтверждённого матча — приходит
// туда с пустым error_msg и до раскладки не доходит, поэтому единственный
// источник причины здесь — построение предпросмотра.
func TestReviewData_PreviewErrorWhenStateHasNoReason(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200)
f := newApplyFixture(t, plan)
// Состояние без записанной причины: так задачу оставляет finishRecognition.
d := f.st.downloads["1"]
d.ErrorCode = store.NullString("")
d.ErrorMsg = store.NullString("")
f.st.put(d)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if len(rd.Preview) != 0 {
t.Fatalf("предпросмотр = %d ссылок, ожидался пустой", len(rd.Preview))
}
if rd.PreviewError == "" {
t.Fatal("предпросмотр пуст, а причины нет — экран промолчит о том, почему пропала кнопка")
}
if !strings.Contains(rd.PreviewError, "не помещается") {
t.Errorf("PreviewError = %q, ожидалась причина по длине имени", rd.PreviewError)
}
}
// Сценарий спеки «Подсказка человека чинит случай, когда база печатается из
// распознавания»: короткое название доводит задачу до done.
func TestApply_ShorterTitleFixesNameTooLong(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200)
f := newApplyFixture(t, plan)
if err := f.w.Apply(context.Background(), "1"); !errors.Is(err, layout.ErrNameTooLong) {
t.Fatalf("подготовка: ожидался отказ по длине, got %v", err)
}
// Человек задаёт название короче (жёсткая правка поля — override).
if err := f.st.SetOverride(context.Background(), "1", ovrTitle, "Шоу"); err != nil {
t.Fatal(err)
}
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply после укорачивания: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done", got)
}
}
// Унаследованная база отказа по длине дать не может, и это свойство арифметики,
// а не совпадение: папка-якоря лежит на диске, значит её имя <= предела, а хвост
// имени файла (" S02E01" + расширение, 11 байт) не длиннее хвоста папки
// (" [" + provider-тег + "]", минимум 11 байт). Тест держит это свойство: если
// суффиксы имён вырастут (язык и флаги субтитров, двойные серии), он покраснеет
// раньше, чем задача упрётся в тупик, из которого подсказка не выводит.
func TestApply_InheritedBaseAtLimitStillFits(t *testing.T) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
prov, pid := "tvdb", "269613"
tag := prov + "id-" + pid
// Папка якоря ровно в предел: длиннее её на диске быть не может.
base := strings.Repeat("a", 255-len(" [")-len(tag)-len("]"))
anchorDir := filepath.Join(f.series, base+" ["+tag+"]")
if err := os.MkdirAll(filepath.Join(anchorDir, "Season 01"), 0o755); err != nil {
t.Fatal(err)
}
f.st.links = append(f.st.links, store.FileLink{
DownloadID: "other", DstPath: filepath.Join(anchorDir, "Season 01", base+" S01E01.mkv"),
Status: string(layout.StatusLinked),
})
_ = f.st.SetOverride(context.Background(), "1", ovrProvider, prov)
_ = f.st.SetOverride(context.Background(), "1", ovrProviderID, pid)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("унаследованная база в предел должна раскладываться: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done", got)
}
}
+2 -1
View File
@@ -961,7 +961,8 @@ func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
log := logctx.FromOr(ctx, w.log) log := logctx.FromOr(ctx, w.log)
switch { switch {
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput), case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput),
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision): errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision),
errors.Is(err, layout.ErrNameTooLong):
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err) log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
default: default:
log.Error("command failed", "command", cmd, "download_id", id, "error", err) log.Error("command failed", "command", cmd, "download_id", id, "error", err)
@@ -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`) - **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`)
- **THEN** система скан не дёргает - **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** обработчик исполняет то же доменное действие и отвечает редиректом на - **THEN** обработчик исполняет то же доменное действие и отвечает редиректом на
`/review/{id}`, поведение без JavaScript не ломается `/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** панель действий печатает общее предложение подтвердить источник
+6 -1
View File
@@ -72,7 +72,12 @@
POST-формы без hx-* (полная навигация, см. web-ui.md). */}} POST-формы без hx-* (полная навигация, см. web-ui.md). */}}
{{define "review_action_bar"}} {{define "review_action_bar"}}
<div class="action-bar" id="action-bar"{{if .ActionBarOOB}} hx-swap-oob="true"{{end}}> <div class="action-bar" id="action-bar"{{if .ActionBarOOB}} hx-swap-oob="true"{{end}}>
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span> {{/* Без превью «Применить» не показывается, и панель обязана сказать почему.
Первой идёт PreviewError — она посчитана на показе и относится к текущему
плану; StateError остался от последнего перехода и после смены источника
устаревает. Общий текст — только когда причины нет ни той, ни другой:
источник правда ещё не подтверждён. */}}
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else if .PreviewError}}{{.PreviewError}}{{else if .StateError}}{{.StateError}}{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form> <form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form> <form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}} {{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}