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

- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
This commit is contained in:
av
2026-08-10 12:16:44 +03:00
parent 1710e5a9d5
commit b9f0929d0c
31 changed files with 1511 additions and 17 deletions
@@ -0,0 +1,77 @@
# Причина, по которой человек не видит плана, считается на показе, а не читается из состояния
- **Дата:** 2026-08-10
- **Источник:**
[openspec/changes/archive/2026-08-10-long-title-to-review/design.md](../../openspec/changes/archive/2026-08-10-long-title-to-review/design.md),
Решение 6 и раздел `Risks / Trade-offs`; отчёт триажа того же change,
находка 1
## Контекст
Проверка длины целевого имени встала в `layout.BuildLinks` — туда же, где
собираются оба предпросмотра экрана ревью. Это дало даром совпадение показанного
с применённым, но и вторую половину: непомещающееся имя обнуляет предпросмотр, а
без предпросмотра экран прячет команду «Применить».
Первым решением панель действий брала текст из `error_msg` — причины, записанной
при последнем переходе. Ревью показало, что на самом частом входе этого поля
нет вовсе: задача, пришедшая в `review` из-за отсутствия матча, попадает туда с
пустой причиной и до раскладки не доходит. Замер триажа на двух деревьях:
```
до изменения: предпросмотр строится, «Применить» доступна,
применение доводит до failed с текстом ядра
после: предпросмотр пуст, команды нет, причины нет —
экран печатает «Подтверди источник», хотя источник ни при чём
```
То есть изменение, чья цель — «человек узнаёт причину», на этом входе
диагностируемость ухудшало.
## Решение
**Причина отказа считается в момент показа и отдаётся транспорту значением
(`worker.ReviewData.PreviewError`); записанная в состоянии используется только
когда посчитанной нет.**
Цитата из `design.md`, Решение 6:
> Оба предпросмотра ревью (карточка и строка источника) строят пути тем же
> `BuildLinks`, поэтому вердикт на показе и вердикт на применении совпадают по
> устройству, а не по договорённости.
Отсюда следует и обратное: раз вердикт считается на показе, там же считается и
его причина. Посчитанная предпочитается записанной по двум причинам сразу:
записанной может не быть вовсе, а после смены источника она уже про другой план — команды,
меняющие эффективный источник, поля ошибки не чистят.
**Чтение при этом состояние не двигает.** Построение предпросмотра остаётся без
побочных эффектов; причина уходит наружу возвращаемым значением.
Это второй случай одного класса за день. Первый —
[ADR-2026-08-10-sanitize-at-every-entry](ADR-2026-08-10-sanitize-at-every-entry.md):
гарантия, поставленная на запись, не покрывает то, что записано раньше. Здесь она
не покрывает то, что не записано вовсе.
## Рассмотренные варианты
- **Записывать причину в состояние при построении предпросмотра.** Отвергнуто:
чтение начало бы двигать состояние. Запрет уже стоял в коде отдельным
комментарием — предпросмотр не переводит задачу в `review` при рассинхроне
папок, — и заводить исключение ради текста на экране значило бы снять правило.
- **Оставить как есть, записав остаток сценарием спеки.** Отвергнуто на
чекпоинте: регресс диагностируемости дошёл бы до боевого окружения на самом
частом входе.
- **Печатать причину только в баннере состояния, панель не трогать.** Отвергнуто:
баннер показывает записанное и на этом входе пуст ровно так же.
## Цена
Причина живёт в двух местах — записанная в состоянии и посчитанная на показе, — и
порядок между ними держится на ревью, а не на типе. Взамен экран ревью объясняет
отсутствие команды всегда, а не только когда причину успели записать, и
объяснение относится к текущему плану, а не к прошлому.
Побочно: тот же текст может оказаться и в баннере, и в панели, когда записанная
причина совпала с посчитанной. Дубль признан приемлемым — он честен, а
код, который его снимал бы, дороже.
+1
View File
@@ -42,6 +42,7 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — |
| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — |
| 2026-08-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) | — |
+2
View File
@@ -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` — источник истины по полям |
+8 -1
View File
@@ -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
+1
View File
@@ -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 нет,
кэша метабаз нет; состояние по каждому пробелу и заведённые под них задачи —
+51
View File
@@ -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
View File
@@ -49,7 +49,10 @@ REST API работают **без авторизации** осознанно;
- **Правило:** компоненты санитизируются (убираются разделители пути, `..`,
управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго
под** соответствующей библиотекой, иначе операция отклоняется. Проверка на
результате, а не на входе.
результате, а не на входе. Следом — длина: каждый компонент обязан помещаться в
255 байт UTF-8, иначе задача уходит в `review`. Порядок значим: путь, вышедший
за песочницу, отклоняется как выход за библиотеку, а не как длинное имя, иначе
находка безопасности спряталась бы за косметической причиной.
- **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из
`/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и
линкуем**; писать в `paths.downloads` нельзя вообще.