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` нельзя вообще.
|
||||
|
||||
Reference in New Issue
Block a user