diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index 099a1b1..2e9fdce 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -43,31 +43,41 @@ ## Очередь -- [✨ Уйти с PocketBase на SQLite со своим каталогом файлов](items/storage-without-pocketbase.md) — Библиотека держит шесть ролей и вышла за адаптер: её типы стоят во всех пяти файлах контроллера и во всех шести его проверках, панель опубликована в интернет вместе с необойдённым /%5f/, а пространство /api/ нельзя закрыть на прокси, потому что за файлами туда ходит браузер пользователя. - [🧹 Поднимать сервис для локальной работы одной командой](items/dev-run-task.md) — Локальная проверка требует ручной чистки каталога данных и остановки процесса, а владельца панели заводят по одноразовой ссылке из журнала. +- [🧹 Вынести тело подкоманды resume из main-пакета и покрыть тестом](items/devtools-resume-testable.md) — go test ./cmd/... отвечает no test files: подкоманда возврата записи в работу не проверена ничем, а тест на неё пришлось бы писать, повторяя её тело руками. - [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит. - [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем. - [✨ Дать владельцу править запись, возвращать её в работу и видеть её путь](items/audiorecord-actions.md) — С записью нельзя сделать ничего: заголовок ставит одна языковая модель, остановленную возвращает в работу только владелец сервиса в панели, а журнал событий пишется и не читается никем, кроме него же - [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись. - [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в cmd/transcriber проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом. +- [🐞 Судить маршрут по пути из запроса, а не по раскодированной копии](items/route-by-escaped-path.md) — Адрес /%6detrics отдаёт метрики байт в байт: ServeMux сравнивает раскодированный путь, а правило прокси написано на литерал /metrics — тот же класс, что закрытый /%5f/. - [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта. - [🧹 Свести пять расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре. +- [🧹 Свести норму схемы со служебной таблицей goose](items/goose-table-in-schema-norm.md) — Таблица учёта goose_db_version несёт второй вид времени и своё умолчание, а норма схемы объявлена абсолютной; сторож перечисляет наши таблицы поимённо и служебной не видит вовсе. +- [🧹 Переименовать требование webapp про путь в журнале](items/rename-webapp-journal-requirement.md) — Заголовок требования называет половину нормы — путь чужого корня, — а само требование накрывает обе половины адресного пространства; заголовок это идентичность требования, и синк оставил бы вторую, устаревшую копию. - [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны. - [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Скрипту недоступны ни браузерная сессия, ни вход у Authelia: домен целиком стоит за прокси, и автоматизировать загрузку нечем. - [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем. +- [🧹 Проверять ответ обработчика на отказ репозитория](items/http-tests-repo-failure-injection.md) — Тесты обработчиков идут по настоящему sqlite без управляемых отказов: класс «база недоступна во время запроса» не проверен нигде. - [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто. - [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата. - [🐞 Различать отказ, который стоит повторить, и приговор записи](items/failure-verdict-vs-retry.md) — Отказ приведения останавливает запись с первой попытки, и предел в пять отказов не работает никогда: разовый сбой ffmpeg останавливает запись приговором, хотя повтор обработал бы её успешно. +- [🐞 Не закрывать базу, пока живёт брошенный воркер](items/db-closed-under-live-worker.md) — По истечении жёсткого таймаута остановки процесс возвращается из run(), отложенный db.Close() обнуляет пулы, брошенный воркер разыменует nil и роняет процесс паникой; захват записи после этого стоит до восьми часов, и следа не остаётся. - [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен. - [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками. - [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла. +- [🧹 Писать об одном отказе шага одну строку журнала](items/single-journal-line-per-step-failure.md) — Временный отказ шага доезжает до владельца двумя строками ERROR: шаг пишет ошибку и возвращает её, воркер пишет её снова — в журнале отказов вдвое больше, чем было. +- [🧹 Разобрать мелочи хранилища на sqlite](items/sqlite-repo-nits.md) — Четыре мелочи ниже потолка триажа в одном пакете: три репозитория не переводят sql.ErrNoRows в доменную ошибку, два числа задают один и тот же предел страницы порознь, Append не заполняет время, которое сам же кладёт в базу, а у selectList — мёртвый довод prefix. - [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит. +- [🧹 Отдавать файл записи через http.ServeContent](items/serve-content-for-file-ranges.md) — Отдача файла считает диапазоны байт своей рукой — около 110 строк семантики HTTP, которую стандартная библиотека делает сама, и на этих строках стоит проигрывание записи на экране. - [✨ Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage. - [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет. - [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе. - [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки. - [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей. - [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново. +- [🔬 Сверка файла с записью при отдаче](items/file-record-ownership-check.md) — Владение судится у записи, а файл открывается по её ссылке без сверки files.record_id и files.owner_id; схема этой связи не держит, и обе стороны будут править delete-record и long-audio-chunking. +- [🔬 Имя копии в каталоге записи без обращения к базе](items/record-file-name-readability.md) — Имена копий вида без базы не читаются: по каталогу на диске не сказать ни чья запись, ни какого она уровня, а раскладка объявлена необратимой, и цена решения растёт с каждой уложенной записью. - [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере. - [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса. - [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет. diff --git a/tasks/items/db-closed-under-live-worker.md b/tasks/items/db-closed-under-live-worker.md new file mode 100644 index 0000000..50f13ac --- /dev/null +++ b/tasks/items/db-closed-under-live-worker.md @@ -0,0 +1,58 @@ +# 🐞 Не закрывать базу, пока живёт брошенный воркер + +- **Тип:** fix +- **Категория:** Очередь — Остановка чинится раньше бюджета остановки: пока база закрывается под живым воркером, замерять мягкий бюджет нечем — процесс падает паникой, а захват стоит до восьми часов +- **Зачем:** По истечении жёсткого таймаута остановки процесс возвращается из run(), отложенный db.Close() обнуляет пулы, брошенный воркер разыменует nil и роняет процесс паникой; захват записи после этого стоит до восьми часов, и следа не остаётся. +- **Теги:** review-2026-08-23 + +Остановка сервиса не дожидается брошенных горутин. По истечении +`ForceShutdownTimeout` `run()` возвращается, отложенный `db.Close()` обнуляет +пулы соединений, а воркер, который не успел закончить шаг, разыменует `nil` и +роняет процесс паникой. + +Замер прогона триажа: `Close()` вернулся за 2.8 мкс, пока другая горутина +держала пишущую транзакцию, и та зафиксировала её уже после закрытия. + +Цена не в самой панике, а в следе: захваченная запись остаётся захваченной до +истечения срока захвата — до восьми часов, — и о причине в журнале нет ничего. +Инвариант «принятая запись не теряется молча» держится тем, что отказ виден +владельцу записи; здесь не виден никому. + +Нашли проходы `review-code` (C2) и `review-ops` (O3) ревью change +`2026-08-23-storage-without-pocketbase`. + +## Воспроизведение + +1. Занять шаг конвейера работой дольше `[server] force_shutdown_timeout`. +2. Послать процессу `SIGTERM`. +3. По истечении жёсткого таймаута процесс возвращается из `run()` и закрывает + базу; брошенный воркер обращается к закрытому пулу и роняет процесс паникой. +4. Запись остаётся с признаком захвата, а в журнале об этом ни строки. + +## Затрагивает + +- порядок остановки в `cmd/transcriber/main.go` — возврат из `run()` и + отложенное закрытие базы; +- `internal/adapter/repo/sqlite/db.go` — `Close()` и жизненный цикл обоих пулов; +- цикл воркера `internal/controller/worker` — что делает шаг, у которого база + закрылась под руками; +- ключи конфига `[server] shutdown_timeout` и `force_shutdown_timeout`; +- поведение при остановке в спеке `pipeline`. + +## Критерии приёмки + +- Жёсткая остановка не роняет процесс паникой. **Оракул:** тест — шаг спит + дольше жёсткого таймаута, процесс останавливается, `recover` в тесте не + срабатывает, код возврата нулевой. +- Обращение к закрытой базе отвечает отказом, а не паникой. **Оракул:** тест + репозитория — вызов после `Close()` возвращает ошибку, и она отличима от + «записи нет». +- О брошенной работе остаётся след. **Оракул:** тест с перехваченным журналом — + жёсткая остановка при незакрытом шаге пишет строку с идентификатором записи и + причиной. + +## Рамки + +Бюджет мягкой остановки этой задачей не замеряется — это задача +`context-cancel-in-pipeline`; здесь закрывается только путь после жёсткого +таймаута. Сроки захвата и рубежи не двигаются. diff --git a/tasks/items/devtools-resume-testable.md b/tasks/items/devtools-resume-testable.md new file mode 100644 index 0000000..904cd5e --- /dev/null +++ b/tasks/items/devtools-resume-testable.md @@ -0,0 +1,39 @@ +# 🧹 Вынести тело подкоманды resume из main-пакета и покрыть тестом + +- **Тип:** chore +- **Категория:** Очередь — Тот же пакет оснастки: после того как в cmd/devtools осядет подкоманда подъёма, тела подкоманд выносятся из main-пакета разом +- **Зачем:** go test ./cmd/... отвечает no test files: подкоманда возврата записи в работу не проверена ничем, а тест на неё пришлось бы писать, повторяя её тело руками. +- **Теги:** review-2026-08-23 + +`go test ./cmd/...` отвечает `no test files`. Подкомандой `resume` владелец +возвращает остановленную запись в работу, пока этого не делают экраны, — и +проверяет её только человек руками. + +Тело подкоманды живёт в `main`-пакете и завязано на `log.Fatalf`: тест на неё +пришлось бы писать, повторяя это тело у себя, — то есть проверять копию, а не +предмет. Починка — вынести тело в вызываемую функцию, возвращающую ошибку. + +Нашёл проход `review-specs` (S4) ревью change +`2026-08-23-storage-without-pocketbase`. + +## Затрагивает + +- `cmd/devtools/resume.go` — тело подкоманды и его выход через `log.Fatalf`; +- `cmd/devtools/main.go` — разбор доводов и выбор подкоманды; +- сборка тестов `cmd/devtools`, которой сегодня нет. + +## Критерии приёмки + +- Тело подкоманды вызывается из теста. **Оракул:** `go test ./cmd/...` больше + не отвечает `no test files`, и тест зовёт функцию, а не повторяет её шаги. +- Остановленная запись возвращается в работу. **Оракул:** тест на временной базе + — после вызова у записи снят признак остановки, счётчик отказов обнулён, а в + журнале событий записи стоит строка о возврате. +- Отказ виден кодом возврата. **Оракул:** тест — несуществующий идентификатор + даёт ошибку из функции, а команда — ненулевой код. + +## Рамки + +Подкоманду не переименовываем и доводов ей не добавляем: правится место тела, а +не её договор с человеком. Боевой каталог данных не трогать — тест идёт по +`t.TempDir()`. diff --git a/tasks/items/file-record-ownership-check.md b/tasks/items/file-record-ownership-check.md new file mode 100644 index 0000000..e74575e --- /dev/null +++ b/tasks/items/file-record-ownership-check.md @@ -0,0 +1,38 @@ +# 🔬 Сверка файла с записью при отдаче + +- **Тип:** research +- **Категория:** Очередь — Ответ нужен до удаления записи и до нарезки на фрагменты: обе задачи правят обе стороны связи файла с записью +- **Зачем:** Владение судится у записи, а файл открывается по её ссылке без сверки files.record_id и files.owner_id; схема этой связи не держит, и обе стороны будут править delete-record и long-audio-chunking. +- **Теги:** review-2026-08-23 + +Владение судится у записи: обработчик находит запись по идентификатору, сверяет +владельца и открывает файл по ссылке из этой записи. Сверки `files.record_id` и +`files.owner_id` при этом нет, и схема такой связи не держит намеренно. + +Пути наружу сегодня нет — ссылку в запись кладёт только сервис, — и потому это +разведка, а не починка. Но обе стороны связи будут править `delete-record` и +`long-audio-chunking`: первая убирает файлы вместе с записью, вторая заводит у +одной записи несколько копий. Сверять надо при первой же правке файлов, пока +сторон две, а не десять. + +Гипотеза прохода `review-adversary` (V5) ревью change +`2026-08-23-storage-without-pocketbase`, понижённая триажем: свойство без +построенного пути. + +## Вопрос + +Чем держится принадлежность файла записи — сверкой при отдаче, связью в схеме +или тем и другим, — и что из этого стоит завести до того, как у записи станет +несколько копий? + +## Куда ляжет ответ + +Записка `docs/research/`, а нормативная половина — в спеку `storage` +требованием. Из ответа выходят рамки для `delete-record` и +`long-audio-chunking`: обе правят обе стороны связи. + +## Рамки + +Раскладку каталога данных ответ не двигает — она объявлена необратимой. +Применённые шаги схемы не переписываются: связь в схеме, если она понадобится, +приходит новым шагом. diff --git a/tasks/items/goose-table-in-schema-norm.md b/tasks/items/goose-table-in-schema-norm.md new file mode 100644 index 0000000..87f67e8 --- /dev/null +++ b/tasks/items/goose-table-in-schema-norm.md @@ -0,0 +1,54 @@ +# 🧹 Свести норму схемы со служебной таблицей goose + +- **Тип:** chore +- **Категория:** Очередь — Та же окрестность, что и сведение документов: норма схемы объявлена в database.md, а держит её сторож +- **Зачем:** Таблица учёта goose_db_version несёт второй вид времени и своё умолчание, а норма схемы объявлена абсолютной; сторож перечисляет наши таблицы поимённо и служебной не видит вовсе. +- **Теги:** review-2026-08-23, question + +Норма схемы объявлена абсолютной: [database.md](../../docs/database.md) +описывает таблицы сервиса, а сторож `internal/archrules` перечисляет их поимённо +и сверяет с применённой схемой. Таблицы учёта `goose_db_version` в этом перечне +нет, и она под норму не подпадает: время в ней своего вида, у колонки своё +умолчание — то и другое ставит goose, а не мы. + +Сегодня расхождение молчит: сторож смотрит только на названные таблицы. Стоит +ему начать смотреть на применённую схему целиком, и он покраснеет на таблице, +которую мы не заводили и править не можем. + +Исход — выбор нормы, а не правка кода: либо требование сужается и называет +таблицу учёта прямо, либо сторож расширяется на всю применённую схему с +поимённым исключением. + +Нашёл проход `review-specs` (S2) ревью change +`2026-08-23-storage-without-pocketbase`. + +## Затрагивает + +- [docs/database.md](../../docs/database.md) — формулировка нормы схемы; +- спека `storage` — требование о составе схемы; +- сторож `internal/archrules` — перечень таблиц и то, что он сверяет; +- каталог шагов `internal/adapter/repo/sqlite/migrations` — только чтением. + +## Критерии приёмки + +- Норма схемы называет таблицу учёта явно: либо как исключение, либо как + предмет. **Оракул:** чтение `docs/database.md` и спеки рядом — про + `goose_db_version` сказано, чья она и почему её колонки другие. +- Сторож судит то, что объявлено нормой, и краснеет на расхождении. **Оракул:** + мутация — завести колонку мимо шага схемы либо снять таблицу из перечня, и + `go test ./internal/archrules/` краснеет. +- Служебная таблица сторожа не роняет. **Оракул:** `task gate` зелёный на + применённой схеме целиком. + +## Вопросы + +- Что становится нормой: сузить требование и назвать таблицу учёта goose + исключением — или расширить сторож на всю применённую схему с поимённым + исключением? Первое дешевле и оставляет сторожа слепым к таблице, заведённой + мимо шага схемы; второе ловит такую таблицу, но требует держать перечень + исключений. + +## Рамки + +Применённые шаги схемы не переписываются. Сама goose не заменяется и её таблица +учёта не переименовывается. diff --git a/tasks/items/http-tests-repo-failure-injection.md b/tasks/items/http-tests-repo-failure-injection.md new file mode 100644 index 0000000..910b09c --- /dev/null +++ b/tasks/items/http-tests-repo-failure-injection.md @@ -0,0 +1,44 @@ +# 🧹 Проверять ответ обработчика на отказ репозитория + +- **Тип:** chore +- **Категория:** Очередь — Подставной репозиторий заводится там же, где тесты конвейера: одна сборка теста на два слоя +- **Зачем:** Тесты обработчиков идут по настоящему sqlite без управляемых отказов: класс «база недоступна во время запроса» не проверен нигде. +- **Теги:** review-2026-08-23 + +Тесты обработчиков ходят по настоящему sqlite во временном каталоге. Управляемых +отказов у него нет, поэтому весь класс «база недоступна во время запроса» не +проверен нигде: что отвечает обработчик, когда репозиторий отказал, известно +только чтением кода. + +Проверять надо не отказ базы, а ответ на него: код, тело единой формы и то, что +причина не попала в ответ пользователю. Для этого репозиторию нужна подставная +реализация, отказывающая по требованию. + +Гипотеза прохода `review-autotests` (A4) ревью change +`2026-08-23-storage-without-pocketbase`, понижённая триажем до наблюдения: +оракула у неё нет ровно потому, что управляемых отказов не существует. Сам пробел +проверен — `grep` по тестам пакета пуст. + +## Затрагивает + +- тесты `internal/controller/http` — сборка теста и способ подставить + репозиторий; +- договор ядра с хранилищем `internal/contract` — интерфейсы, по которым + подставляется отказ; +- единая форма ответа об отказе `internal/controller/http/errors.go`. + +## Критерии приёмки + +- Тест подставляет отказ репозитория. **Оракул:** тест — подставной + репозиторий отдаёт ошибку на названном методе, и обработчик её получает. +- Ответ на отказ идёт единой формой и не несёт причины. **Оракул:** тест на + каждом читающем обработчике — код `500`, тело эталонной формы, текста ошибки + базы в теле нет. +- Отказ виден владельцу сервиса. **Оракул:** тот же тест с перехваченным + журналом — строка уровня `ERROR` с причиной есть. + +## Рамки + +Настоящую базу из тестов не убираем: подставной репозиторий нужен для отказов, а +не вместо неё. Формы ответов и коды не меняем — задача проверяет то, что уже +объявлено. diff --git a/tasks/items/record-file-name-readability.md b/tasks/items/record-file-name-readability.md new file mode 100644 index 0000000..71fe9c2 --- /dev/null +++ b/tasks/items/record-file-name-readability.md @@ -0,0 +1,35 @@ +# 🔬 Имя копии в каталоге записи без обращения к базе + +- **Тип:** research +- **Категория:** Очередь — Разведка о той же раскладке каталога данных, и цена ответа растёт с каждой уложенной записью +- **Зачем:** Имена копий вида без базы не читаются: по каталогу на диске не сказать ни чья запись, ни какого она уровня, а раскладка объявлена необратимой, и цена решения растёт с каждой уложенной записью. +- **Теги:** review-2026-08-23 + +Копии записи лежат именами вида `<расширение>` в каталоге записи. По +каталогу на диске не сказать ни чья запись, ни какого уровня копия: имя читается +только вместе с базой. + +Пока записей мало, это не мешает. Цена растёт с каждой уложенной записью: +раскладка каталога данных объявлена необратимой, и переименование задним числом +стоит прохода по всему каталогу и по всем ссылкам в базе. + +Разведка отвечает не «переименовать ли», а «нужна ли читаемость без базы вообще» +— и во что обойдётся её отсутствие, когда записи пойдут живым потоком. + +Нашёл триаж ревью change `2026-08-23-storage-without-pocketbase`. + +## Вопрос + +Нужно ли имени копии нести что-то, по чему запись узнаётся без обращения к базе, +и какой ценой это берётся сейчас против той, что придётся заплатить потом? + +## Куда ляжет ответ + +Записка `docs/research/`, а решение — в +[ADR о раскладке каталога данных](../../docs/adr/). Изменение самой раскладки +необратимо и берётся отдельной задачей после ответа. + +## Рамки + +Раскладка каталога данных этой разведкой не меняется — она объявлена +необратимой, и правка её решается человеком. Боевой каталог данных не трогать. diff --git a/tasks/items/rename-webapp-journal-requirement.md b/tasks/items/rename-webapp-journal-requirement.md new file mode 100644 index 0000000..a70eb68 --- /dev/null +++ b/tasks/items/rename-webapp-journal-requirement.md @@ -0,0 +1,38 @@ +# 🧹 Переименовать требование webapp про путь в журнале + +- **Тип:** chore +- **Категория:** Очередь — Хвост спек после переезда хранилища, идёт рядом с нормой схемы: оба правят объявленное, а не поведение +- **Зачем:** Заголовок требования называет половину нормы — путь чужого корня, — а само требование накрывает обе половины адресного пространства; заголовок это идентичность требования, и синк оставил бы вторую, устаревшую копию. +- **Теги:** review-2026-08-23 + +Задача `storage-without-pocketbase` переписала требование спеки `webapp` про +путь в журнале, и оно накрывает теперь обе половины адресного пространства: и путь под корнем приложения, и путь вне корней. Заголовок остался +прежним и называет только вторую половину. + +Заголовок оставлен намеренно: он — идентичность требования в секции `MODIFIED`, +и переименование при синке оставило бы в спеке вторую, устаревшую копию. Смена +имени требует отдельного прохода через `RENAMED`. + +Нашёл синк документации после ревью change +`2026-08-23-storage-without-pocketbase`. + +## Затрагивает + +- спека `openspec/specs/webapp/spec.md` — заголовок требования про путь в + журнале; +- change с секцией `RENAMED` и его архив; +- ссылки на это требование в `docs/` и в записях каталога задач, если они есть. + +## Критерии приёмки + +- Заголовок называет обе половины нормы. **Оракул:** чтение требования — + заголовок и тело говорят об одном и том же адресном пространстве. +- Второй копии требования в спеке нет. **Оракул:** `grep` по + `openspec/specs/webapp/spec.md` — требование о пути в журнале одно. +- Спека проходит проверку. **Оракул:** `openspec validate --strict` и `task + gate` зелёные. + +## Рамки + +Само поведение не меняется — правится имя требования. Прочие требования спеки +`webapp` не трогаются. diff --git a/tasks/items/route-by-escaped-path.md b/tasks/items/route-by-escaped-path.md new file mode 100644 index 0000000..2d275c3 --- /dev/null +++ b/tasks/items/route-by-escaped-path.md @@ -0,0 +1,58 @@ +# 🐞 Судить маршрут по пути из запроса, а не по раскодированной копии + +- **Тип:** fix +- **Категория:** Очередь — Правится там же, где маршруты сводятся в одно место: суждение о пути и его объявление — один заход +- **Зачем:** Адрес /%6detrics отдаёт метрики байт в байт: ServeMux сравнивает раскодированный путь, а правило прокси написано на литерал /metrics — тот же класс, что закрытый /%5f/. +- **Теги:** review-2026-08-23 + +Правило обратного прокси написано на литерал `/metrics`, а `net/http` сравнивает +путь **после** раскодирования процентных последовательностей. Адрес +`/%6detrics` правилу прокси не совпадает, а `ServeMux` отдаёт по нему тот же +обработчик метрик. + +Тот же класс закрыт задачей `storage-without-pocketbase` для `/%5f/` — там путь +исчез вместе с пространством хранилища. Здесь путь остаётся, и обойти можно +всякое правило прокси, написанное на литерал. + +Серьёзность понижена тем, что метрики объявлены открытыми без узнавания — +[security.md](../../docs/security.md), — то есть сегодня обход не даёт того, чего +нельзя получить прямым запросом. Дефект в том, что суждение о пути расходится +между сервисом и прокси: следующий закрытый прокси адрес обойдётся так же. + +Нашёл проход `review-adversary` (V3) ревью change +`2026-08-23-storage-without-pocketbase`, проверено сырыми запросами. + +## Воспроизведение + +1. Поднять сервис локально. +2. `curl -s http://<адрес>/%6detrics` — приходит страница метрик байт в байт, + как по `/metrics`. +3. Правило прокси, написанное на литерал `/metrics`, такой путь не узнаёт и + пропускает его наружу. + +## Затрагивает + +- перечень корней сервиса `internal/controller/http/mounts.go` — сравнение пути + в `Mount.Covers` и `ExactAddressOf`; +- подъём сервера и раздача слушателей в `cmd/transcriber`; +- публичный контракт HTTP: адреса `/metrics` и `/health`; +- модель угроз `docs/security.md` — строка об открытых метриках и о том, чем + держится закрытость адреса. + +## Критерии приёмки + +- Перекодированный адрес метрик не отдаёт метрики. **Оракул:** тест маршрутов — + `/%6detrics`, `/%6D%65trics` и `/metrics/` отвечают эталоном неизвестного + пути, а `/metrics` отвечает метриками. +- Тем же эталоном отвечает перекодированный адрес всякого точного корня. + **Оракул:** тот же тест на `/%68ealth`. +- В журнал перекодированный путь идёт своим полем длины, а не дословно. + **Оракул:** тест журнала — строка о таком запросе несёт `<приложение>` либо + точный адрес, а не текст спрашивающего. + +## Рамки + +Правило обратного прокси живёт в чужом репозитории `pet-project-server`, и эта +задача его не правит: сервис обязан судить о своём адресном пространстве сам. +Открытость метрик без узнавания решением не пересматривается — вопрос в том, по +какому пути они отдаются. diff --git a/tasks/items/serve-content-for-file-ranges.md b/tasks/items/serve-content-for-file-ranges.md new file mode 100644 index 0000000..5ccb0c4 --- /dev/null +++ b/tasks/items/serve-content-for-file-ranges.md @@ -0,0 +1,48 @@ +# 🧹 Отдавать файл записи через http.ServeContent + +- **Тип:** chore +- **Категория:** Очередь — Стандартная семантика диапазонов нужна проигрыванию записи на экране: браузер тянет звук именно диапазонами +- **Зачем:** Отдача файла считает диапазоны байт своей рукой — около 110 строк семантики HTTP, которую стандартная библиотека делает сама, и на этих строках стоит проигрывание записи на экране. +- **Теги:** review-2026-08-23 + +Отдача файла записи разбирает заголовок `Range`, считает границы, ставит +`Content-Range` и код `206` своей рукой — около 110 строк семантики HTTP. +`http.ServeContent` делает то же самое: диапазоны, `If-Range`, `Last-Modified`, +`ETag`, неверный диапазон с кодом `416`. + +Своя реализация стоит не тем, что длиннее, а тем, что расходится: браузер +проигрывает звук именно диапазонами, и задача `play-recording-in-app` встанет +поверх этих строк. + +Нашёл проход `review-architecture` (вторая половина R2) ревью change +`2026-08-23-storage-without-pocketbase`. + +## Затрагивает + +- `internal/controller/http/file.go` — разбор `Range`, ответ `206` и заголовки + диапазона; +- тип содержимого ответа: он выводится из закрытого перечня форматов и остаётся + за нами, `ServeContent` его не выбирает; +- открытие копии в `internal/adapter/repo/sqlite/store.go` — `ServeContent` + требует `io.ReadSeeker` и время последнего изменения; +- публичный контракт HTTP: коды `200`, `206`, `416` и заголовки ответа на + запрос файла. + +## Критерии приёмки + +- Диапазоны считает стандартная библиотека. **Оракул:** `grep` по + `internal/controller/http` — ни одного разбора заголовка `Range` руками, + вызов `http.ServeContent` один. +- Ответы на диапазон не изменились. **Оракул:** тест контроллера — запрос без + `Range` отдаёт `200` и всю длину, запрос `bytes=0-99` отдаёт `206` с + `Content-Range`, запрос за пределом длины отдаёт `416`. +- Тип содержимого по-прежнему выбирает сервис. **Оракул:** тест на записи с + расширением `.html` — ответ несёт `application/octet-stream` и + `attachment`, а не тип из имени. +- Чужой файл остаётся недостижимым. **Оракул:** прежний тест владения — + чужая и несуществующая запись отвечают байт в байт одинаково. + +## Рамки + +Раскладку каталога данных и имена копий не трогаем: она объявлена необратимой. +Ответ на запрос чужого файла не меняется ни кодом, ни телом. diff --git a/tasks/items/single-journal-line-per-step-failure.md b/tasks/items/single-journal-line-per-step-failure.md new file mode 100644 index 0000000..8213f31 --- /dev/null +++ b/tasks/items/single-journal-line-per-step-failure.md @@ -0,0 +1,44 @@ +# 🧹 Писать об одном отказе шага одну строку журнала + +- **Тип:** chore +- **Категория:** Очередь — Правит цикл воркера следом за мелочами того же цикла: уровень записи и число записей — один заход +- **Зачем:** Временный отказ шага доезжает до владельца двумя строками ERROR: шаг пишет ошибку и возвращает её, воркер пишет её снова — в журнале отказов вдвое больше, чем было. +- **Теги:** review-2026-08-23 + +Временный отказ шага конвейера доезжает до владельца сервиса дважды: шаг +пишет ошибку в журнал и возвращает её, а цикл воркера пишет её снова строкой +`Worker error` — `internal/controller/worker/worker.go`. + +Вопрос стоит триггером ревью в [review.md](../../docs/review.md) с 2026-08-10 — +«не удвоилась ли запись об одном сбое» — и остаётся открытым: на прогоне ревью +change `2026-08-23-storage-without-pocketbase` удвоение воспроизведено дословным +выводом (проход `review-ops`, O2). + +Цена — счёт: всплеск отказов в журнале вдвое больше настоящего, и владелец +судит по нему о поломке. + +То же правило для транспорта записано критерием задачи +[http-transport-nits](http-transport-nits.md); порознь они потому, что живут в +разных пакетах и правятся разными заходами. + +## Затрагивает + +- цикл воркера `internal/controller/worker/worker.go` — строка `Worker error`; +- шаги конвейера `internal/service` — их собственные записи об отказе; +- конвенция [logging.md](../../docs/conventions/logging.md) — кто отвечает за + запись об ошибке, родивший её слой или получивший. + +## Критерии приёмки + +- Об одном временном отказе шага в журнале одна запись. **Оракул:** тест с + перехваченным журналом — шаг отказывает один раз, строк уровня `ERROR` про + этот отказ ровно одна. +- Запись несёт то, чего у второй стороны нет. **Оракул:** тот же тест — в + строке есть имя шага, идентификатор записи и причина. +- Молчание не заводится. **Оракул:** тот же тест — отказ шага без записи в + журнал не проходит: строк по нему не ноль. + +## Рамки + +`NoopJobError` не логируется и не считается — инвариант, и эта задача его не +трогает. Уровни `WARN` и `ERROR` не переопределяются: правит кто пишет, а не чем. diff --git a/tasks/items/sqlite-repo-nits.md b/tasks/items/sqlite-repo-nits.md new file mode 100644 index 0000000..288c188 --- /dev/null +++ b/tasks/items/sqlite-repo-nits.md @@ -0,0 +1,51 @@ +# 🧹 Разобрать мелочи хранилища на sqlite + +- **Тип:** chore +- **Категория:** Очередь — Мелочи слоя хранилища разбираются, пока слой в руках +- **Зачем:** Четыре мелочи ниже потолка триажа в одном пакете: три репозитория не переводят sql.ErrNoRows в доменную ошибку, два числа задают один и тот же предел страницы порознь, Append не заполняет время, которое сам же кладёт в базу, а у selectList — мёртвый довод prefix. +- **Теги:** review-2026-08-23 + +Четыре мелочи ревью change `2026-08-23-storage-without-pocketbase`, оставшиеся +ниже потолка триажа. Собраны одной задачей: все живут в +`internal/adapter/repo/sqlite` и правятся одним заходом. + +1. **Репозитории текста, структуры и попытки распознавания не переводят + `sql.ErrNoRows` в доменную ошибку**, при том что репозиторий записи в том же + пакете правило исполняет (`record_repo.go`). Вызывающий не отличает «записи + нет» от отказа базы, потому что типизированного значения ему не приходит. +2. **Два числа об одном пределе страницы:** `defaultListLimit = 30` в слое + хранилища и `DefaultPageLimit = 30` в контракте. Совпадают дословно, а + меняются порознь. +3. **`RecordEventRepository.Append` не заполняет `event.CreatedAt`**, хотя само + же значение кладёт в базу: вызывающий получает событие с нулевым временем. +4. **Мёртвый довод `prefix` у `selectList`** — ни один вызов его не читает. + +Нашёл проход `review-code` (C6 и то, что осталось за его потолком конвенций). + +## Затрагивает + +- `internal/adapter/repo/sqlite` — `text_repo.go`, `recognition_repo.go`, + `record_event_repo.go`, `record_list.go`; +- `internal/contract` — типизированное значение «записи нет» и константа + предела страницы; +- конвенция [errors.md](../../docs/conventions/errors.md) — единая точка + трансляции ошибки хранилища. + +## Критерии приёмки + +- «Записи нет» приходит вызывающему одним значением из всех репозиториев. + **Оракул:** тест на каждый из трёх репозиториев — запрос несуществующего + идентификатора отвечает значением, которое узнаётся `errors.Is`, а не + `sql.ErrNoRows`. +- Предел страницы объявлен одним числом. **Оракул:** `grep -rn "= 30" + internal/` — объявление одно, второе место ссылается на него. +- Приложенное событие приходит с временем, которое уехало в базу. **Оракул:** + тест `Append` — у возвращённого события `CreatedAt` не нулевое и совпадает со + строкой в базе. +- Мёртвого довода нет. **Оракул:** `go vet ./...` и `golangci-lint run` + зелёные, `grep` по `selectList` не находит довода `prefix`. + +## Рамки + +Раскладку каталога данных и имена копий не трогаем — это разведка +`record-file-name-readability`. Применённые шаги схемы не переписываются. diff --git a/tasks/items/stalled-pipeline-metric.md b/tasks/items/stalled-pipeline-metric.md index 5c90080..19435f4 100644 --- a/tasks/items/stalled-pipeline-metric.md +++ b/tasks/items/stalled-pipeline-metric.md @@ -3,6 +3,7 @@ - **Тип:** feature - **Категория:** Очередь — Признак вставшего конвейера — вторая половина той же работы. - **Зачем:** Вставший конвейер неотличим от простоя: возраст задачи в состоянии не считается, и очередь без движения выглядит как отсутствие работы. +- **Теги:** review-2026-08-23 По метрикам видно, что конвейер встал, и это отличимо от «работы нет». @@ -10,10 +11,23 @@ очереди не считает ничто. Пустая очередь и очередь, где десять задач висят третий час, дают одинаковый нулевой прирост счётчиков. +**Вторая половина той же слепоты — сам воркер.** Ревью change +`2026-08-23-storage-without-pocketbase` (проход `review-ops`, O1) показало: живой +воркер, которому не досталось работы, и воркер, чей шаг завис, дают одну и ту же +картину — ни строки в журнале, ни движения счётчика. Признак живости здесь свой, +отдельный от возраста задачи: очередь бывает пуста законно, а вот прогон цикла +случается всегда. + +Считать для этого `NoopJobError` **нельзя** — инвариант CLAUDE.md: значение +«задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает +уровень. Нужен свой признак: отметка времени последнего прогона цикла у каждого +воркера, а не счётчик пустых заходов. + ## Затрагивает -- `internal/metrics` — метрика числа задач по состояниям и возраста самой старой - из них; +- `internal/metrics` — метрика числа задач по состояниям, возраста самой старой + из них и времени последнего прогона цикла у воркера; +- цикл воркера `internal/controller/worker` — откуда снимается признак живости; - таблица задач: колонка состояния, по которой считаются числа, и отметка времени перехода, по которой считается возраст самой старой; - `docs/architecture.md`, раздел эксплуатации — что означает каждое число; @@ -27,6 +41,12 @@ сменила состояние. Оракул — тест на задаче с заведомо старой отметкой времени. - Съём чисел не мешает работе воркеров. Оракул — чтение кода: запрос идёт по индексу состояния и не берёт захват. +- Живой воркер отличим от вставшего. Оракул — тест: у воркера, крутящего цикл на + пустой очереди, отметка последнего прогона растёт; у воркера, чей шаг завис + дольше периода опроса, стоит на месте. +- `NoopJobError` не считается и не пишется. Оракул — тест с перехваченным + журналом на пустой очереди: строк о задаче нет, счётчик отказов не вырос, + а признак живости при этом двигается. ## Рамки diff --git a/tasks/items/storage-without-pocketbase.md b/tasks/items/storage-without-pocketbase.md deleted file mode 100644 index fb8b0e6..0000000 --- a/tasks/items/storage-without-pocketbase.md +++ /dev/null @@ -1,47 +0,0 @@ -# ✨ Уйти с PocketBase на SQLite со своим каталогом файлов - -- **Тип:** feature -- **Категория:** Очередь — Всё остальное строится поверх хранилища: экраны пишутся на типах контроллера, а dev-run-task заводит владельца панели, которой не станет -- **Зачем:** Библиотека держит шесть ролей и вышла за адаптер: её типы стоят во всех пяти файлах контроллера и во всех шести его проверках, панель опубликована в интернет вместе с необойдённым /%5f/, а пространство /api/ нельзя закрыть на прокси, потому что за файлами туда ходит браузер пользователя. - -Сервис работает с SQLite напрямую, файлы записей лежат в своём каталоге и -отдаются своим обработчиком, маршруты и слои живут на `net/http`, схему двигают -свои шаги. Панель администратора исчезает вместе с адресами `/_/` и `/api/`. - -Решение — [ADR-2026-08-22-storage-without-pocketbase](../../docs/adr/ADR-2026-08-22-storage-without-pocketbase.md), -разведка — [storage-without-pocketbase](../../docs/research/storage-without-pocketbase.md). - -## Затрагивает - -- каталог `internal/adapter/repo/pocketbase` целиком и его шаги схемы; -- каталог `internal/controller/http` целиком вместе с проверками; -- подъём сервера и цепочку слоёв в `cmd/transcriber/main.go`; -- раскладку каталога данных: файл базы и каталог файлов записей — **необратимое**; -- публичный контракт HTTP: пространство `/api/` и адрес `/_/` исчезают, корень - `/app/` остаётся; -- отдачу файла записи браузеру: короткий токен файла и защищённое поле коллекции; -- панель администратора как инструмент владельца сервиса; -- ключ конфига `[storage] data_dir` и перечень зависимостей `go.mod`; -- модель угроз: адрес `/_/`, дефект `/%5f/` и правило прокси на пространство - хранилища. - -## Критерии приёмки - -- Библиотеки нет в сборке. **Оракул:** `go list -deps ./... | grep -c pocketbase` - даёт `0`, а `go mod tidy` не возвращает её модулей в `go.mod`. -- Чужой файл недостижим по прямой ссылке. **Оракул:** тест контроллера — запрос - к файлу чужой записи и к несуществующей отвечает одинаково. -- Захват записи остаётся неделимым. **Оракул:** тест с параллельными воркерами - под `-race` — запись достаётся ровно одному, второй получает отказ по значению - признака захвата. -- Пространства хранилища не существует. **Оракул:** тест маршрутов — `/api/…`, - `/_/` и `/%5f/` отвечают тем же, чем всякий неизвестный путь. -- Схема накатывается на пустом каталоге до старта воркеров. **Оракул:** запуск на - чистом каталоге данных — ни одного отказа в журнале до первой строки о готовности. - -## Рамки - -Данные не переносятся: стройка, на сервере пусто. Панель не заменяется ничем — -остановленную запись возвращает в работу запрос к базе, пока этого не сделают -экраны владельца. Очередь остаётся своей таблицей с захватом одним запросом -`RETURNING`. Боевой каталог данных не трогать.