layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой операции с ФС: ни каталога, ни ссылки при отказе не создаётся - причина пустого предпросмотра считается на показе (ReviewData.PreviewError) и печатается в панели действий и в карточке Telegram: у задачи без записанной причины взять её больше неоткуда
This commit is contained in:
@@ -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` при рассинхроне
|
||||
папок, — и заводить исключение ради текста на экране значило бы снять правило.
|
||||
- **Оставить как есть, записав остаток сценарием спеки.** Отвергнуто на
|
||||
чекпоинте: регресс диагностируемости дошёл бы до боевого окружения на самом
|
||||
частом входе.
|
||||
- **Печатать причину только в баннере состояния, панель не трогать.** Отвергнуто:
|
||||
баннер показывает записанное и на этом входе пуст ровно так же.
|
||||
|
||||
## Цена
|
||||
|
||||
Причина живёт в двух местах — записанная в состоянии и посчитанная на показе, — и
|
||||
порядок между ними держится на ревью, а не на типе. Взамен экран ревью объясняет
|
||||
отсутствие команды всегда, а не только когда причину успели записать, и
|
||||
объяснение относится к текущему плану, а не к прошлому.
|
||||
|
||||
Побочно: тот же текст может оказаться и в баннере, и в панели, когда записанная
|
||||
причина совпала с посчитанной. Дубль признан приемлемым — он честен, а
|
||||
код, который его снимал бы, дороже.
|
||||
@@ -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-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) | — |
|
||||
|
||||
@@ -116,6 +116,8 @@
|
||||
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
|
||||
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
|
||||
| Хардлинки и удаление своих ссылок | `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) |
|
||||
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
|
||||
| Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям |
|
||||
|
||||
@@ -88,6 +88,7 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
| `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
|
||||
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
|
||||
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
|
||||
| `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
|
||||
| `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | «внутренняя ошибка» |
|
||||
|
||||
@@ -116,7 +117,13 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
|
||||
транспорта, несущих URL с секретом);
|
||||
- это **не** канал для транзиентных отказов команд — те остаются нейтральными
|
||||
(см. выше).
|
||||
(см. выше);
|
||||
- **внешнее значение в тексте усекается на границе, а его размер называется
|
||||
числом.** `error_msg` уезжает в баннер ревью, в панель действий и в карточку
|
||||
Telegram; имя файла на 400 байт занимает там экран целиком и оседает в БД
|
||||
навсегда. Усечение — серединой и по рунам (`layout.shorten`,
|
||||
`naming.truncate`, `tgbot.shorten`), точная величина остаётся числом рядом:
|
||||
без неё человек не поймёт, насколько сокращать.
|
||||
|
||||
## panic
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ erDiagram
|
||||
| Константа | Значение | Что означает |
|
||||
| --- | --- | --- |
|
||||
| `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 нет,
|
||||
кэша метабаз нет; состояние по каждому пробелу и заведённые под них задачи —
|
||||
|
||||
@@ -185,6 +185,11 @@ Go-сервиса и что здесь уже проскакивало. Устр
|
||||
чтение — и что будет с данными, записанными до деплоя, которые обычный путь
|
||||
не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
|
||||
очередь ревью её обходила)
|
||||
- `operations`: новая проверка встала в общую точку — что она отняла у тех, кто
|
||||
зовёт эту точку не ради проверки? Пропала ли команда, стал ли предпросмотр пустым, перестал ли экран объяснять
|
||||
причину, и узнает ли человек причину на **каждом** входе, а не только
|
||||
на том, который разбирали? (журнал, 2026-08-10: проверка длины погасила
|
||||
«Применить» и ничего не объяснила)
|
||||
- `operations`: не удваивает ли новая ветка расход лимита метабаз и платного
|
||||
LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет,
|
||||
задача `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 — чистка названия метабазы стояла только на записи, и очередь ревью её обходила [пойман]
|
||||
|
||||
- **Где:** `internal/worker/review.go` — `sourcePins`, `applyOverrides`,
|
||||
|
||||
+4
-1
@@ -49,7 +49,10 @@ REST API работают **без авторизации** осознанно;
|
||||
- **Правило:** компоненты санитизируются (убираются разделители пути, `..`,
|
||||
управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго
|
||||
под** соответствующей библиотекой, иначе операция отклоняется. Проверка на
|
||||
результате, а не на входе.
|
||||
результате, а не на входе. Следом — длина: каждый компонент обязан помещаться в
|
||||
255 байт UTF-8, иначе задача уходит в `review`. Порядок значим: путь, вышедший
|
||||
за песочницу, отклоняется как выход за библиотеку, а не как длинное имя, иначе
|
||||
находка безопасности спряталась бы за косметической причиной.
|
||||
- **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из
|
||||
`/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и
|
||||
линкуем**; писать в `paths.downloads` нельзя вообще.
|
||||
|
||||
@@ -352,3 +352,58 @@ func TestRetryListShowsProgress(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -770,7 +770,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
|
||||
// некорректный ввод команды (worker.ErrInvalidInput) →
|
||||
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
||||
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и
|
||||
// конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
|
||||
// сообщение + корреляционный ключ.
|
||||
func classifyErr(err error) (int, string) {
|
||||
@@ -793,6 +794,10 @@ func classifyErr(err error) (int, string) {
|
||||
// Целевой путь уже занят: задача штатно ушла в review с причиной —
|
||||
// это не сбой, а требующий разбора конфликт.
|
||||
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
|
||||
case errors.Is(err, layout.ErrNameTooLong):
|
||||
// Целевое имя не помещается в файловую систему: задача штатно ушла в
|
||||
// review, где название правится подсказкой. Не сбой сервера.
|
||||
return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью"
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||
|
||||
@@ -1098,3 +1098,25 @@ func TestRerecognize(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ type reviewView struct {
|
||||
State string
|
||||
Error string // из ?err=
|
||||
StateError string // error_msg загрузки (напр. причина коллизии)
|
||||
PreviewError string // почему предпросмотр не построился, посчитано на показе
|
||||
MediaType string
|
||||
IsSeries bool
|
||||
Title string
|
||||
@@ -113,6 +114,10 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
||||
State: string(rd.Download.State),
|
||||
Error: errMsg,
|
||||
StateError: rd.Download.ErrorMsg.String,
|
||||
// Причина пустого предпросмотра считается на показе и потому всегда про
|
||||
// текущий план; error_msg остался от последнего перехода и после смены
|
||||
// источника уже не про него.
|
||||
PreviewError: rd.PreviewError,
|
||||
Hints: rd.Hints,
|
||||
}
|
||||
if rec := rd.Recognition; rec != nil {
|
||||
|
||||
@@ -171,6 +171,11 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
|
||||
if !underRoot(root, dst) {
|
||||
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})
|
||||
}
|
||||
if len(links) == 0 {
|
||||
@@ -267,6 +272,11 @@ type Result struct {
|
||||
// ErrCollision — цель существует и это другой файл (нужен review).
|
||||
var ErrCollision = errors.New("layout: target collision")
|
||||
|
||||
// ErrNameTooLong — компонент целевого пути длиннее предела длины имени
|
||||
// (maxComponentBytes). Проверяется в BuildLinks, до первой операции с ФС:
|
||||
// задача уходит в review с доменной причиной, а не в failed с текстом ядра.
|
||||
var ErrNameTooLong = errors.New("layout: имя не помещается")
|
||||
|
||||
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
|
||||
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
|
||||
// единственный файл (см. state-reconciliation, инвариант безопасного Undo).
|
||||
|
||||
@@ -6,6 +6,53 @@ import (
|
||||
"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 чистит один компонент пути (имя папки/файла): убирает
|
||||
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
|
||||
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,15 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
|
||||
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) {
|
||||
// Коллизия цели: задача штатно ушла в review с причиной — показываем
|
||||
// конкретно и обновляем карточку (в ней теперь причина коллизии).
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -78,6 +79,7 @@ type fakeReviewer struct {
|
||||
deleted []string
|
||||
dismissed []string
|
||||
chosen map[string]string // downloadID → выбранный candidateID
|
||||
applyErr error // чем отвечает Apply (nil — успехом)
|
||||
}
|
||||
|
||||
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 {
|
||||
f.applied = append(f.applied, id)
|
||||
return nil
|
||||
return f.applyErr
|
||||
}
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
if f.refined == nil {
|
||||
@@ -614,3 +616,23 @@ func TestBot_IngestErrorPromisesNoDownloadID(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +89,26 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
}
|
||||
if n := len(rd.Preview); n > 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id := rd.Download.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -955,6 +963,13 @@ type ReviewData struct {
|
||||
Recognition *store.Recognition
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||
// PreviewError — почему предпросмотр не построился, человекочитаемо (пусто —
|
||||
// построился либо строить было нечего). Считается НА ПОКАЗЕ, поэтому всегда
|
||||
// относится к текущему эффективному плану — в отличие от error_msg загрузки,
|
||||
// который остался от последнего перехода и после смены источника устаревает.
|
||||
// Задача в review без записанной причины (обычный «нет матча») иначе оставила
|
||||
// бы экран без объяснения, почему пропала кнопка «Применить».
|
||||
PreviewError string
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
@@ -1082,8 +1097,12 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
rd.Preview = links
|
||||
} else {
|
||||
// Видимая деградация: без превью на экране ревью пропадает
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN.
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN. Причину
|
||||
// отдаём транспорту: молча пропавшая кнопка неотличима от
|
||||
// поломки, а у задачи без записанной причины взять её больше
|
||||
// неоткуда.
|
||||
log.Warn("review data build preview failed", "error", lerr)
|
||||
rd.PreviewError = lerr.Error()
|
||||
}
|
||||
}
|
||||
// Единый список источников: нейронка + кандидаты, каждый с
|
||||
@@ -1267,6 +1286,7 @@ const (
|
||||
reasonBuild = "build" // не удалось построить план ссылок
|
||||
reasonPersist = "persist" // ссылки на диске, но учёт не записан
|
||||
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
|
||||
reasonNameTooLong = "name_too_long" // компонент целевого пути длиннее предела (layout.ErrNameTooLong)
|
||||
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
|
||||
)
|
||||
|
||||
|
||||
@@ -2406,3 +2406,142 @@ func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,7 +961,8 @@ func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
|
||||
log := logctx.FromOr(ctx, w.log)
|
||||
switch {
|
||||
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)
|
||||
default:
|
||||
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).
|
||||
@@ -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** они помещаются в предел, потому что папка-якорь лежит на диске и уже
|
||||
не длиннее предела, а хвост имени файла не длиннее хвоста имени папки
|
||||
|
||||
|
||||
@@ -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** панель действий печатает общее предложение подтвердить источник
|
||||
|
||||
|
||||
@@ -72,7 +72,12 @@
|
||||
POST-формы без hx-* (полная навигация, см. web-ui.md). */}}
|
||||
{{define "review_action_bar"}}
|
||||
<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}}/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}}
|
||||
|
||||
Reference in New Issue
Block a user