Compare commits
25
Commits
eacaf76d5f
...
b7d4660aef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d4660aef
|
||
|
|
62b2829cba
|
||
|
|
e660617ba0
|
||
|
|
ce0ae76977
|
||
|
|
312caf0fa3
|
||
|
|
cd57b68215
|
||
|
|
903941f587
|
||
|
|
4c87220d90
|
||
|
|
edcf8ede70
|
||
|
|
b733a84d6a
|
||
|
|
863ba3b42e
|
||
|
|
220a4374b1
|
||
|
|
ec136b50fb
|
||
|
|
54268b5933
|
||
|
|
539ed926cb
|
||
|
|
cb65967389
|
||
|
|
26256cdb06
|
||
|
|
6994feec55
|
||
|
|
3ffb5109a7
|
||
|
|
f7a8a1df9d
|
||
|
|
2c12376262
|
||
|
|
5501384cdc
|
||
|
|
00148bcfb5
|
||
|
|
32949e7b01
|
||
|
|
b76f2d7c7e
|
@@ -0,0 +1,13 @@
|
||||
# Раскладка av-dev в этом проекте: версия и настройки проверок.
|
||||
# Файл ведут скиллы плагина, править руками можно — комментарии свои.
|
||||
|
||||
version = 4 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
|
||||
|
||||
[docs]
|
||||
# каталог миграций: по нему docs.py сверяет схему с database.md
|
||||
migrations = "internal/adapter/repo/pocketbase/migrations"
|
||||
|
||||
[tasks]
|
||||
# каталог задач от корня репозитория; имена частей — умолчания скрипта
|
||||
dir = "tasks"
|
||||
stage = "build"
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"av-dev@av-dev-skills": true,
|
||||
"av-dev-git@av-dev-skills": true
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,8 @@ task gate # весь набор проверок разом
|
||||
```
|
||||
|
||||
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` —
|
||||
скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в
|
||||
[docs/conventions/config.md](docs/conventions/config.md) строками
|
||||
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
|
||||
в [docs/conventions/config.md](docs/conventions/config.md) строками
|
||||
«*Расхождение:*».
|
||||
|
||||
## Гейт
|
||||
@@ -141,7 +141,7 @@ task gate # весь набор проверок разом
|
||||
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
|
||||
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
|
||||
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
||||
сверка версий Go, три сверки документов и `govulncheck`: они смотрят всё
|
||||
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
|
||||
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
||||
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
|
||||
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
||||
@@ -155,14 +155,14 @@ task gate # весь набор проверок разом
|
||||
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
|
||||
образ по-прежнему может только человек — `task image`, и на подъёме версии
|
||||
это обязательно;
|
||||
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и два его
|
||||
правила подавлены поимённо — `DL3007` до задачи `pin-runtime-image-base` и
|
||||
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и часть его
|
||||
правил подавлена поимённо — `DL3007` до задачи `pin-runtime-image-base` и
|
||||
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
|
||||
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
|
||||
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
|
||||
коммита. Полную историю никто не проверяет;
|
||||
- согласованность документов между собой и с кодом — её судят агенты, зовёт
|
||||
их скилл `av-dev-docs:healthcheck`, и звать его надо руками;
|
||||
их скилл `av-dev:doc-healthcheck`, и звать его надо руками;
|
||||
- покрытие изменённых строк не считается ничем.
|
||||
|
||||
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
|
||||
@@ -186,12 +186,29 @@ task gate # весь набор проверок разом
|
||||
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
|
||||
ронять и пересоздавать можно свободно.
|
||||
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
|
||||
перехватывает обновления у работающего, и пользователь теряет ответы.
|
||||
перехватывает обновления у работающего, и пользователь теряет ответы. Запускай
|
||||
с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит
|
||||
ни одного обращения, и работает он одним входом, по HTTP. Пустого
|
||||
`bot_token` для этого мало и больше не значит ничего: включён вход или нет,
|
||||
решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true`
|
||||
роняет старт. Выключенного входа
|
||||
для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но
|
||||
наружу при этом не ходят, так что годятся выдуманные непустые значения;
|
||||
подробности строками в `config.example.toml`.
|
||||
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
||||
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
||||
подставляй `internal/adapter/recognizer/memory.go`.
|
||||
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
|
||||
запускает человек.
|
||||
- **Проверок над проверками не заводить.** Уровень проверки один: линтеры и
|
||||
тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты
|
||||
на шаги гейта и на свои скрипты проверок, стражи предмета у правил,
|
||||
механизация покрытия изменённого кода, мутационная сверка оракулов и
|
||||
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
|
||||
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
|
||||
[tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать
|
||||
сценариев шага сверки версий Go, единственный такой файл в проекте.
|
||||
Исключений у запрета нет.
|
||||
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
|
||||
временном каталоге и убирают за собой.
|
||||
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
|
||||
@@ -206,9 +223,9 @@ task gate # весь набор проверок разом
|
||||
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
|
||||
секрета.
|
||||
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
|
||||
твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга
|
||||
из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли
|
||||
задачами.
|
||||
твоей правки. Такое чинится прежде любой другой работы. Исключений из этого
|
||||
правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать
|
||||
красный шаг больше не на что.
|
||||
- **Ориентир по размеру порции:** не замерялся.
|
||||
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
|
||||
поимённо.
|
||||
@@ -218,3 +235,18 @@ task gate # весь набор проверок разом
|
||||
- Документация, комментарии, сообщения коммитов — русский.
|
||||
- Код и идентификаторы — английский.
|
||||
- Текст, который видит пользователь Telegram, — русский.
|
||||
- **Точного числа накопленного в документах нет.** «Три capability», «пять
|
||||
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
|
||||
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
|
||||
такое не считает, а читатель верит написанному. Ссылаться можно только на
|
||||
**конкретную запись** (по имени, со ссылкой) либо на **весь корпус разом**
|
||||
(«заведённые capability», «записи журнала ниже»). Само перечисление при этом
|
||||
законно: перечень обновляют вместе с предметом, а число живёт отдельно от него
|
||||
и потому протухает в одиночку.
|
||||
|
||||
*Изъятие:* число, которое не растёт с работой, остаётся числом — количество
|
||||
уровней журнала в библиотеке, ступеней сборки образа, состояний списка на
|
||||
экране. Так же законно **историческое** число в записи о прошлом: «решением от
|
||||
2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт.
|
||||
Настройки с числовым значением — свой случай, их дом
|
||||
[docs/database.md](docs/database.md).
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
```
|
||||
3. Скопируйте образец конфига и заполните его:
|
||||
```bash
|
||||
cp config.dist.toml config.toml
|
||||
cp config.example.toml config.toml
|
||||
```
|
||||
4. Запустите приложение:
|
||||
```bash
|
||||
@@ -62,9 +62,13 @@ inv pl -- transcriber
|
||||
|
||||
## HTTP API
|
||||
|
||||
Четыре маршрута: `POST /api/audio` — приём записи, `GET /api/status/:id` —
|
||||
готовность задачи, `GET /metrics` — метрики Prometheus с префиксом
|
||||
`transcriber_`, `GET /health` — проверка живости.
|
||||
Семь адресов приложения: `POST /api/audio` — приём записи, `GET /api/status/:id`
|
||||
— готовность задачи, `GET /auth/login`, `GET /auth/callback` и
|
||||
`POST /auth/logout` — вход через провайдера
|
||||
([access](openspec/specs/access/spec.md)), `GET /metrics` — метрики Prometheus с
|
||||
префиксом `transcriber_`, `GET /health` — проверка живости. Сверх них тем же
|
||||
портом отдаётся собственная поверхность встроенного хранилища и панель `/_/` —
|
||||
[docs/security.md](docs/security.md), «Из чего строятся пути и ключи».
|
||||
|
||||
Контракт приёма и опроса нормативен и живёт в
|
||||
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
|
||||
@@ -74,7 +78,7 @@ inv pl -- transcriber
|
||||
## Состояния задач
|
||||
|
||||
Перечень состояний, переходы между ними и число воркеров —
|
||||
[docs/database.md](docs/database.md), разделы «Таблицы» и «Представление
|
||||
[docs/database.md](docs/database.md), разделы «Коллекции» и «Представление
|
||||
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Структура проекта
|
||||
|
||||
+13
-12
@@ -15,9 +15,9 @@ vars:
|
||||
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
|
||||
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
|
||||
# по коду скрипта, а не по коду `task`.
|
||||
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}'
|
||||
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}'
|
||||
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}'
|
||||
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/canon/scripts/docs.py"}}'
|
||||
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/task-track/scripts/tasks.py"}}'
|
||||
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/code-openspec/scripts/openspec.py"}}'
|
||||
|
||||
tasks:
|
||||
|
||||
@@ -89,19 +89,20 @@ tasks:
|
||||
echo "задай свою: task migrations BASE=<rev>"
|
||||
exit 3
|
||||
fi
|
||||
# Каталог шагов берётся из docs/.docs.json — там он уже записан ключом
|
||||
# `migrations` для сверки документов. Свой литерал завёл бы факту второй
|
||||
# дом: каталог переехал бы, а один из двух стражей молча позеленел.
|
||||
dir=$(python3 -c 'import json,sys; print(json.load(open("docs/.docs.json"))["migrations"])' 2>/dev/null) || dir=""
|
||||
# Каталог шагов берётся из .av-dev.toml — там он уже записан ключом
|
||||
# `migrations` секции `[docs]` для сверки документов. Свой литерал завёл
|
||||
# бы факту второй дом: каталог переехал бы, а один из двух стражей молча
|
||||
# позеленел. До слияния плагинов файл звался docs/.docs.json.
|
||||
dir=$(python3 -c 'import tomllib; print(tomllib.load(open(".av-dev.toml","rb"))["docs"]["migrations"])' 2>/dev/null) || dir=""
|
||||
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||
echo "каталог шагов схемы не найден: ключ migrations в docs/.docs.json → '$dir'"
|
||||
echo "каталог шагов схемы не найден: ключ [docs] migrations в .av-dev.toml → '$dir'"
|
||||
exit 3
|
||||
fi
|
||||
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
|
||||
# одного переименования — тот же приём, что у правил `internal/archrules`.
|
||||
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
|
||||
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
|
||||
echo "поправь шаблон имени в этом шаге либо ключ migrations в docs/.docs.json"
|
||||
echo "поправь шаблон имени в этом шаге либо ключ [docs] migrations в .av-dev.toml"
|
||||
exit 3
|
||||
fi
|
||||
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
|
||||
@@ -175,7 +176,7 @@ tasks:
|
||||
py=$(eval echo {{.DOCS_PY}})
|
||||
if [ ! -f "$py" ]; then
|
||||
echo "docs.py не найден: $py"
|
||||
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
|
||||
echo "поставь плагин av-dev либо задай путь: task docs DOCS_PY=<путь>"
|
||||
exit 3
|
||||
fi
|
||||
python3 "$py" check --base {{.BASE}}
|
||||
@@ -187,7 +188,7 @@ tasks:
|
||||
py=$(eval echo {{.TASKS_PY}})
|
||||
if [ ! -f "$py" ]; then
|
||||
echo "tasks.py не найден: $py"
|
||||
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
|
||||
echo "поставь плагин av-dev либо задай путь: task tasks TASKS_PY=<путь>"
|
||||
exit 3
|
||||
fi
|
||||
python3 "$py" check --dir tasks
|
||||
@@ -199,7 +200,7 @@ tasks:
|
||||
py=$(eval echo {{.OPENSPEC_PY}})
|
||||
if [ ! -f "$py" ]; then
|
||||
echo "openspec.py не найден: $py"
|
||||
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
||||
echo "поставь плагин av-dev либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
||||
exit 3
|
||||
fi
|
||||
python3 "$py" check --dir .
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# Server configuration
|
||||
[server]
|
||||
port = 8080
|
||||
shutdown_timeout = 5
|
||||
force_shutdown_timeout = 20
|
||||
|
||||
# Storage configuration
|
||||
# Единственный каталог данных: под ним лежат и база, и файлы записей.
|
||||
[storage]
|
||||
data_dir = "data"
|
||||
|
||||
# Yandex Cloud Configuration
|
||||
[yandex]
|
||||
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
|
||||
folder_id = "your_folder_id_here"
|
||||
|
||||
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
|
||||
speech_kit_api_key = "your_speech_kit_api_key_here"
|
||||
|
||||
# Object Storage (S3) configuration
|
||||
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
|
||||
object_storage_access_key_id = "your_access_key_id"
|
||||
|
||||
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
|
||||
object_storage_secret_access_key = "your_secret_access_key"
|
||||
|
||||
# Имя бакета в Object Storage
|
||||
object_storage_bucket_name = "your_bucket_name"
|
||||
|
||||
# Регион Object Storage
|
||||
object_storage_region = "ru-central1"
|
||||
|
||||
# Endpoint Object Storage
|
||||
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
||||
|
||||
# Вход через внешнего провайдера OIDC (Authelia).
|
||||
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
|
||||
# API открытым наружу.
|
||||
[auth]
|
||||
# Адрес, куда сервис уводит человека на вход
|
||||
auth_url = "https://auth.example.com/api/oidc/authorization"
|
||||
|
||||
# Адрес, где код обменивается на токен
|
||||
token_url = "https://auth.example.com/api/oidc/token"
|
||||
|
||||
# Адрес, откуда берутся сведения о вошедшем
|
||||
user_info_url = "https://auth.example.com/api/oidc/userinfo"
|
||||
|
||||
# Идентификатор клиента, заведённого у провайдера
|
||||
client_id = "transcriber"
|
||||
|
||||
# Секрет клиента; приходит из выкладки, в git не коммитится
|
||||
client_secret = ""
|
||||
|
||||
# Адрес возврата; тот же, что записан клиенту у провайдера
|
||||
redirect_url = "https://transcriber.example.com/auth/callback"
|
||||
|
||||
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
|
||||
# запуска по http://localhost, где браузер такую куку не сохранит
|
||||
secure_cookie = true
|
||||
|
||||
# Telegram Bot Configuration
|
||||
[telegram]
|
||||
# Токен Telegram бота (получить у @BotFather в Telegram)
|
||||
bot_token = "your_telegram_bot_token_here"
|
||||
|
||||
# Таймаут обновлений Telegram бота (в секундах)
|
||||
update_timeout = 10
|
||||
@@ -0,0 +1,98 @@
|
||||
# Server configuration
|
||||
[server]
|
||||
port = 8080
|
||||
shutdown_timeout = 5
|
||||
force_shutdown_timeout = 20
|
||||
|
||||
# Storage configuration
|
||||
# Единственный каталог данных: под ним лежат и база, и файлы записей.
|
||||
[storage]
|
||||
data_dir = "data"
|
||||
|
||||
# Yandex Cloud Configuration
|
||||
[yandex]
|
||||
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
|
||||
folder_id = "your_folder_id_here"
|
||||
|
||||
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
|
||||
speech_kit_api_key = "your_speech_kit_api_key_here"
|
||||
|
||||
# Object Storage (S3) configuration
|
||||
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
|
||||
object_storage_access_key_id = "your_access_key_id"
|
||||
|
||||
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
|
||||
object_storage_secret_access_key = "your_secret_access_key"
|
||||
|
||||
# Имя бакета в Object Storage
|
||||
object_storage_bucket_name = "your_bucket_name"
|
||||
|
||||
# Регион Object Storage
|
||||
object_storage_region = "ru-central1"
|
||||
|
||||
# Endpoint Object Storage
|
||||
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
||||
|
||||
# Вход через внешнего провайдера OIDC (Authelia).
|
||||
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
|
||||
# API открытым наружу.
|
||||
[auth]
|
||||
# Адрес, куда сервис уводит человека на вход
|
||||
auth_url = "https://auth.example.com/api/oidc/authorization"
|
||||
|
||||
# Адрес, где код обменивается на токен
|
||||
token_url = "https://auth.example.com/api/oidc/token"
|
||||
|
||||
# Адрес, откуда берутся сведения о вошедшем
|
||||
user_info_url = "https://auth.example.com/api/oidc/userinfo"
|
||||
|
||||
# Идентификатор клиента, заведённого у провайдера
|
||||
client_id = "transcriber"
|
||||
|
||||
# Секрет клиента; приходит из выкладки, в git не коммитится
|
||||
client_secret = ""
|
||||
|
||||
# Адрес возврата; тот же, что записан клиенту у провайдера
|
||||
redirect_url = "https://transcriber.example.com/auth/callback"
|
||||
|
||||
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
|
||||
# запуска по http://localhost, где браузер такую куку не сохранит
|
||||
secure_cookie = true
|
||||
|
||||
# Telegram Bot Configuration
|
||||
[telegram]
|
||||
# Нужен ли сервису вход Telegram. Ключ **обязателен**: умолчания у него нет, и
|
||||
# файл без него негоден — сервис выходит с ошибкой настройки, назвав недостающий
|
||||
# ключ. Умолчание было бы угаданным намерением, а признак заведён затем, чтобы
|
||||
# намерение объявляли: любое умолчание делает одну из двух ошибок тихой — либо
|
||||
# бот молча пропадает, либо файл без признака молча работает.
|
||||
#
|
||||
# false — сервис поднимается без Telegram и работает одним входом, по HTTP. Бот
|
||||
# не заводится, к Telegram не уходит ни одного обращения, записи из Telegram не
|
||||
# принимаются, а ответы на задачи, принятые оттуда прежде, не уходят —
|
||||
# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP.
|
||||
# О выключенном входе сервис говорит одной записью журнала «к сведению»: это
|
||||
# выбор владельца, а не отклонение.
|
||||
#
|
||||
# true — сервис поднимает бота. Пустой bot_token при этом роняет старт: бота по
|
||||
# пустому ключу не существует. Старт роняет и ответ Telegram «такого бота нет» —
|
||||
# это опечатка в ключе, ждать тут нечего. А вот недоступность Telegram (сеть,
|
||||
# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и предупреждает
|
||||
# записью журнала, потому что основной вход у него другой.
|
||||
#
|
||||
# Локальный прогон идёт с false — боевым токеном запускаться запрещено: второй
|
||||
# процесс с тем же токеном перехватывает обновления у работающего. Выключенного
|
||||
# входа для подъёма мало: секции [auth] и [yandex] проверяются на старте и
|
||||
# роняют процесс на пустых ключах. Наружу при старте не ходит ни одна из них,
|
||||
# поэтому для локального прогона годятся выдуманные непустые значения — адреса
|
||||
# [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах
|
||||
# не работает: её подменяют в коде.
|
||||
enabled = false
|
||||
|
||||
# Токен Telegram бота (получить у @BotFather в Telegram). Только ключ доступа:
|
||||
# включением входа он больше не заведует, этим занят enabled выше. При
|
||||
# enabled = false не читается вовсе.
|
||||
bot_token = ""
|
||||
|
||||
# Таймаут обновлений Telegram бота (в секундах)
|
||||
update_timeout = 10
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"canon": 14,
|
||||
"migrations": "internal/adapter/repo/pocketbase/migrations"
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решение 2
|
||||
- **Статус:** устарело — 2026-08-13 владелец решил обратное: инструментарию в
|
||||
спеках не место. Capability `toolchain` упразднена, замены у неё нет, а норма
|
||||
шага осталась комментариями в `scripts/check-go-version.sh`
|
||||
|
||||
## Решение
|
||||
|
||||
@@ -10,8 +13,9 @@
|
||||
собирают. Потребитель у неё другой: тот, кто собирает.
|
||||
|
||||
Требование о согласованности объявленной версии Go живёт нормой в
|
||||
[openspec/specs/toolchain/spec.md](../../openspec/specs/toolchain/spec.md), а не
|
||||
прозой в памятке.
|
||||
`openspec/specs/toolchain/spec.md`, а не прозой в памятке. *Уточнено 2026-08-13:
|
||||
файла по этому адресу больше нет, ссылка снята — capability упразднена, см.
|
||||
статус записи.*
|
||||
|
||||
## Почему
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Намерение объявляется признаком, а не выводится из ключа доступа
|
||||
|
||||
- **Дата:** 2026-08-13
|
||||
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Вход Telegram включается отдельным признаком `telegram.enabled`, а `bot_token`
|
||||
означает только доступ. Признак **обязателен**: умолчания у него нет, и файл
|
||||
настроек без него негоден — сервис выходит с ошибкой настройки, назвав
|
||||
недостающий ключ.
|
||||
|
||||
## Почему
|
||||
|
||||
Прежде пустой ключ доступа значил разом две вещи — «вход выключен намеренно» и
|
||||
«ключа нет», — и сервис поднимался без бота в обоих случаях. Цена расхождения
|
||||
падала на выкладку: файл настроек собирает Ansible, и потерянный при сборке ключ
|
||||
выглядел для сервиса как решение владельца.
|
||||
|
||||
Умолчания у признака нет, и это **намеренный отказ от очевидного подхода** —
|
||||
булев ключ обычно заводят с умолчанием. Цитата из источника:
|
||||
|
||||
> умолчание — это угаданное намерение, а признак заводится ровно затем, чтобы
|
||||
> намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
|
||||
> стороны, и любое умолчание делает одну из двух ошибок тихой.
|
||||
|
||||
Отвергнуты оба умолчания. «Включён» — файл без признака работал бы «как-нибудь»,
|
||||
и разница между объявленным и угаданным намерением исчезала бы ровно там, где её
|
||||
завели. «Выключен» — первый же подъём после выкладки выключил бы бота молча, то
|
||||
есть дал бы исход, против которого написано само требование.
|
||||
|
||||
Отсутствие ключа судит **разбор**, а не значение: `toml.MetaData.IsDefined`
|
||||
отличает «не задан» от «задан ложным», тогда как нулевое значение `bool` у обоих
|
||||
одинаковое. Форма поля с указателем отвергнута: указатель пережил бы проверку и
|
||||
уехал к потребителям, где `nil` уже невозможен, но выглядит возможным.
|
||||
|
||||
Тем же решением закрыт разрез текста отказа при разборе файла настроек. Цитата
|
||||
из источника:
|
||||
|
||||
> Пересказывать библиотеку нельзя: она собирает текст отказа из разбираемого
|
||||
> куска файла, и оборванная строка секретного ключа уехала бы в журнал вместе со
|
||||
> значением.
|
||||
|
||||
Норму держит инвариант «Секрет не покидает конфиг», а форму записи — конвенция
|
||||
настроек. Спеки загрузку настроек не нормируют, и это назначено явно: загрузка
|
||||
не принадлежит ни одной заведённой capability.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` потерянный при сборке файла ключ доступа роняет старт вслух, а не оставляет
|
||||
сервис работать в половину силы;
|
||||
- `+` выключенный вход перестал быть поводом для предупреждения: решение
|
||||
владельца сообщается записью «к сведению», а предупреждение осталось за тем,
|
||||
чего владелец не выбирал, — недоступностью Telegram;
|
||||
- `+` оборванная строка секретного ключа больше не уносит значение в журнал
|
||||
контейнера;
|
||||
- `−` **порядок выкладки стал обязательным**: шаблон настроек обязан получить
|
||||
признак раньше накатки образа, иначе сервис не поднимется вовсе. Правило живёт
|
||||
в [architecture.md](../architecture.md), раздел «Эксплуатация», и задаётся там
|
||||
по ключу, а не по файлу целиком;
|
||||
- `−` один путь молчаливой потери бота остался: признак, ошибочно собранный
|
||||
как «выключен», отличим от решения владельца только записью журнала. Признак
|
||||
поднятости входа тут не помощник — он равен нулю и при недоступности Telegram;
|
||||
- `−` отказ разбора файла настроек стал беднее на текст библиотеки: место и ключ
|
||||
названы, а что именно в строке не так — нет. Плата принята ради инварианта,
|
||||
помеченного необратимым.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Недоступность Telegram подъёму сервиса не мешает
|
||||
|
||||
- **Дата:** 2026-08-13
|
||||
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Старт роняет только один исход сборки клиента бота — ответ Telegram «такого бота
|
||||
нет». Всё прочее, включая недоступность Telegram и истёкший срок ожидания, даёт
|
||||
подъём без Telegram: сервис работает по HTTP и говорит о неподнятом входе
|
||||
записью журнала и метрикой.
|
||||
|
||||
## Почему
|
||||
|
||||
Очевидный подход был обратный, и он же стоял в первой редакции дизайна: любой
|
||||
отказ сборки бота роняет старт, потому что «сервис, молча потерявший бота после
|
||||
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
|
||||
бы неоткуда».
|
||||
|
||||
Ревью кода показало цену этого подхода. Цитата из источника:
|
||||
|
||||
> при `api.telegram.org`, отвечающем молчанием, процесс висит в `getMe` без
|
||||
> ограничения времени: HTTP-вход не открыт, панель не открыта, `/health` не
|
||||
> отвечает вовсе, воркеры не запущены, в журнале — ни строки.
|
||||
|
||||
То есть перезапуск в минуту чужой аварии оставлял без работы приём по HTTP,
|
||||
панель и конвейер, которому Telegram не нужен вовсе. Паспорт при этом называет
|
||||
основным входом приложение, а бот и HTTP API — дополняющими его.
|
||||
|
||||
Тем же ревью снят довод, на котором держалась прежняя редакция. Она утверждала,
|
||||
что «Telegram не признал бота» и «до Telegram не дошли» различать нечем. Цитата
|
||||
из источника:
|
||||
|
||||
> Различать есть чем: ответ Bot API приезжает своим типом с кодом, транспортный
|
||||
> отказ — нашим после чистки, и одно от другого отделяется проверкой типа.
|
||||
> Утверждение держалось на незнании библиотеки, а не на её устройстве.
|
||||
|
||||
Решение владельца: недоступность Telegram на старт приложения не влияет.
|
||||
|
||||
Из него следует второе, без которого оно невыполнимо: ожидание при сборке
|
||||
ограничено сроком. Пока срока не было, недоступность не отличалась от подъёма.
|
||||
Срок стоит только на сборке — длинный опрос им не ограничен, иначе он рвался бы
|
||||
на каждом круге.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` авария Telegram не роняет основной вход, панель и конвейер: сервис
|
||||
поднимается и обрабатывает уже принятое;
|
||||
- `+` опечатка в токене по-прежнему заметна: Telegram отвечает отказом, и старт
|
||||
не проходит;
|
||||
- `+` молчащий Telegram больше не вешает подъём бессрочно;
|
||||
- `−` долгая недоступность Telegram даёт сервис, работающий без бота, а
|
||||
отправители в это время не получают ответов. Замена «узнать неоткуда» —
|
||||
запись журнала при старте и признак поднятости входа метрикой;
|
||||
- `−` токен, не разбирающийся как часть адреса (перенос строки из шаблона
|
||||
выкладки), Telegram не отвергает — его отвергает разбор адреса, и такой случай
|
||||
попадает в недоступность, а не в ошибку настройки. Заметен он записью журнала,
|
||||
а не отказом старта.
|
||||
|
||||
*Уточнено 2026-08-13:* исходов сборки клиента, роняющих старт, стало два —
|
||||
к ответу «такого бота нет» добавился пустой ключ доступа при включённом входе.
|
||||
Решение это не меняет: пустой ключ ошибкой настройки и был, просто прежде он
|
||||
выражал ещё и отказ от входа, а теперь отказ выражает признак `telegram.enabled`
|
||||
и до сборки клиента не доходит вовсе. Недоступность Telegram по-прежнему подъёму
|
||||
не мешает — ровно как решено здесь. Разведение двух значений — отдельная запись,
|
||||
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md).
|
||||
+7
-2
@@ -21,7 +21,10 @@
|
||||
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
|
||||
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
|
||||
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
|
||||
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
|
||||
- Записи неизменяемы **в решении**: передумали — новая запись, старой ставится
|
||||
статус. Уточнить прежнюю запись можно только строкой «*Уточнено ГГГГ-ММ-ДД:*» в
|
||||
разделе «Последствия» и только фактом, который решения не меняет, — например
|
||||
действующим адресом того, что решение завело.
|
||||
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
|
||||
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
|
||||
источником, а не абзацем в теле.
|
||||
@@ -32,11 +35,13 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
|
||||
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
|
||||
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | |
|
||||
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
|
||||
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
|
||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
|
||||
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
|
||||
|
||||
+60
-32
@@ -5,23 +5,29 @@
|
||||
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
|
||||
|
||||
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
|
||||
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
||||
[passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из
|
||||
этого ещё не решено — в разделе «Открытые вопросы».
|
||||
|
||||
Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его
|
||||
потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а
|
||||
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает.
|
||||
Заведённые capability нормируют **поведение сервиса** для его потребителей —
|
||||
все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе:
|
||||
у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением
|
||||
от 2026-08-13 его нормы живут в самих шагах, их проверках и
|
||||
[conventions/go-linters.md](conventions/go-linters.md).
|
||||
|
||||
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и
|
||||
опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала,
|
||||
метка метрики несёт только известное расширение. Задачи
|
||||
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
|
||||
входов**: приём и опрос за сессией, имя отправителя не доходит ни до
|
||||
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
|
||||
выключенный вход Telegram не мешает подъёму. Задачи
|
||||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||
`pocketbase-storage` и `oidc-login` 2026-08-12. Приём из Telegram здесь не
|
||||
описан;
|
||||
`pocketbase-storage` и `oidc-login` 2026-08-12,
|
||||
`local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу —
|
||||
кто допущен и как забирается запись — здесь по-прежнему не описан;
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
|
||||
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
|
||||
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются
|
||||
задачи и срок его протухания, число попыток, состояние «мертва», пауза перед
|
||||
повтором и недоставленный ответ отправителю: задачи
|
||||
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и
|
||||
`local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена
|
||||
контекста посреди шага остаются
|
||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||||
@@ -30,11 +36,7 @@
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
|
||||
видит всё, что видел прежде аноним;
|
||||
- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой
|
||||
его версии собирается сервис: одно число версии Go во всех местах, где она
|
||||
названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade`
|
||||
2026-08-12.
|
||||
видит всё, что видел прежде аноним.
|
||||
|
||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
||||
@@ -68,7 +70,7 @@
|
||||
|
||||
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
||||
|
||||
<!-- канон: поведение → openspec/specs/intake, delivery -->
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам -->
|
||||
|
||||
| Компонент | Где | Что делает |
|
||||
| --- | --- | --- |
|
||||
@@ -81,14 +83,15 @@
|
||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
|
||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
||||
|
||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний -->
|
||||
|
||||
Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Три
|
||||
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача,
|
||||
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг,
|
||||
а тот, кто её захватил.
|
||||
Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Каждый
|
||||
переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что
|
||||
делает задача, исчерпавшая попытки, нормирует
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
|
||||
«мертва»».
|
||||
|
||||
## Внешние границы и форматы
|
||||
|
||||
@@ -111,15 +114,35 @@
|
||||
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
||||
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
||||
обратный прокси, который публикует HTTP-порт наружу.
|
||||
- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила
|
||||
«сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют
|
||||
противоположного, и оба правила действуют одновременно.
|
||||
- **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он
|
||||
обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт,
|
||||
без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и
|
||||
конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя
|
||||
правка конфига безопасна, а поздняя роняет сервис.
|
||||
- **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы
|
||||
старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта.
|
||||
- **Откат при выключенном входе** допустим только на образ от 2026-08-13 и
|
||||
новее. На более старом состояния «сервис поднят, бот опущен» не существует
|
||||
вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает
|
||||
бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется.
|
||||
- **Откат образа при `enabled = false` и заполненном ключе** отменяет решение
|
||||
владельца молча: прежний образ признака не видит и поднимает бота. Если вход
|
||||
был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса
|
||||
поделят один длинный опрос и часть ответов до людей не дойдёт.
|
||||
|
||||
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
|
||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||
|
||||
<!-- канон: поведение → openspec/specs/conversion, recognition -->
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline -->
|
||||
|
||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
||||
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
@@ -132,7 +155,7 @@
|
||||
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
|
||||
Отдельного оповещения нет.
|
||||
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
||||
три воркера опрашивают базу вхолостую с паузой из
|
||||
воркеры опрашивают базу вхолостую с паузой из
|
||||
[database.md](database.md), «Настройки с числовым значением».
|
||||
|
||||
## Единые точки проекта
|
||||
@@ -191,8 +214,11 @@
|
||||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||||
[security.md](security.md).
|
||||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
|
||||
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный
|
||||
потолок проекта — шесть часов, и он взят с запасом, а не замером.
|
||||
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные
|
||||
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||||
записи живёт в хранилище»; откуда взято число —
|
||||
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||||
записка не узнала».
|
||||
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
||||
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
||||
обрыв начинает загрузку заново.
|
||||
@@ -216,9 +242,11 @@
|
||||
конвертер этот случай не проверялся.
|
||||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
|
||||
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера
|
||||
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что
|
||||
это обходится, никто не мерил.
|
||||
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
|
||||
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
|
||||
паузы, а не замер
|
||||
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
|
||||
нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
|
||||
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
||||
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
||||
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
||||
|
||||
@@ -21,10 +21,11 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
|
||||
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg` —
|
||||
предложение с заглавной буквы вместо константной категории.
|
||||
|
||||
Из этого перечня закрыты два. Доменные ошибки проверялись приведением типа до
|
||||
Часть перечня закрыта. Доменные ошибки проверялись приведением типа до
|
||||
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
|
||||
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
|
||||
держит линтер. Оба места больше не долг, а регрессия.
|
||||
держит линтер. Образец конфига звался `config.dist.toml` до 2026-08-14, задача
|
||||
`config-example-toml`. Эти места больше не долг, а регрессия.
|
||||
|
||||
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
||||
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
||||
@@ -42,16 +43,15 @@ htmx, а здесь решено делать SPA — и перенесённы
|
||||
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
|
||||
типизированной.
|
||||
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
|
||||
`0600`, самодокументируемый `config.dist.toml`, проверка на старте.
|
||||
`0600`, самодокументируемый `config.example.toml`, проверка на старте.
|
||||
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
|
||||
ULID, разбор на входной границе, естественные ключи у деталей.
|
||||
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
|
||||
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
|
||||
над `fetch`, показ ошибок и состояний списка.
|
||||
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: лестница
|
||||
механизации, два круга (pre-commit и гейт), перечень правил и подавлений,
|
||||
порядок заведения нового правила. Про инструменты, а не про то, как писать
|
||||
тесты.
|
||||
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: два круга
|
||||
(pre-commit и гейт), перечень правил и подавлений, порядок заведения нового
|
||||
правила. Про инструменты, а не про то, как писать тесты.
|
||||
|
||||
## Что из этого проверяет машина
|
||||
|
||||
|
||||
+49
-20
@@ -4,9 +4,9 @@
|
||||
Правила оформления кода (How), не спецификация поведения.
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главные: образец называется `config.dist.toml`, а не `config.example.toml`;
|
||||
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
|
||||
проверки пустых ключей внутри адаптеров.
|
||||
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые
|
||||
ключи `[yandex]` ловит конструктор распознавателя.
|
||||
|
||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
||||
@@ -29,13 +29,13 @@
|
||||
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
|
||||
процесса.
|
||||
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
|
||||
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный
|
||||
- Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
|
||||
`config.toml` не коммитится.
|
||||
|
||||
## config.dist.toml — самодокументируемый образец
|
||||
## config.example.toml — самодокументируемый образец
|
||||
|
||||
`config.dist.toml` коммитим как единый справочник по конфигу: все секции и все
|
||||
поля. **Каждое поле снабжаем комментарием**, из которого ясно:
|
||||
`config.example.toml` коммитим как единый справочник по конфигу: все секции и
|
||||
все поля. **Каждое поле снабжаем комментарием**, из которого ясно:
|
||||
|
||||
- **зачем** поле — что оно меняет в поведении;
|
||||
- **допустимые значения** — перечисление или границы;
|
||||
@@ -53,12 +53,12 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
||||
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
|
||||
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
|
||||
одним домом — таблица «Настройки с числовым значением» в
|
||||
[../database.md](../database.md); `config.dist.toml` — источник истины по составу
|
||||
полей.
|
||||
[../database.md](../database.md); `config.example.toml` — источник истины по
|
||||
составу полей.
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||
|
||||
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
|
||||
*Расхождение:* секции `[server]` в `config.example.toml` не хватает поля
|
||||
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
||||
|
||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||
@@ -75,7 +75,7 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
||||
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
|
||||
обязательных полей; поля других значений не требуются. Неизвестное значение —
|
||||
ошибка на старте с перечислением поддерживаемых.
|
||||
- **Образец — по `type`.** В `config.dist.toml`:
|
||||
- **Образец — по `type`.** В `config.example.toml`:
|
||||
- основное (умолчательное) значение **предзаполнено** рабочими значениями;
|
||||
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
|
||||
полей (зачем, границы, единицы — как у обычных полей);
|
||||
@@ -96,12 +96,23 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
владелец — пользователь процесса (`1000:1000`).
|
||||
- В `config.dist.toml` секретные поля — пустые строки.
|
||||
- В `config.example.toml` секретные поля — пустые строки.
|
||||
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
|
||||
строки, и загрузчик их не отличает от настоящего значения.
|
||||
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
|
||||
отрендеренный файл) — см. «Проверка и остановка на старте».
|
||||
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
|
||||
- **Отказ загрузки настроек не несёт содержимого файла.** Текст такого отказа
|
||||
собирает библиотека разбора, и собирает она его из разбираемого куска:
|
||||
`toml.ParseError` кладёт в сообщение само значение («Invalid float value: %q»).
|
||||
Оборванная кавычка в строке секретного ключа — типовая поломка криво
|
||||
отрендеренного шаблона выкладки — уносит ключ в журнал контейнера целиком, а
|
||||
инвариант «секрет не покидает конфиг» помечен необратимым. Поэтому отказ
|
||||
разбора пересобирается своими словами: путь, строка, столбец и последний ключ,
|
||||
без сообщения библиотеки. Прочие отказы декодера (несовпадение типов,
|
||||
неподдерживаемый тип) собраны из имён ключей и типов, значений в них нет, и их
|
||||
текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
|
||||
платить не за что.
|
||||
|
||||
## Проверка и остановка на старте
|
||||
|
||||
@@ -116,10 +127,19 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
- ключи внешних сервисов не пусты.
|
||||
|
||||
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
|
||||
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и
|
||||
приложение продолжает работу без бота; пустые ключи Yandex ловятся в
|
||||
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
|
||||
места проверки нет.
|
||||
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
|
||||
выходит с кодом 1. Единого места проверки нет.
|
||||
|
||||
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак
|
||||
включения и ключ доступа, — и проверок у них две. Третий ключ секции,
|
||||
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
|
||||
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
|
||||
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
|
||||
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
|
||||
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true` —
|
||||
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
|
||||
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
|
||||
включения решает, поднимается ли вход Telegram».
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
||||
@@ -131,10 +151,19 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
|
||||
## Структура в коде
|
||||
|
||||
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
||||
- Одна корневая структура `Config` с под-структурами по секциям. Перечень секций
|
||||
и полей здесь не повторяем: источник истины по составу — `config.dist.toml`,
|
||||
действующие числа — [../database.md](../database.md), «Настройки с числовым
|
||||
значением». Каталог данных задаётся одним ключом `[storage] data_dir`
|
||||
- Одна корневая структура `Config` с под-структурами по секциям. Перечень
|
||||
секций и полей здесь не повторяем: источник истины по составу —
|
||||
`config.example.toml`, действующие числа — [../database.md](../database.md),
|
||||
«Настройки с числовым значением». Каталог данных задаётся одним ключом
|
||||
`[storage] data_dir`
|
||||
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
|
||||
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
||||
требует правки обоих мест.
|
||||
- **Обязательное поле — поле, у которого умолчания нет намеренно.** Умолчание у
|
||||
такого поля было бы угаданным намерением, и одна из двух ошибок стала бы
|
||||
тихой. Форма записи: умолчания нет ни в `defaultConfig()` (причина — строкой
|
||||
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
|
||||
судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что
|
||||
значение отличить «не задано» от «задано нулём» не позволяет. В
|
||||
`config.example.toml` у поля стоит значение свежей установки. Первое такое
|
||||
поле — `telegram.enabled`.
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||
вставка падает громко. Измерение длительности — не метка времени.
|
||||
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`,
|
||||
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»).
|
||||
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид
|
||||
хранилища не меняем — сравнение строк в сыром запросе побайтово, и
|
||||
разошедшийся вид молча обращает условие срока захвата в константу.
|
||||
- Миграции — шаги PocketBase на Go
|
||||
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
|
||||
поля заводятся кодом. При изменении структуры обновляем схему
|
||||
|
||||
@@ -65,9 +65,15 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
|
||||
где хватает sentinel.
|
||||
|
||||
Сегодня в проекте три типизированные ошибки, и данные несёт только одна:
|
||||
Типизированные ошибки проекта несут данные все до одной:
|
||||
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
||||
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel).
|
||||
(состояние), `contract.LostAcquisitionError` (идентификатор задачи).
|
||||
|
||||
`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило —
|
||||
тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13;
|
||||
его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт
|
||||
`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка
|
||||
отправителя не знает ни задачи, ни чата, и нести ей нечего.
|
||||
|
||||
## Граница и трансляция: приватный и публичный канал
|
||||
|
||||
|
||||
@@ -9,17 +9,16 @@
|
||||
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
|
||||
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
|
||||
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
|
||||
обязан проверять, и там же записано требование, чтобы проверка была **способна
|
||||
упасть**. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
|
||||
обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
|
||||
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
|
||||
исходники.
|
||||
|
||||
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
|
||||
будет своя запись, а лестница и два круга останутся общими.
|
||||
будет своя запись, а два круга останутся общими.
|
||||
|
||||
Устройство ниже **переносимо**: разделы «Лестница механизации», «Два круга» и
|
||||
«Как заводят новое правило» — не особенность transcriber и переносятся в другой
|
||||
Go-проект как есть. Своё здесь — перечень правил и подавлений.
|
||||
Устройство ниже **переносимо**: разделы «Два круга» и «Как заводят новое
|
||||
правило» — не особенность transcriber и переносятся в другой Go-проект как есть.
|
||||
Своё здесь — перечень правил и подавлений.
|
||||
|
||||
## Границы: где что живёт
|
||||
|
||||
@@ -36,45 +35,17 @@ Go-проект как есть. Своё здесь — перечень пра
|
||||
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
|
||||
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
|
||||
спрашивать уже не нужно;
|
||||
- **поведение сервиса** — нормативные спеки `openspec/specs/`. У шага сверки
|
||||
версий Go поведение нормировано отдельно, спекой
|
||||
[toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка
|
||||
проекта, у которой есть своя capability, и потому единственная, чьи сценарии
|
||||
проверяются построчно (`scripts/check_go_version_test.go`). Второй самодельный
|
||||
шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах
|
||||
- **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
|
||||
проверок туда не входят: инструментарий спеками не нормируется, и спека
|
||||
`toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего
|
||||
дома у нормы этого шага теперь нет вовсе — она живёт комментариями в
|
||||
`scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены
|
||||
тем же решением. Второй самодельный
|
||||
шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах
|
||||
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
||||
него нет, и дрейф его собственного шаблона имени никто не поймает. Это
|
||||
объявленный долг, а не умолчание.
|
||||
|
||||
## Лестница механизации
|
||||
|
||||
Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому,
|
||||
чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже
|
||||
в заведении, поэтому прыгать через ступень без нужды не надо.
|
||||
|
||||
1. **Проза конвенции.** Свойство названо словами, проверяет человек на каждом
|
||||
ревью заново. Это ступень по умолчанию и худшая из всех: она стоит внимания
|
||||
каждого прогона и молча перестаёт работать, когда внимание кончилось.
|
||||
2. **Настройка готового линтера.** Свойство совпало с чужим правилом —
|
||||
включается строкой в `.golangci.yml`. Дешевле всего; ограничение в том, что
|
||||
правило чужое и говорит о том, о чём его написали.
|
||||
3. **Запрет по имени** (`forbidigo`, `depguard`). Свойство выражается через «эту
|
||||
функцию/пакет тут звать нельзя». Дешёво и точно, но требует **единой точки**,
|
||||
куда запрещённое переносят: запрет без дома оставляет код без способа сделать
|
||||
нужное.
|
||||
4. **Тест-сканер исходников** (`internal/archrules`). Свойство — о структуре, а
|
||||
не о вызове: направление зависимостей, согласованность двух перечней,
|
||||
отсутствие идиомы. Пишется руками на `go/parser` или регулярном выражении,
|
||||
зато читается как тест и ломается заметно.
|
||||
5. **Свой шаг проверки** (`scripts/`, шаги `Taskfile.yml`). Свойство выходит за
|
||||
пределы кода на Go: версия инструмента, форма `Dockerfile`, раскладка
|
||||
документов. Дороже всех — у шага появляется своя норма и свои тесты.
|
||||
|
||||
Ступень, выбранная неверно, видна сразу. Запрет по имени, обходимый одной
|
||||
лишней строкой, — это ступень 4, наряженная третьей: так было с правилом о
|
||||
заголовках ответа, которое сначала запретило текст `\.Header\(\)\.Get`, а
|
||||
обходилось присваиванием в переменную. Правило переписано на суждение **по типу
|
||||
приёмника** (`analyze-types`), и это уже настоящая третья ступень.
|
||||
него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
|
||||
не числится: проверок над проверками проект не заводит —
|
||||
[CLAUDE.md](../../CLAUDE.md), «Запреты».
|
||||
|
||||
## Два круга: pre-commit и гейт
|
||||
|
||||
@@ -120,7 +91,7 @@ Go-проект как есть. Своё здесь — перечень пра
|
||||
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
|
||||
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
|
||||
|
||||
### Отмена и внешний собеседник
|
||||
|
||||
@@ -139,14 +110,13 @@ Go-проект как есть. Своё здесь — перечень пра
|
||||
| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml` → `forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем |
|
||||
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml` → `sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
|
||||
|
||||
### Проверки о самих проверках
|
||||
### Код проверок и подавления
|
||||
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
||||
| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть |
|
||||
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Без компилятора C шаг гоняет тесты без детектора и краснеет кодом 3: гонки — не повод отнимать у гейта сами тесты |
|
||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
||||
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||
|
||||
### Форма кода и файлов вне Go
|
||||
@@ -164,8 +134,8 @@ Go-проект как есть. Своё здесь — перечень пра
|
||||
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` в `docs/.docs.json`, чтобы у факта не было второго дома; пустой каталог роняет шаг — правило, потерявшее предмет, молчать не должно. `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
|
||||
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` |
|
||||
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
|
||||
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
|
||||
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
||||
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
||||
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
|
||||
@@ -196,8 +166,8 @@ Go-проект как есть. Своё здесь — перечень пра
|
||||
остаётся то, чему нет ни готового правила, ни детерминированного оракула:
|
||||
уровень лога по адресату, единая логирующая точка на доменной границе, словарь
|
||||
имён полей, канонический вид идентификатора, естественные ключи у деталей.
|
||||
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново — это и
|
||||
есть первая ступень лестницы, и подъём с неё всегда выигрыш.
|
||||
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново, и правило,
|
||||
снявшее с него эту работу, всегда выигрыш.
|
||||
|
||||
Названы поимённо и **остатки правил** — то, что правило не ловит и потому
|
||||
осталось человеку:
|
||||
@@ -239,8 +209,9 @@ Go-проект как есть. Своё здесь — перечень пра
|
||||
|
||||
Порядок один и тот же, и последние два шага пропускать нельзя.
|
||||
|
||||
1. **Найти дом.** Ступень лестницы выбирается по тому, чем свойство
|
||||
выражается, а не по тому, что проще включить.
|
||||
1. **Найти дом.** Дом выбирается по тому, чем свойство выражается, а не по тому,
|
||||
что проще включить: настройка готового линтера, запрет по имени, тест-сканер
|
||||
исходников или свой шаг набора проверок.
|
||||
2. **Написать причину рядом.** Правило без причины снимают при первом же
|
||||
неудобстве: тот, кто снимает, не знает, что оно ловило.
|
||||
3. **Починить находки, а не подавить.** Подавление годится, когда правило
|
||||
|
||||
@@ -163,8 +163,7 @@ log := log.With("job_id", job.Id, "capability", "conversion")
|
||||
|
||||
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
|
||||
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
|
||||
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf`
|
||||
мимо `slog` целиком.
|
||||
записи.
|
||||
|
||||
## Внешние сервисы: логируем все вызовы
|
||||
|
||||
@@ -241,9 +240,9 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||
|
||||
Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы —
|
||||
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы —
|
||||
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
||||
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из пяти:
|
||||
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех:
|
||||
|
||||
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
||||
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
||||
|
||||
@@ -33,11 +33,13 @@
|
||||
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
|
||||
выполнения нет.
|
||||
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
|
||||
цели [web-access](../../tasks/items/web-access.md). Без сети приложение
|
||||
показывает состояние, а не пустой экран.
|
||||
- **Web Push не делаем**: уведомления идут через apprise и ntfy, цель
|
||||
[ready-notification](../../tasks/items/ready-notification.md).
|
||||
- **Записи звука в приложении не делаем** — файл выбирают системным диалогом.
|
||||
из [паспорта](../passport.md). Без сети приложение показывает состояние, а не
|
||||
пустой экран.
|
||||
- **Web Push не делаем**: уведомления идут через apprise и ntfy — решение живёт
|
||||
в [architecture.md](../architecture.md), «Уведомления», делает его
|
||||
[ntfy-delivery](../../tasks/items/ntfy-delivery.md).
|
||||
- **Записи звука в приложении не делаем** — граница из
|
||||
[паспорта](../passport.md), «Диктофон»; файл выбирают системным диалогом.
|
||||
|
||||
## Фреймворк и сборка
|
||||
|
||||
@@ -55,9 +57,12 @@
|
||||
|
||||
## Маршруты
|
||||
|
||||
- **Четыре экрана, одна таблица маршрутов** через `createRouter`. Маршруты по
|
||||
файлам не включаем: сборочная надстройка роутера пятой версии стоит 34 пакета
|
||||
в установке и на четырёх маршрутах не окупается.
|
||||
- **Одна таблица маршрутов** через `createRouter`. Маршруты по файлам не
|
||||
включаем: сборочная надстройка роутера пятой версии стоит 34 пакета в
|
||||
установке и на нашем числе маршрутов не окупается
|
||||
([research/spa-framework.md](../research/spa-framework.md), «Vue»). Сколько
|
||||
экранов и какие — не здесь: состав нормирует спека приложения, а до неё его
|
||||
держит [spa-skeleton](../../tasks/items/spa-skeleton.md).
|
||||
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
||||
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`;
|
||||
пути внутри `/api/` в приложение не проваливаются никогда.
|
||||
@@ -78,6 +83,9 @@
|
||||
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
|
||||
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
|
||||
не `Response`.
|
||||
- **Сессия живёт кукой `transcriber_session`**, и приложение её не читает: кука
|
||||
`HttpOnly`, браузер шлёт её сам, а вошедшего экран узнаёт по ответу API. Норма
|
||||
— [access](../../openspec/specs/access/spec.md).
|
||||
|
||||
## Показ ошибок и состояний
|
||||
|
||||
@@ -100,5 +108,5 @@
|
||||
узнала»).
|
||||
- **Устройство service worker и версионирование статики** — задача
|
||||
[installable-pwa](../../tasks/items/installable-pwa.md).
|
||||
- **Где живёт сессия и как приложение узнаёт вошедшего** — открытый вопрос
|
||||
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос
|
||||
«Учётные записи» в [../architecture.md](../architecture.md).
|
||||
|
||||
+13
-10
@@ -16,8 +16,8 @@ CGO сборке не нужен.
|
||||
|
||||
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
|
||||
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
|
||||
пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на
|
||||
каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
|
||||
пути**, а префикс наводится только на каталог. Где этот префикс задан —
|
||||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
|
||||
репозитория берёт их оттуда.
|
||||
|
||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
||||
@@ -39,7 +39,7 @@ CGO сборке не нужен.
|
||||
### `files`
|
||||
|
||||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
||||
Object Storage — три разные записи.
|
||||
Object Storage — каждая своей записью.
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
@@ -78,10 +78,10 @@ capability, и третий смысл развёл бы одно слово п
|
||||
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
|
||||
этот один.
|
||||
|
||||
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит
|
||||
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого
|
||||
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
|
||||
сам: это делает тот, кто захватил задачу с превышенным счётчиком.
|
||||
**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
|
||||
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
|
||||
потребует нового шага.
|
||||
|
||||
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
|
||||
может только владелец панели. Проверено прогоном: анонимный запрос к
|
||||
@@ -118,8 +118,10 @@ capability, и третий смысл развёл бы одно слово п
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||
невоспроизводим.
|
||||
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время
|
||||
работы достался другому, завершается без записи и без ответа отправителю.
|
||||
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
||||
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
||||
что условие проверяется тем же запросом, что и сам захват.
|
||||
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
||||
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
||||
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
||||
@@ -145,10 +147,11 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
|
||||
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||
| Срок жизни сессии | 7 суток | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки в 5 суток никем не выбрано |
|
||||
| Срок жизни сессии | нормирует [access](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
|
||||
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
# Паспорт проекта
|
||||
|
||||
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
||||
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «в каком порядке», паспорт —
|
||||
устроено», [tasks/BACKLOG.md](../tasks/BACKLOG.md) — «в каком порядке», паспорт —
|
||||
«зачем и для кого».
|
||||
|
||||
## Цель
|
||||
@@ -22,7 +22,7 @@
|
||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только кука, снятая из браузера. Токен приносит `api-tokens` |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
|
||||
|
||||
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
||||
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
||||
@@ -32,8 +32,8 @@
|
||||
|
||||
- запись любого распространённого формата принимается без предварительной
|
||||
подготовки, включая дорожку из видео;
|
||||
- запись длиной до шести часов доходит до текста, а не прерывается ошибкой при
|
||||
достижении предела;
|
||||
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается
|
||||
ошибкой при достижении предела (норма — `openspec/specs/storage`);
|
||||
- сервисом пользуются несколько человек, и записи одного не видны другому;
|
||||
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
|
||||
узнаёт о его готовности, не держа приложение открытым;
|
||||
@@ -54,7 +54,8 @@
|
||||
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
|
||||
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
|
||||
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
|
||||
Направление — цель [text-insights](../tasks/items/text-insights.md).
|
||||
Считать уровни текста берётся задача
|
||||
[llm-insights-adapter](../tasks/items/llm-insights-adapter.md).
|
||||
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
|
||||
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
|
||||
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
|
||||
@@ -65,8 +66,7 @@
|
||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||
обрабатываем.
|
||||
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
||||
файл. Своей записи и работы без сети не делаем — граница цели
|
||||
[web-access](../tasks/items/web-access.md).
|
||||
файл. Своей записи и работы без сети не делаем.
|
||||
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
||||
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
|
||||
доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
|
||||
|
||||
@@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
|
||||
|
||||
| Дата | Запись | О чём |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
|
||||
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
|
||||
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
||||
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
|
||||
|
||||
@@ -49,8 +49,9 @@
|
||||
|
||||
## Захват чинится одним запросом
|
||||
|
||||
Сегодняшний захват — два запроса подряд без транзакции
|
||||
([../database.md](../database.md), «Представление данных»). Замер показал, что
|
||||
Захват **на момент замера** — два запроса подряд без транзакции; после перехода
|
||||
на PocketBase он свернулся в один с `RETURNING` —
|
||||
[../database.md](../database.md), «Представление данных». Замер показал, что
|
||||
после перехода на PocketBase он сворачивается в один: движок за
|
||||
`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх
|
||||
горутинах разом запись получила **ровно одна**.
|
||||
|
||||
@@ -90,6 +90,10 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
||||
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
|
||||
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
|
||||
|
||||
*Уточнено 2026-08-12:* перевод состоялся, и требования CGO в стеке больше нет —
|
||||
[../../CLAUDE.md](../../CLAUDE.md), «Стек»: компилятор C нужен только детектору
|
||||
гонок в гейте.
|
||||
|
||||
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
|
||||
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
|
||||
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
|
||||
@@ -101,9 +105,9 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
||||
|
||||
## Вход через OIDC: что выяснилось при реализации
|
||||
|
||||
Дописано 2026-08-12 задачей `oidc-login`. Провенанс общий: чтение исходников
|
||||
`pocketbase@v0.39.10` из кеша модулей плюс прогоны против настоящего хранилища на
|
||||
временном каталоге, все — в ходе ревью того change. Живой Authelia в прогонах не
|
||||
Дописано 2026-08-12 задачей `oidc-login`. Все находки ниже получены одним
|
||||
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
|
||||
против настоящего хранилища на временном каталоге — в ходе ревью того change. Живой Authelia в прогонах не
|
||||
было ни разу: провайдера подменял свой `httptest`-сервер.
|
||||
|
||||
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
|
||||
|
||||
@@ -25,7 +25,8 @@ Nuxt, Next — не рассматривали: конвенция
|
||||
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
|
||||
одинаковость экрана.
|
||||
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
|
||||
столько экранов у цели [web-access](../../tasks/items/web-access.md). Роутеры
|
||||
столько экранов заводит задача
|
||||
[spa-skeleton](../../tasks/items/spa-skeleton.md). Роутеры
|
||||
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0.
|
||||
- **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
|
||||
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Разбор TOML: какое семейство отказов несёт значения из файла
|
||||
|
||||
Отвечает на вопрос, возникший по ходу задачи `telegram-enabled-flag`: можно ли
|
||||
пересказывать отказ библиотеки разбора в журнал, если в файле настроек лежат
|
||||
секреты. Наблюдение понадобилось потому, что ревью дизайна назвало этот путь
|
||||
утечкой, а чинить его без разреза пришлось бы выбрасыванием всего текста отказа —
|
||||
то есть платой разборчивостью на каждой опечатке.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Не замером, а **чтением исходников** зависимости, зафиксированной в `go.mod`:
|
||||
`github.com/BurntSushi/toml` версии **v1.5.0**. Смотрел `error.go`, `parse.go`,
|
||||
`decode.go`, `meta.go`, `lex.go` в кэше модулей. Дополнительно прогонял
|
||||
`toml.Decode` на правдоподобных опечатках — в каталоге вне репозитория, чтобы не
|
||||
править код проекта.
|
||||
|
||||
## Что выяснилось
|
||||
|
||||
- **Значения из файла несёт ровно одно семейство отказов — `toml.ParseError`.**
|
||||
Его поле `Message` собирается из разбираемого куска: `Invalid float value: %q`
|
||||
(`parse.go:341`), `invalid duration: %q`, `%v is out of range`, `Invalid
|
||||
integer %q`. Туда же лексер отдаёт свои отказы через `panicItemf`
|
||||
(`parse.go:134`).
|
||||
- **Прочие отказы декодера значений не содержат вовсе.** Их строит `md.e`
|
||||
(`decode.go:577`) и `md.badtype` — из имён ключей, имён типов (`%T` через
|
||||
`fmtType`) и длин. Обойдены все места: `decode.go:282,288,297,329,348,385,388,399,428,437,467,487,518,552,561`.
|
||||
- **`LastKey` секрета нести не может.** Текущий ключ присваивается только после
|
||||
`itemKeyEnd`, то есть после `=` (`parse.go:200`), а лексер ключа до `=` не
|
||||
доходит (`lex.go:481-501`). Посторонняя строка со значением ключом не станет.
|
||||
- **Поле `Line` у `ParseError` врёт, а `Position.Line` — нет.** `panicErr` и
|
||||
`panicItemf` кладут в устаревшее поле `Line` значение `it.pos.Len`, то есть
|
||||
**длину**, а не номер строки (`parse.go:97,106`). Брать надо `Position.Line`.
|
||||
- **`ParseError` возвращается значением, не указателем** (`decode.go:564`,
|
||||
`parse.go` целиком), поэтому `errors.As` берёт целью `toml.ParseError`, а не
|
||||
`*toml.ParseError`. `Unwrap` у типа нет.
|
||||
- **Ветка без последнего ключа достижима обычной опечаткой.** Незакрытая скобка
|
||||
секции даёт `LastKey=""`:
|
||||
|
||||
```
|
||||
вход "[telegram\nenabled = true\n"
|
||||
→ LastKey="" err=toml: line 2: expected '.' or ']' to end table name, but got '\n' instead
|
||||
```
|
||||
|
||||
## Что из этого следует для кода
|
||||
|
||||
Разрез по семейству отказа: `ParseError` пересобирается своими словами — путь,
|
||||
строка, столбец, последний ключ, — а его `Message` не берётся; прочие отказы
|
||||
проходят как есть. Так инвариант «Секрет не покидает конфиг» держится, а
|
||||
несовпадение типов по-прежнему называет ключ и типы.
|
||||
|
||||
**Наблюдение привязано к версии.** Версия, переложившая значение в другое
|
||||
семейство или сменившая возврат на указатель, вернёт утечку молча. Держат это
|
||||
проверки поломанного файла настроек в `internal/config/config_test.go`; при
|
||||
подъёме версии библиотеки их отказ читается как сигнал перечитать эту записку, а
|
||||
не как случайный шум.
|
||||
+107
-47
@@ -2,11 +2,34 @@
|
||||
|
||||
## Как настроен конвейер
|
||||
|
||||
Конвейер ревью прогонялся один раз — 2026-08-11, на изменении
|
||||
`fix-http-handler-tests`; его триаж лежит в
|
||||
`openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md`.
|
||||
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые
|
||||
ложноположительные» первым прогоном уже пользовались.
|
||||
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/` — под именем
|
||||
`triage.md` либо `report.md`: имя менялось по ходу, и оба встречаются. Самый
|
||||
ранний — `fix-http-handler-tests` 2026-08-11, самый поздний —
|
||||
`start-without-telegram-token` 2026-08-13.
|
||||
|
||||
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
|
||||
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
|
||||
Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем
|
||||
прогонов.
|
||||
|
||||
**Проход, поднявший сервис, обязан его остановить.** Живой прогон стал доступен
|
||||
2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный
|
||||
проход поднял сервис на своём порту и оставил работать. Следующий прогон занять
|
||||
порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры
|
||||
относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два
|
||||
правила, оба прозой и без механизации: **поднял — останови за собой**, а
|
||||
**меряющий убеждается, что отвечает его собственная сборка** (порт занят им,
|
||||
новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля,
|
||||
метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.
|
||||
|
||||
**Ни один проход не сообщает свой потолок, и это надо читать как границу
|
||||
покрытия.** Прогон `telegram-enabled-flag` 2026-08-13: у прохода есть потолок
|
||||
находок, и устав велит объявлять строкой, сколько осталось за срезом и какого
|
||||
рода. Ни один из четырёх проходов такой строки не дал, и заметил это только
|
||||
триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не
|
||||
поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна
|
||||
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
|
||||
остаётся сверка триажа.
|
||||
|
||||
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
|
||||
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
|
||||
@@ -68,22 +91,11 @@
|
||||
|
||||
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
||||
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
||||
становится неотличим от привычного шума (журнал, запись 2026-08-10);
|
||||
- проверка **способна упасть**. Утверждение, разбирающее ответ в ту же
|
||||
структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним
|
||||
и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется
|
||||
мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал,
|
||||
запись 2026-08-11);
|
||||
- **то же и об оракуле критерия приёмки, не только о тесте.** Критерий, чей
|
||||
единственный оракул — молчание линтера, годится ровно тогда, когда линтер
|
||||
краснеет на **всех** негодных реализациях; проверяется той же мутацией.
|
||||
Прецедент: «отказ `Close` не теряется молча» принимался молчанием `errcheck`,
|
||||
а тот пропускал `_ = conn.Close()` — реализацию, теряющую отказ целиком
|
||||
(журнал, запись 2026-08-11 про недостижимую норму; закрыто
|
||||
[решением](adr/ADR-2026-08-11-errcheck-check-blank.md));
|
||||
- **требование без сценария не имеет оракула** и потому не может быть нарушено
|
||||
заметно. Норма, которую нечем уронить, расходится с кодом молча — и расходится
|
||||
тем вернее, чем убедительнее написана (журнал, запись 2026-08-11).
|
||||
становится неотличим от привычного шума (журнал, запись 2026-08-10).
|
||||
|
||||
Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
|
||||
мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
|
||||
границы — [CLAUDE.md](../CLAUDE.md), «Запреты».
|
||||
|
||||
### Типовые ложноположительные
|
||||
|
||||
@@ -114,7 +126,7 @@
|
||||
|
||||
### Вопросы по темам
|
||||
|
||||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
||||
Форма: `<тема>: <вопрос> (<откуда>)`.
|
||||
|
||||
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||
@@ -122,7 +134,7 @@
|
||||
собеседник»), а исход прерванного шага нормой по-прежнему не описан
|
||||
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
|
||||
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||||
`transcribe.go`, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с
|
||||
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
|
||||
дефектом «остановка хоронила запись»).
|
||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||||
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||
@@ -144,13 +156,16 @@
|
||||
`createTranscribeJob` — сегодня через него идут оба входа
|
||||
([architecture.md](architecture.md), «Единые точки проекта»).
|
||||
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
||||
заведены две capability (`openspec/specs/intake` и `openspec/specs/pipeline`),
|
||||
и каждая описана частично. Поведение прочих узлов живёт в обзоре под маркерами
|
||||
долга, а соблазн дописать туда ещё — самый большой.
|
||||
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
|
||||
первые две описаны частично. Поведение прочих узлов, включая
|
||||
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
|
||||
ещё — самый большой.
|
||||
- `conventions`: новая колонка правится во всех четырёх местах репозитория
|
||||
(CLAUDE.md, «Инварианты»).
|
||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
|
||||
тестов два файла, и оба мимо конвейера.
|
||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
|
||||
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
|
||||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
|
||||
числа файлов здесь не называем — оно протухает с каждой задачей.
|
||||
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
|
||||
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
|
||||
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
|
||||
@@ -186,8 +201,10 @@
|
||||
|
||||
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
||||
пользователь приложения, до начала работы назвать нельзя;
|
||||
- всё, что делается на выбранном фреймворке впервые: форма решения нащупывается
|
||||
по ходу, пока конвенция веб-UI пуста;
|
||||
- всё, что делается на выбранном фреймворке впервые: правила
|
||||
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
|
||||
на пробном экране, а не из написанного кода, и первая же задача проверяет их
|
||||
собой — форма решения нащупывается по ходу;
|
||||
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
|
||||
до работы назвать нельзя;
|
||||
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
||||
@@ -199,7 +216,7 @@
|
||||
|
||||
- правка текста, который видит пользователь Telegram;
|
||||
- новая метрика в `internal/metrics`;
|
||||
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
|
||||
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
|
||||
- правка документов канона.
|
||||
|
||||
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
|
||||
@@ -231,20 +248,60 @@ API и имя не откатываются обратной правкой по
|
||||
длительность от подставного источника. Своего теста у
|
||||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
||||
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер
|
||||
Telegram: он проверяет токен обращением к Telegram, а боевым токеном
|
||||
запускаться запрещено. Значит поведенческая верификация живым прогоном
|
||||
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера
|
||||
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока
|
||||
нет.
|
||||
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||||
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
|
||||
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
|
||||
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
|
||||
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
|
||||
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона
|
||||
сменился с пустого ключа доступа на выключенный вход задачей
|
||||
`telegram-enabled-flag` того же дня.
|
||||
|
||||
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
|
||||
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
|
||||
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
|
||||
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
|
||||
расшифровку и заливку.
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
Верхняя запись найдена конвейером ревью на первом же его прогоне, вторая —
|
||||
прогоном гейта при заведении канона 2026-08-10, две нижние восстановлены по
|
||||
истории git тогда же. Три нижние помечены `проскочил`: ревью тогда не было, и
|
||||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
||||
выдумывать его задним числом нельзя.
|
||||
Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
|
||||
`[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
|
||||
уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
|
||||
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
||||
оракул, и выдумывать оракул задним числом нельзя.
|
||||
|
||||
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
|
||||
|
||||
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
|
||||
попадает в отказ» задачи `telegram-enabled-flag`. Дефект в самой проверке, кода
|
||||
сервиса он не касался
|
||||
- **Симптом:** проверка была зелёной и утверждала, что отказ `TelegramConfig.Validate()`
|
||||
не несёт значения ключа доступа. Приёмочный критерий задачи считался закрытым ею
|
||||
- **Причина:** двойная, и каждая половина достаточна. Утверждение искало
|
||||
подстроку `enabled = true при`, а в сообщении стоит `при enabled = true` —
|
||||
порядок слов обратный, и такой подстроки не бывает ни при каком входе. Глубже:
|
||||
`Validate()` отказывает **только** на пустом ключе, то есть значения, которым
|
||||
можно проговориться, на этом пути не существует вовсе. Комментарий при этом
|
||||
утверждал «Ключ непуст», а в теле стояло `BotToken: ""` — описан был не тот
|
||||
вход, который задан
|
||||
- **Чем воспроизведён:** триаж скопировал дерево во временный каталог и заменил
|
||||
тело `Validate()` на утекающее — `fmt.Errorf("... bot_token=%q ...", c.BotToken)`.
|
||||
Проверка осталась зелёной
|
||||
- **Почему не поймали раньше:** проверка написана в той же задаче и той же рукой,
|
||||
что и код; гейт зелёный, а зелёная проверка неотличима от работающей. Поймали
|
||||
два прохода независимо — разбор кода и сверка требований
|
||||
- **Что меняем:** проверка переписана честно и переименована: половина требования
|
||||
«сообщение не несёт значения» на этом пути **вакуумна**, и это названо прямо, а
|
||||
настоящий сторож той же нормы указан по имени — он живёт там, где непустой ключ
|
||||
в отказ попасть действительно может, в проверках отказа разбора файла настроек.
|
||||
Класс всплывает **третий раз** (2026-08-11 «проверка приёма не могла упасть»,
|
||||
2026-08-12 «проверка не могла упасть: читала живую карту заголовков»), и в этот
|
||||
раз он другой природы: прежние два ловились правилом линтера про источник
|
||||
утверждения, а этот — про **вход**: у сторожа утечки вход обязан содержать
|
||||
значение, которое может утечь, иначе сторож пуст независимо от формы
|
||||
утверждения. Механизации у этого нет и, похоже, быть не может: «может ли здесь
|
||||
вообще утечь» — суждение, а не форма. Остаётся проходу ревью
|
||||
|
||||
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
|
||||
|
||||
@@ -297,7 +354,7 @@ API и имя не откатываются обратной правкой по
|
||||
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
|
||||
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
|
||||
машина прозу не проверяет
|
||||
- **Что меняем:** чистка перенесена с места употребления на **границу клиента** —
|
||||
- **Что меняем:** чистку перенесли с места употребления на **границу клиента** —
|
||||
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
|
||||
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
|
||||
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
|
||||
@@ -351,9 +408,10 @@ API и имя не откатываются обратной правкой по
|
||||
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
|
||||
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
|
||||
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
|
||||
проверено мутацией по каждой. Отсюда же строка в
|
||||
docs/conventions/go-linters.md, «Лестница механизации»: запрет по имени, обходимый лишней строкой, — это ступень
|
||||
тест-сканера, наряженная запретом
|
||||
проверено мутацией по каждой. Урок записи: запрет по имени, обходимый лишней
|
||||
строкой, свойства не держит — такому свойству нужен тест-сканер. Строка об этом
|
||||
стояла в `docs/conventions/go-linters.md`, разделе «Лестница механизации»;
|
||||
раздел снят 2026-08-13, урок остался здесь
|
||||
|
||||
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
|
||||
|
||||
@@ -465,7 +523,9 @@ API и имя не откатываются обратной правкой по
|
||||
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
|
||||
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
|
||||
согласованно, и парная сверка не увидела бы документ, разошедшийся с
|
||||
согласованным кодом. Норма — capability `toolchain`.
|
||||
согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain`
|
||||
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
|
||||
комментариями в самом скрипте.
|
||||
|
||||
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
||||
|
||||
|
||||
+35
-10
@@ -122,8 +122,10 @@ Telegram отправителю.
|
||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
||||
есть доступны они только владельцу панели; проверено прогоном — записи отдают
|
||||
`403`, служебные разделы `401`.
|
||||
есть доступны они только владельцу панели; коды, снятые прогоном, —
|
||||
[database.md](database.md), «Коллекции», норма —
|
||||
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
|
||||
то, что заказано».
|
||||
|
||||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||||
|
||||
@@ -143,9 +145,10 @@ Telegram отправителю.
|
||||
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
||||
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||
кукой `transcriber_session`, живёт семь суток, обесценивается выходом.
|
||||
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
|
||||
([database.md](database.md), «Настройки с числовым значением»).
|
||||
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
||||
бессрочно, и семисуточный срок — единственное, чем отзыв доступа у провайдера
|
||||
бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
|
||||
доходит до сервиса, — не значил бы ничего.
|
||||
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
||||
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
||||
@@ -271,13 +274,34 @@ Telegram отправителю.
|
||||
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
|
||||
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
|
||||
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
|
||||
скачивания и отправки пишутся в журнал. Теперь адрес снимается на границе
|
||||
клиента — `internal/adapter/telegram`, `NewBot`: свой `Do` чистит отказ, а
|
||||
скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает
|
||||
свой `Do` — `internal/adapter/telegram`, `NewBot`: он чистит отказ, а
|
||||
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
|
||||
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
|
||||
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
|
||||
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
|
||||
|
||||
Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до
|
||||
неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из
|
||||
шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента
|
||||
**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ
|
||||
конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами
|
||||
независимо; оракул — там же, в `bot_test.go`.
|
||||
|
||||
Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире
|
||||
токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла
|
||||
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
|
||||
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
|
||||
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
|
||||
шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ
|
||||
пересобирается своими словами: путь, строка, столбец и последний ключ, без текста
|
||||
библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому
|
||||
проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же
|
||||
работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»;
|
||||
оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек.
|
||||
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
|
||||
несёт значения **в нынешней версии** библиотеки.
|
||||
|
||||
## Что вне модели
|
||||
|
||||
Перечислить явно.
|
||||
@@ -295,10 +319,11 @@ Telegram отправителю.
|
||||
не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
|
||||
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
|
||||
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
|
||||
нет и не будет: решено считать расход и показывать его владельцу, а не
|
||||
отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
||||
Authelia. Рост каталога данных при этом ничем не наблюдается —
|
||||
открытый вопрос `architecture.md`.
|
||||
нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход
|
||||
считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда
|
||||
следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и
|
||||
защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом
|
||||
ничем не наблюдается — открытый вопрос `architecture.md`.
|
||||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||||
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
||||
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module git.vakhrushev.me/av/transcriber
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
@@ -49,6 +49,7 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//
|
||||
// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина
|
||||
// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом
|
||||
// `docs/database.md` по префиксу пути (`docs/.docs.json`, ключ `migrations`), а
|
||||
// префикс наводится только на каталог. Пока шаги лежали файлом, наводить его
|
||||
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
|
||||
// `[docs]`), а префикс наводится только на каталог. Пока шаги лежали файлом,
|
||||
// наводить его
|
||||
// было не на что, и проверка молчала на всякой правке схемы.
|
||||
package migrations
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
|
||||
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
|
||||
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
|
||||
// `contract.ErrDeliveryChannelDown`.
|
||||
//
|
||||
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
|
||||
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
|
||||
// месте употребления завела бы в ядре знание о том, как собран сервис.
|
||||
//
|
||||
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
|
||||
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
|
||||
// Пишет поэтому шаг конвейера, который задачу знает.
|
||||
type AbsentMessageSender struct{}
|
||||
|
||||
func NewAbsentMessageSender() *AbsentMessageSender {
|
||||
return &AbsentMessageSender{}
|
||||
}
|
||||
|
||||
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
|
||||
return contract.ErrDeliveryChannelDown
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// Заглушка отдаёт «канал не поднят» и молчит: записать недоставку ей нечем —
|
||||
// идентификатора задачи контракт отправки не несёт, и пишет её шаг конвейера.
|
||||
func TestAbsentSenderReportsChannelDown(t *testing.T) {
|
||||
sender := NewAbsentMessageSender()
|
||||
|
||||
err := sender.Send("расшифровка записи", 100, nil)
|
||||
|
||||
require.ErrorIs(t, err, contract.ErrDeliveryChannelDown)
|
||||
}
|
||||
|
||||
// Непустой годный токен по-прежнему даёт настоящего отправителя: прежний путь
|
||||
// сохранён, и меняется только то, что клиента теперь отдают готовым.
|
||||
func TestSenderIsBuiltFromLiveBot(t *testing.T) {
|
||||
bot, _ := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
if _, err := w.Write([]byte(getMeResponse)); err != nil {
|
||||
t.Errorf("подставной Telegram не смог ответить: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
sender := NewTelegramMessageSender(bot, slog.New(slog.DiscardHandler))
|
||||
|
||||
require.NotNil(t, sender)
|
||||
assert.Same(t, bot, sender.bot, "отправитель говорит с тем же клиентом, что и транспорт")
|
||||
}
|
||||
@@ -7,12 +7,19 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// ErrEmptyToken — токен бота не задан. Отдельным значением, потому что подъём
|
||||
// без Telegram — законный исход: сервис продолжает работать с HTTP API.
|
||||
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
|
||||
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
|
||||
// недоступности Telegram, у которой исход обратный — подъём без бота.
|
||||
//
|
||||
// Отказ от входа Telegram этим значением больше не выражается: намерение
|
||||
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
|
||||
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
|
||||
// поэтому сюда он доходит только в обход проверки.
|
||||
var ErrEmptyToken = errors.New("telegram bot token is empty")
|
||||
|
||||
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
|
||||
@@ -43,9 +50,35 @@ func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, erro
|
||||
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
|
||||
}
|
||||
|
||||
return tgbotapi.NewBotAPIWithClient(token, endpoint, &safeClient{inner: &http.Client{}})
|
||||
// Сборка ходит за `getMe` и стоит на пути старта — раньше HTTP-сервера,
|
||||
// панели и воркеров. Без срока ожидания молчащий Telegram (соединение
|
||||
// принято, ответа нет) вешал бы весь подъём бессрочно: порт не слушается,
|
||||
// проба здоровья не отвечает, а в журнале ни строки.
|
||||
probe := &safeClient{inner: &http.Client{Timeout: ProbeTimeout}}
|
||||
|
||||
// Отказ конструктора чистится здесь, а не клиентом: адрес собирается
|
||||
// строкой с токеном внутри, и `http.NewRequest` падает на его разборе
|
||||
// **до** обращения к клиенту — то есть мимо `safeClient`. Токен с
|
||||
// управляющим символом или неверной `%`-последовательностью иначе уезжает
|
||||
// в журнал целиком: перенос строки в конце значения ловится так же.
|
||||
bot, err := tgbotapi.NewBotAPIWithClient(token, endpoint, probe)
|
||||
if err != nil {
|
||||
return nil, WithoutURL(err)
|
||||
}
|
||||
|
||||
// Дальше живёт длинный опрос, и срок ему не нужен: он ждёт обновлений
|
||||
// столько, сколько задано настройкой, и клиент со сроком рвал бы его.
|
||||
bot.Client = &safeClient{inner: &http.Client{}}
|
||||
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// ProbeTimeout — сколько ждём Telegram при сборке клиента. Число выбрано
|
||||
// решением, а не замером: одно обращение за `getMe` укладывается в доли
|
||||
// секунды, а десять секунд — потолок, после которого Telegram считается
|
||||
// недоступным и сервис поднимается без него.
|
||||
const ProbeTimeout = 10 * time.Second
|
||||
|
||||
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
|
||||
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
|
||||
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
|
||||
|
||||
@@ -63,6 +63,27 @@ func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
|
||||
// недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту,
|
||||
// то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший
|
||||
// с секретом из шаблона выкладки, и невычищенная `%`-последовательность.
|
||||
func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) {
|
||||
broken := map[string]string{
|
||||
"перенос строки": probeToken + "\n",
|
||||
"негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE",
|
||||
}
|
||||
|
||||
for name, token := range broken {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err)
|
||||
assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
|
||||
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
|
||||
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
|
||||
@@ -92,10 +113,21 @@ func TestLibraryLoggerRedactsToken(t *testing.T) {
|
||||
}
|
||||
|
||||
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
|
||||
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
|
||||
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
|
||||
// отказ, и молча потеряет бота.
|
||||
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
|
||||
_, err := NewBot("", slog.New(slog.DiscardHandler))
|
||||
|
||||
require.ErrorIs(t, err, ErrEmptyToken)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
server.Close()
|
||||
|
||||
_, err = newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrEmptyToken)
|
||||
}
|
||||
|
||||
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
|
||||
|
||||
@@ -15,18 +15,15 @@ type TelegramMessageSender struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
|
||||
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ
|
||||
// конструктора несёт — `NewBotAPI` зовёт `getMe`.
|
||||
bot, err := NewBot(botToken, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
|
||||
// заводит сборка при старте — одного на отправителя и на транспорт бота: пока
|
||||
// его строили здесь и там порознь, два пути одного старта разошлись в том,
|
||||
// терпеть ли негодный токен, и согласовывать их приходилось руками.
|
||||
func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
|
||||
return &TelegramMessageSender{
|
||||
bot: bot,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -41,11 +42,33 @@ type YandexConfig struct {
|
||||
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
|
||||
}
|
||||
|
||||
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
|
||||
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
|
||||
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
|
||||
// и сервис поднимался без бота в обоих случаях.
|
||||
type TelegramConfig struct {
|
||||
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
|
||||
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
|
||||
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
|
||||
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
|
||||
Enabled bool `toml:"enabled"`
|
||||
BotToken string `toml:"bot_token"`
|
||||
UpdateTimeout int `toml:"update_timeout"`
|
||||
}
|
||||
|
||||
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
|
||||
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
|
||||
// подъём без бота оставил бы отправителей без ответов.
|
||||
//
|
||||
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
|
||||
// должно.
|
||||
func (c TelegramConfig) Validate() error {
|
||||
if c.Enabled && c.BotToken == "" {
|
||||
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
|
||||
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
||||
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
||||
@@ -129,6 +152,7 @@ func defaultConfig() *Config {
|
||||
ObjStorageRegion: "ru-central1",
|
||||
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
|
||||
},
|
||||
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
|
||||
Telegram: TelegramConfig{
|
||||
BotToken: "",
|
||||
UpdateTimeout: 10,
|
||||
@@ -149,9 +173,48 @@ func LoadConfig(path string) (*Config, error) {
|
||||
config := defaultConfig()
|
||||
|
||||
// Load configuration from file
|
||||
if _, err := toml.DecodeFile(path, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode config file: %w", err)
|
||||
meta, err := toml.DecodeFile(path, &config)
|
||||
if err != nil {
|
||||
return nil, decodeError(path, err)
|
||||
}
|
||||
|
||||
// Признак включения входа Telegram обязателен: умолчания у него нет, и
|
||||
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
|
||||
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
|
||||
// она не отдаётся, приговор выносится здесь.
|
||||
if !meta.IsDefined("telegram", "enabled") {
|
||||
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// decodeError переводит отказ разбора на свои слова. Пересказывать библиотеку
|
||||
// нельзя: она собирает текст отказа из разбираемого куска файла, и оборванная
|
||||
// строка секретного ключа уехала бы в журнал вместе со значением.
|
||||
//
|
||||
// Разрез идёт по семейству отказа, и значения несёт только одно:
|
||||
//
|
||||
// - `toml.ParseError` — сюда сведены отказы лексера и разбора значения, а его
|
||||
// `Message` собран из разбираемого куска («Invalid float value: %q»,
|
||||
// «invalid duration: %q»). Берём строку, столбец и последний ключ — они
|
||||
// безопасны, — а `Message` не берём;
|
||||
// - прочие отказы декодера собраны из имён ключей и имён типов, значений в них
|
||||
// нет вовсе. Их текст берём как есть: выбросив его, мы заплатили бы
|
||||
// разборчивостью отказа там, где платить не за что.
|
||||
//
|
||||
// Две ветки не сводятся в одну намеренно. Сведённая к общему знаменателю, она
|
||||
// либо вернёт утечку, либо оставит несовпадение типов без единого намёка.
|
||||
func decodeError(path string, err error) error {
|
||||
var parseErr toml.ParseError
|
||||
if errors.As(err, &parseErr) {
|
||||
if parseErr.LastKey != "" {
|
||||
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d, последний ключ %q",
|
||||
path, parseErr.Position.Line, parseErr.Position.Col, parseErr.LastKey)
|
||||
}
|
||||
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d",
|
||||
path, parseErr.Position.Line, parseErr.Position.Col)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to decode config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -95,3 +98,179 @@ func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Признак включения объявляет намерение, ключ доступа означает только доступ.
|
||||
// Пока эти два значения жили в одном поле, пустой токен читался разом как
|
||||
// «вход выключен» и как «ключ не доехал».
|
||||
|
||||
func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("включённый вход с ключом отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: true, BotToken: ""}
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("включённый вход без ключа доступа пропущен")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bot_token") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём —
|
||||
// обычное состояние локального прогона, а не ошибка настройки.
|
||||
func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: false, BotToken: ""}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("выключенный вход без ключа отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Половина требования «сообщение не несёт значения ключа» на этой проверке
|
||||
// **вакуумна**, и честнее это назвать, чем изображать сторожа.
|
||||
//
|
||||
// `Validate()` отказывает ровно на пустом ключе — значения, которым можно
|
||||
// проговориться, на этом пути не существует. Прежняя редакция сторожа искала
|
||||
// подстроку, которой в сообщении нет ни при каком входе, и потому не могла
|
||||
// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это
|
||||
// третий пойманный случай проверки, не способной упасть.
|
||||
//
|
||||
// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть
|
||||
// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и
|
||||
// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что
|
||||
// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа.
|
||||
func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) {
|
||||
filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"}
|
||||
if err := filled.Validate(); err != nil {
|
||||
t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err)
|
||||
}
|
||||
|
||||
err := TelegramConfig{Enabled: true, BotToken: ""}.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("включённый вход без ключа доступа пропущен")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bot_token") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать файл настроек: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const validConfigBody = `
|
||||
[telegram]
|
||||
enabled = false
|
||||
bot_token = ""
|
||||
`
|
||||
|
||||
// Признак обязателен: файл без него негоден. Умолчание было бы угаданным
|
||||
// намерением, а отличить «не задан» от «задан ложным» умеет только разбор —
|
||||
// нулевое значение bool у обоих одинаковое.
|
||||
func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) {
|
||||
path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("файл без признака включения принят")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "enabled") {
|
||||
t.Fatalf("имя недостающего ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) {
|
||||
for _, enabled := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) {
|
||||
body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled)
|
||||
|
||||
cfg, err := LoadConfig(writeConfig(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("годный файл отвергнут: %v", err)
|
||||
}
|
||||
if cfg.Telegram.Enabled != enabled {
|
||||
t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая
|
||||
// библиотека из разбираемого куска файла, и оборванная строка ключа доступа
|
||||
// уехала бы в журнал вместе со значением. Отсюда собственное сообщение.
|
||||
func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
|
||||
const secret = "123456:AAHfake-secret-token-value"
|
||||
// Кавычка не закрыта: разбор оборвётся на значении.
|
||||
path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("поломанный файл настроек принят")
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} {
|
||||
if strings.Contains(message, part) {
|
||||
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
|
||||
}
|
||||
}
|
||||
// Без места и ключа отказ нечинибелен: скрыть значение мало.
|
||||
if !strings.Contains(message, "строке 3") {
|
||||
t.Fatalf("номер строки не назван, чинить нечего: %v", err)
|
||||
}
|
||||
if !strings.Contains(message, "bot_token") {
|
||||
t.Fatalf("ключ не назван, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Обратная сторона того же разреза: отказ несовпадения типов собран из имён
|
||||
// ключей и типов, значений в нём нет, и выбрасывать его текст незачем.
|
||||
func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) {
|
||||
path := writeConfig(t, "[server]\nport = \"8080\"\n"+validConfigBody)
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("строка вместо числа принята")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "port") {
|
||||
t.Fatalf("имя ключа не названо, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Вторая ветка разреза: разбор оборвался до всякого ключа, и последнего ключа
|
||||
// нет вовсе. Пропущенная скобка секции — обычная опечатка, а ветка эта самая
|
||||
// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки
|
||||
// обратно.
|
||||
func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
|
||||
const secret = "123456:AAHsecret-token-value"
|
||||
// У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа.
|
||||
path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("поломанный файл настроек принят")
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
for _, part := range []string{secret, "AAHsecret", "secret-token-value"} {
|
||||
if strings.Contains(message, part) {
|
||||
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(message, "строке") {
|
||||
t.Fatalf("место отказа не названо, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
package contract
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят.
|
||||
// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не
|
||||
// настроен.
|
||||
//
|
||||
// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние,
|
||||
// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата,
|
||||
// и запись о недоставке делает шаг, у которого задача под рукой.
|
||||
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
|
||||
|
||||
type JobNotFoundError struct {
|
||||
State string
|
||||
|
||||
@@ -328,9 +328,3 @@ func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type EmptyBotTokenError struct{}
|
||||
|
||||
func (e *EmptyBotTokenError) Error() string {
|
||||
return "telegram bot token is empty"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,28 @@ var (
|
||||
[]string{"source_format", "target_format", "error"},
|
||||
)
|
||||
|
||||
// Поднят ли вход приёма. Единственный канал наблюдения, автоматизированный
|
||||
// у владельца: потерянный вход иначе виден только строкой журнала при
|
||||
// старте, а проба здоровья отвечает «ok» и без него.
|
||||
IntakeUpGauge = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "transcriber_intake_up",
|
||||
Help: "Whether an intake channel is up (1) or not (0)",
|
||||
},
|
||||
[]string{"channel"},
|
||||
)
|
||||
|
||||
// Ответы, которые не удалось доставить отправителю. Работа при этом
|
||||
// сделана, шаг отказа не объявляет, и без счётчика недоставка видна только
|
||||
// в журнале — до его ротации.
|
||||
UndeliveredReplyCounter = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "transcriber_undelivered_reply_count",
|
||||
Help: "Count of replies that could not be delivered to the sender",
|
||||
},
|
||||
[]string{"reason"},
|
||||
)
|
||||
|
||||
// Размер файла после конвертации (в байтах)
|
||||
OutputFileSizeHistogram = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
|
||||
@@ -551,19 +551,43 @@ func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jo
|
||||
return s.send(job, errorMessage)
|
||||
}
|
||||
|
||||
// send отвечает отправителю там, откуда пришла запись, и отказ отправки
|
||||
// поднимает вверх: он принадлежит шагу.
|
||||
// send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает
|
||||
// вверх: он принадлежит шагу.
|
||||
//
|
||||
// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит
|
||||
// после того, как достигнутое состояние сохранено: работа к этой минуте
|
||||
// сделана, и объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу
|
||||
// записью отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не
|
||||
// появятся, — поэтому причина недоставки живёт в журнале, а не в состоянии
|
||||
// задачи.
|
||||
//
|
||||
// Служебные поля завершённой задачи отказ бы при этом не переписал: переход в
|
||||
// терминальное состояние снимает захват, и повторная запись натыкается на
|
||||
// «захват потерян». Довод держится на счётчике и журнале, а не на этом.
|
||||
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
|
||||
if job.Source != entity.SourceTelegram {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Адресата у задачи нет: отвечать некуда, и повторять нечего. Уровень здесь
|
||||
// выше, чем у неподнятого канала, и это не педантизм: пустой чат у задачи
|
||||
// из Telegram — симптом порчи записи, а самый коварный её источник назван
|
||||
// инвариантом «колонки очереди правятся в четырёх местах». Утони этот
|
||||
// сигнал в одном ряду со штатным «бот не настроен» — и обнуление колонки
|
||||
// заметит только отправитель, переставший получать ответы.
|
||||
if job.TgChatId == nil {
|
||||
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
|
||||
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
|
||||
s.undelivered(job, slog.LevelError, "chat is not specified")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil {
|
||||
// Канал не поднят: сервис работает без этого входа, и это объявленный
|
||||
// режим, а не поломка.
|
||||
if errors.Is(err, contract.ErrDeliveryChannelDown) {
|
||||
s.undelivered(job, slog.LevelWarn, "delivery channel is down")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Error("Failed to sent message to client", "job_id", job.Id)
|
||||
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
|
||||
}
|
||||
@@ -571,6 +595,31 @@ func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// undelivered записывает недоставленный ответ и считает его в метрику. Уровень
|
||||
// приходит от причины: объявленный режим — «может стать проблемой», порча
|
||||
// записи — событие для разбора.
|
||||
//
|
||||
// Идентификатор задачи обязателен, иначе владелец видит, что ответ не ушёл, но
|
||||
// не может найти, чей; текста ответа в записи нет — он содержимое чужой записи.
|
||||
//
|
||||
// Счётчик нужен потому, что журнал контейнера живёт до ротации, а вопрос «кому
|
||||
// не ответили за последние сутки» задают позже.
|
||||
func (s *TranscribeService) undelivered(job *entity.TranscribeJob, level slog.Level, reason string) {
|
||||
metrics.UndeliveredReplyCounter.WithLabelValues(reason).Inc()
|
||||
|
||||
// Уровень выбирается ветвлением, а не передачей контекста: контекст здесь
|
||||
// брать неоткуда — ответ идёт после сохранения состояния, — а выдуманный
|
||||
// `context.Background()` соврал бы про отмену и цеплялся бы правилами.
|
||||
switch level {
|
||||
case slog.LevelError:
|
||||
s.logger.Error(undeliveredMessage, "job_id", job.Id, "reason", reason)
|
||||
default:
|
||||
s.logger.Warn(undeliveredMessage, "job_id", job.Id, "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
const undeliveredMessage = "Reply was not delivered"
|
||||
|
||||
// notify отвечает отправителю там, где поднимать отказ некуда: задача уже
|
||||
// доведена до конца, и отказ отправки остаётся записью в журнале владельца.
|
||||
func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Ответ отправителю уходит после того, как достигнутое состояние сохранено.
|
||||
// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался
|
||||
// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные
|
||||
// поля завершённой задачи. Причин недоставки две, исход у них общий.
|
||||
|
||||
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
|
||||
// которую ядро получает вместо отправителя Telegram.
|
||||
type downSender struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *downSender) Send(string, int64, *int) error {
|
||||
s.calls++
|
||||
return contract.ErrDeliveryChannelDown
|
||||
}
|
||||
|
||||
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
|
||||
// утверждения судят и состояние задачи, и то, что увидел владелец.
|
||||
func journalEnv(
|
||||
t *testing.T,
|
||||
env *pipelineEnv,
|
||||
rec contract.AudioRecognizer,
|
||||
sender contract.TelegramMessageSender,
|
||||
) (*TranscribeService, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
journal := &bytes.Buffer{}
|
||||
svc := NewTranscribeService(
|
||||
env.jobRepo,
|
||||
env.fileRepo,
|
||||
&okMetaViewer{},
|
||||
&failingConverter{},
|
||||
rec,
|
||||
sender,
|
||||
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||
)
|
||||
|
||||
return svc, journal
|
||||
}
|
||||
|
||||
// Канал не поднят: задача доводится до конца, шаг отказа не объявляет, а
|
||||
// владелец узнаёт о недоставке из журнала.
|
||||
func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
|
||||
rec.result = entity.NewCompletedResult()
|
||||
rec.text = "расшифровка записи"
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, rec, sender)
|
||||
|
||||
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
|
||||
require.NotNil(t, after.TranscriptionText)
|
||||
assert.Equal(t, "расшифровка записи", *after.TranscriptionText, "расшифровка сохранена")
|
||||
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
|
||||
assert.Contains(t, written, job.Id, "запись несёт идентификатор задачи")
|
||||
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
|
||||
assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет")
|
||||
}
|
||||
|
||||
// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ
|
||||
// шага на уже завершённой работе.
|
||||
func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
|
||||
// Задача из Telegram, у которой чат не назван: такую отдаёт правка в панели.
|
||||
// Колонка чистится мимо захвата — иначе setup унёс бы задачу у шага.
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("tg_chat_id", nil)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
|
||||
rec.result = entity.NewCompletedResult()
|
||||
rec.text = "расшифровка записи"
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, rec, sender)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Reply was not delivered")
|
||||
assert.Contains(t, written, job.Id)
|
||||
assert.Contains(t, written, "chat is not specified", "причина названа")
|
||||
assert.Contains(t, written, "level=ERROR",
|
||||
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
|
||||
}
|
||||
|
||||
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
|
||||
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
|
||||
// строки о задачах основного входа.
|
||||
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg")
|
||||
require.NoError(t, err)
|
||||
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, &scriptedRecognizer{}, sender)
|
||||
|
||||
require.NoError(t, svc.send(job, "расшифровка записи"))
|
||||
|
||||
assert.Equal(t, 0, sender.calls, "отправителя не звали")
|
||||
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
|
||||
}
|
||||
@@ -17,12 +17,11 @@ import (
|
||||
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -60,6 +59,14 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Включённый вход без ключа доступа — ошибка настройки, а не режим: бот по
|
||||
// пустому ключу не появится, а тихий подъём без него оставил бы отправителей
|
||||
// без ответов.
|
||||
if err := cfg.Telegram.Validate(); err != nil {
|
||||
logger.Error("Unable to start with incomplete telegram settings", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Загружаем переменные окружения из .env файла
|
||||
if err := godotenv.Load(); err != nil {
|
||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||
@@ -89,9 +96,9 @@ func main() {
|
||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||
converter := ffmpegconv.NewFfmpegConverter()
|
||||
|
||||
tgSender, err := telegram.NewTelegramMessageSender(cfg.Telegram.BotToken, logger)
|
||||
tgBot, tgSender, err := buildTelegram(cfg.Telegram, logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create audio telegram sender", "error", err)
|
||||
logger.Error("Failed to create Telegram bot", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -141,13 +148,17 @@ func main() {
|
||||
UserWhiteList: cfg.Server.UsersWhiteList,
|
||||
}
|
||||
|
||||
// Клиента бота заводит единая точка: её отказ не несёт токена, тогда как
|
||||
// отказ `NewBotAPI` несёт — он ходит за `getMe`.
|
||||
tgController, err := newTelegramController(cfg.Telegram.BotToken, tgConfig, transcribeService, jobRepo, logger)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Telegram controller", "error", err)
|
||||
// Не останавливаем приложение, если Telegram бот не создан
|
||||
} else {
|
||||
// Транспорт поднимается только там, где есть клиент: о том, что бота нет,
|
||||
// сказано выше единственной записью, и вторая здесь была бы записью о том
|
||||
// же факте.
|
||||
var tgController *tgcontroller.TelegramController
|
||||
if tgBot != nil {
|
||||
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Telegram controller", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Запускаем Telegram бот в отдельной горутине
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -179,6 +190,11 @@ func main() {
|
||||
}(w)
|
||||
}
|
||||
|
||||
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
|
||||
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
|
||||
// обоих входах одним отбором.
|
||||
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
|
||||
|
||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||
// и второму серверу на нём взяться неоткуда.
|
||||
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
|
||||
@@ -328,21 +344,3 @@ func main() {
|
||||
|
||||
logger.Info("Transcriber service stopped")
|
||||
}
|
||||
|
||||
// newTelegramController собирает бота и транспорт вокруг него. Токен доходит
|
||||
// до единой точки `internal/adapter/telegram` и дальше не идёт: транспорт его
|
||||
// не видит вовсе, а отказ, который увидит журнал, адреса с токеном не несёт.
|
||||
func newTelegramController(
|
||||
botToken string,
|
||||
cfg tgcontroller.TelegramConfig,
|
||||
transcribeService *service.TranscribeService,
|
||||
jobRepo contract.TranscriptJobRepository,
|
||||
logger *slog.Logger,
|
||||
) (*tgcontroller.TelegramController, error) {
|
||||
bot, err := telegram.NewBot(botToken, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tgcontroller.NewTelegramController(cfg, bot, transcribeService, jobRepo, logger)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-13
|
||||
@@ -0,0 +1,207 @@
|
||||
## Context
|
||||
|
||||
Сегодня старт роняет отсутствующий токен бота: сборка отправителя ответов
|
||||
возвращает «токен не задан», и процесс заканчивается раньше, чем встаёт
|
||||
HTTP-сервер. Соседний путь того же старта — сборка транспорта бота — тот же отказ
|
||||
уже терпит и сервис не роняет. Два пути одного старта решают одно и то же
|
||||
по-разному, и побеждает тот, что стоит выше.
|
||||
|
||||
Ограничение, из-за которого это дорого: боевым токеном запускаться запрещено, а
|
||||
другого действующего токена у разработчика нет. Значит, живой прогон недоступен
|
||||
никому, и всякая задача проверяется одними тестами.
|
||||
|
||||
Отправитель ответов уходит в ядро расшифровки обязательной зависимостью, и ядро
|
||||
зовёт его без проверки. Убрать отправителя, ничего не решив, — значит уронить
|
||||
процесс на первой же задаче из Telegram, лежащей в базе с прошлого запуска.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- сервис поднимается без токена бота и работает оставшимся входом;
|
||||
- отсутствие бота видно в журнале, а не выводится читателем из тишины;
|
||||
- задача из Telegram, которой некому ответить, не роняет процесс и не теряется
|
||||
молча;
|
||||
- ошибка в токене остаётся заметной.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- приём из Telegram по существу — кто допущен, как забирается запись — не
|
||||
нормируется; оговорка спеки `intake` остаётся;
|
||||
- запрет запускаться боевым токеном не снимается и не смягчается;
|
||||
- второй вход не становится необязательным «вообще»: сервис без обоих входов
|
||||
бессмыслен, но проверять это изменение не берётся;
|
||||
- отправка отложенных ответов, когда бот появится позже, не заводится.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Решение 1: пустой токен — отказ от входа, негодный непустой — ошибка настройки
|
||||
|
||||
Разрез проходит по **пустому значению**, а не по отказу сборки бота.
|
||||
|
||||
Что человек увидит иначе: разработчик стирает токен в своём файле настроек и
|
||||
поднимает сервис; владелец сервиса, опечатавшийся в токене при ротации, получает
|
||||
отказ старта вместо сервиса, молча работающего без бота.
|
||||
|
||||
**Правка после ревью кода, решение владельца 2026-08-13.** Разрез перенесён с
|
||||
«пусто / непусто» на «ответил ли Telegram»: недоступность Telegram на подъём
|
||||
сервиса не влияет. Довод — тот же, что у паспорта: основной вход не Telegram, и
|
||||
класть его целиком из-за чужой аварии нельзя. Ровно этого и требовал прежний
|
||||
разрез: перезапуск в минуту аварии Bot API оставил бы без работы приём по HTTP,
|
||||
панель и конвейер, которому Telegram не нужен вовсе.
|
||||
|
||||
Отдельно снят довод, оказавшийся ложным. Дизайн утверждал, что «Telegram не
|
||||
признал бота» и «до Telegram не дошли» различать нечем. Различать есть чем:
|
||||
ответ Bot API приезжает своим типом с кодом, транспортный отказ — нашим после
|
||||
чистки, и одно от другого отделяется проверкой типа. Утверждение держалось на
|
||||
незнании библиотеки, а не на её устройстве.
|
||||
|
||||
Из решения следует второе, без которого оно невыполнимо: **ожидание при сборке
|
||||
ограничивается сроком**. Пока срока не было, недоступность не отличалась от
|
||||
подъёма — молчащий Telegram вешал старт бессрочно, без записи, без порта и без
|
||||
пробы здоровья. Срок стоит только на сборке; длинный опрос им не ограничен, и
|
||||
клиент подменяется сразу после.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **терпеть любой отказ сборки бота** — отвергнуто: опечатка в боевом токене
|
||||
дала бы работающий сервис без бота, и отправители перестали бы получать
|
||||
ответы. Ответ «такого бота нет» опознаётся точно, ждать по нему нечего, и он
|
||||
остаётся единственным отказом старта;
|
||||
- **ронять старт на любом отказе** — отвергнуто владельцем: авария третьей
|
||||
стороны не должна класть основной вход;
|
||||
- **отдельный ключ настройки «работать без Telegram»** — явное объявление
|
||||
намерения. Отвергнуто: имя ключа конфига объявлено необратимым, а пустое
|
||||
значение уже несёт ровно этот смысл. Второй способ сказать одно и то же
|
||||
разъезжается — останется решить, что делать с пустым токеном при выключенном
|
||||
ключе.
|
||||
|
||||
**Разрез стоит в одном месте, потому что клиент бота собирается один раз.**
|
||||
Сегодня его собирают дважды — под отправителя ответов и под транспорт бота, — и
|
||||
именно поэтому два пути разошлись. Вместо того чтобы согласовывать их вручную,
|
||||
изменение сводит сборку к одной: клиент заводится в сборке при старте и отдаётся
|
||||
обоим. Транспорт уже принимает готового клиента, так что менять надо только
|
||||
отправителя — он перестаёт принимать токен и начинает принимать клиента.
|
||||
|
||||
Что это даёт сверх опрятности: разрез «пусто / непусто» существует ровно один,
|
||||
запись о неподнятом боте по построению одна, обращение к Telegram при старте
|
||||
одно вместо двух, и подмена журнала библиотеки тоже одна. Проверять «согласованы
|
||||
ли два пути» больше не надо — второго пути нет.
|
||||
|
||||
**Цена решения:** сборка бота перестаёт терпеть негодный токен и начинает ронять
|
||||
старт. Наблюдаемо это почти ничего не меняет: сборка отправителя роняет старт на
|
||||
том же токене и сегодня, а стоит она раньше — до терпимости транспорта очередь
|
||||
попросту не доходит.
|
||||
|
||||
### Решение 2: заглушка отвечает «канала нет», а запись делает шаг
|
||||
|
||||
Когда токена нет, ядро получает отправителя-заглушку. Она ничего не отправляет и
|
||||
на всякий ответ возвращает **особое значение отказа — «канал доставки не
|
||||
поднят»**. Шаг конвейера узнаёт это значение, пишет недоставку в журнал с
|
||||
идентификатором задачи и завершается **без отказа**.
|
||||
|
||||
Что человек увидит иначе: владелец сервиса находит в журнале строку «ответ не
|
||||
доставлен» с идентификатором задачи и забирает расшифровку там же, где лежат
|
||||
остальные.
|
||||
|
||||
Почему запись делает шаг, а не сама заглушка: **идентификатора задачи у
|
||||
заглушки нет**. Контракт отправки несёт текст, чат и сообщение для ответа —
|
||||
задачу он не называет, и знать о ней отправителю незачем. Заглушка, пишущая
|
||||
`chat_id` вместо задачи, дала бы владельцу строку, по которой задачу не найти, а
|
||||
расширение контракта ради журнала потянуло бы правку и настоящего отправителя, и
|
||||
всех его вызовов.
|
||||
|
||||
Почему это не заводит в ядре ветки «а есть ли бот»: ядро ветвится не на
|
||||
устройстве сборки, а на **исходе доставки** — ровно так же, как оно уже ветвится
|
||||
на «работы нет» и «захват потерян». Особое значение отказа живёт там же, где эти
|
||||
два, и узнаётся тем же способом. Знания о том, как собран сервис, у ядра не
|
||||
появляется.
|
||||
|
||||
Источник задачи ядро при этом уже различает: ответ отправителю начинается с
|
||||
проверки источника и на задаче, пришедшей по HTTP, кончается раньше обращения к
|
||||
отправителю. Заглушка задач основного входа не увидит, и ложных строк о
|
||||
недоставке в журнале не будет.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **ронять задачу в `failed`** — отвергнуто: расшифровка к этому моменту уже
|
||||
получена и сохранена, а «не удалось» сообщить всё равно некому. Пометка отказа
|
||||
на удавшейся работе врёт и панели, и метрике;
|
||||
- **оставлять задачу пригодной к повтору** — отвергнуто: смысл повтора в том,
|
||||
чтобы работа однажды удалась, а недоставка сама не пройдёт — бот не появится
|
||||
оттого, что задачу подождали;
|
||||
- **немая заглушка, возвращающая успех** — отвергнуто находкой ревью дизайна:
|
||||
недоставку тогда некому записать, и норма «принятая запись не теряется молча»
|
||||
оказывается нарушена именно тем решением, которое её и обслуживало;
|
||||
- **отпустить отказ заглушки наверх, не разбирая** — отвергнуто: шаг объявил бы
|
||||
отказ там, где работа сделана. Задачу это в повтор не отправит — воркеры
|
||||
опрашивают только незавершённые состояния, — но воркеру засчитается сбой,
|
||||
которого не было, и владельцу уедет запись отказа. Соврала бы и метрика, и
|
||||
журнал.
|
||||
|
||||
### Решение 3: заглушка живёт рядом с настоящим отправителем
|
||||
|
||||
Место — тот же пакет, что и отправитель Telegram: заглушка знает ровно то же,
|
||||
что и он, и подставляется в сборке при старте, как и все прочие адаптеры.
|
||||
Направление зависимостей это не нарушает, и тесты-сканеры остаются зелёными.
|
||||
Само значение отказа живёт среди контрактов — там же, где «работы нет» и «захват
|
||||
потерян»: узнаёт его ядро, а порождает адаптер, и ни один из них не зависит от
|
||||
другого.
|
||||
|
||||
**Форма значения — сентинел, а не тип с полями.** Соседи по ряду несут поле
|
||||
(состояние, идентификатор задачи) и потому объявлены типами; этому нести нечего —
|
||||
заглушка не знает ни задачи, ни чата. Прецедент сентинела в проекте есть: им же
|
||||
объявлено «токен не задан».
|
||||
|
||||
**Заодно убирается третье представление того же факта.** Кроме пустой строки в
|
||||
настройках и значения «токен не задан» в пакете отправителя, в транспорте бота
|
||||
объявлен ещё один тип с тем же смыслом, не употребляемый нигде. Он снимается
|
||||
этой же задачей: объяснять четвёртое представление дороже, чем удалить мёртвое.
|
||||
|
||||
### Решение 4: живой прогон требует заполнить ещё две секции, и это говорится вслух
|
||||
|
||||
Пустого токена мало. Настройки входа проверяются на старте и роняют процесс,
|
||||
называя незаполненные ключи; конструкторы Yandex так же роняют его на пустых
|
||||
регионе, ключах Object Storage, ключе SpeechKit и папке. Ни один из них при
|
||||
старте наружу не ходит, поэтому **выдуманных непустых значений достаточно** —
|
||||
живой прогон получается, а денег не стоит.
|
||||
|
||||
Этой задачей разрез «пустое значение — отказ от возможности» на другие секции не
|
||||
переносится: распознавание без Yandex не работает по существу, и отказ от него —
|
||||
отдельное решение с отдельной ценой. Здесь только называется, что заполнить,
|
||||
чтобы сервис поднялся.
|
||||
|
||||
Отсюда же граница правки документов: строка «живой прогон недоступен» не
|
||||
снимается, а **сужается с остатком** — стал доступен подъём и осмотр, а прогон с
|
||||
по-настоящему пустыми ключами Yandex по-прежнему невозможен.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Сервис молча работает без бота, потому что токен забыли стереть или забыли
|
||||
вписать** → строка журнала при старте называет это прямо, а не оставляет
|
||||
читателю вывод из тишины. Дальше — дело того, кто выкладывает;
|
||||
- **Отказ старта на негодном токене останавливает выкладку, которая прежде
|
||||
проходила** → это и есть цель решения 1; чинится правкой настройки, и отказ
|
||||
называет, какой ключ виноват, не называя значения;
|
||||
- **Сборка бота ходит в Telegram, и без сети старт с непустым токеном упадёт** →
|
||||
поведение не новое и не ухудшается. Сегодня сборка отправителя зовётся первой и
|
||||
роняет старт на любом отказе, так что терпимость соседнего пути на негодном
|
||||
токене всё равно не срабатывает: до неё не доходит очередь. Изменение делает
|
||||
два пути согласованными и **снимает** сеть с законного пути — пустой токен не
|
||||
ходит наружу вовсе. Срока ожидания у обращения к Telegram при этом нет, и
|
||||
задача его не заводит: таймауты у трёх внешних собеседников — известный
|
||||
недостаток проекта и предмет отдельной работы;
|
||||
- **Недоставленный ответ пропадает навсегда** → отложенной доставки нет и не
|
||||
заводится: расшифровка лежит в хранилище и достаётся через панель и HTTP API;
|
||||
- **Остановка идёт по пути, которым прежде не ходили** → останов зеркален
|
||||
сборке: чего не собрали, того не закрывают и не ждут. Проверяется прогоном
|
||||
сигнала остановки на конфиге с пустым токеном.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Схемы хранилища изменение не трогает, миграции нет, откат — обычный откат образа.
|
||||
Выкладка с заполненным токеном ведёт себя ровно как прежде.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,54 @@
|
||||
## Why
|
||||
|
||||
Сервис принимает записи двумя входами — ботом Telegram и HTTP API, — но
|
||||
поднимается только тогда, когда настроены оба: пустой токен бота кончает старт
|
||||
отказом раньше, чем встаёт HTTP-сервер. Боевым токеном запускаться запрещено, и
|
||||
из этого следует, что **поднять сервис и посмотреть на него живьём не может
|
||||
никто**: всякая задача, меняющая поведение, проверяется одними тестами.
|
||||
|
||||
Намерение «работать без Telegram» в сервисе уже есть — отдельное значение «токен
|
||||
не задан» и терпимость к отказу сборки бота при старте, — но один путь его
|
||||
отменяет, и потому оно ничего не значит.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Ненастроенный вход Telegram больше не мешает подъёму: сервис встаёт и работает
|
||||
оставшимся входом — принимает записи по HTTP, расшифровывает их и отдаёт текст
|
||||
туда же. Об отсутствии бота сервис говорит одной строкой журнала при старте, а
|
||||
не молчанием.
|
||||
- Задача, пришедшая из Telegram и дошедшая до ответа тогда, когда бота нет,
|
||||
доводится до конца, а факт недоставки уезжает в журнал владельца. Сегодня такая
|
||||
задача уронила бы процесс.
|
||||
- Запрет запускаться боевым токеном остаётся: рядом с ним появляется способ
|
||||
поднять сервис без токена вовсе.
|
||||
|
||||
Ломки нет: с заданным токеном не меняется ничего.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет: оба требования ложатся в capability, чей раздел `Purpose` сам
|
||||
называет их своим предметом и приглашает дописать.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `intake`: добавляется требование о подъёме с ненастроенным входом Telegram —
|
||||
сервис работает оставшимся входом. Приём из Telegram по существу (кто допущен,
|
||||
как скачивается запись) остаётся ненормированным, и оговорка спеки об этом
|
||||
сохраняется;
|
||||
- `pipeline`: добавляется требование об ответе отправителю, чей вход не поднят —
|
||||
шаг не роняется, задача доводится до конца, недоставка идёт в журнал.
|
||||
|
||||
## Impact
|
||||
|
||||
- сборка сервиса при старте: отправитель ответов и клиент бота;
|
||||
- ответ отправителю в конвейере расшифровки;
|
||||
- секция `[telegram]` конфига и её образец `config.dist.toml`;
|
||||
- `CLAUDE.md`, раздел «Запреты» — рядом с запретом на боевой токен встаёт способ
|
||||
подняться без него;
|
||||
- `docs/review.md`, подраздел «Недоступно проверке» — строка о недоступности
|
||||
живого прогона сужается;
|
||||
- `docs/architecture.md` — перечень capability и то, что каждая нормирует.
|
||||
|
||||
Внешних зависимостей, схемы хранилища и контракта HTTP API изменение не трогает.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Ревью изменения `start-without-telegram-token` — отчёт триажа
|
||||
|
||||
Прогон 2026-08-13. Отчёт сохранён оркестратором: агент триажа записывать
|
||||
`.md` не вправе.
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Режим:** по графу; изменение не закоммичено, база диффа `origin/master`.
|
||||
- **Метка:** `large` — крупное × знакомое. Повторная разметка после правок
|
||||
дизайна: первая давала `medium`, исходя из того, что ядро не тронуто; правки
|
||||
ревью дизайна это допущение сняли.
|
||||
- **Гейт:** зелёный целиком, 13 шагов, включая `-race`, `golangci-lint`,
|
||||
`govulncheck`. Оракул снят проходом `autotests`, триаж гейт не перезапускал.
|
||||
- **Находок на входе:** 19 (specs 3, code 6, architecture 3, adversary 4,
|
||||
ops 3, autotests 0). **Осталось:** 6 в основном списке, 2 гипотезы,
|
||||
2 кандидата в промоут; срезы названы поимённо.
|
||||
|
||||
### План с исходом по каждой теме
|
||||
|
||||
| тема | дом | глубина | кто закрывает | исход |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| requirements | `openspec/specs` + дельты change | разбор | specs | закрыта, 3 находки |
|
||||
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 0 находок |
|
||||
| conventions | `docs/conventions/` | разбор | code | закрыта, 6 находок |
|
||||
| architecture | `docs/architecture.md` + `passport.md` | доказательство | architecture | закрыта, 3 находки |
|
||||
| security | `docs/security.md` | доказательство | adversary | закрыта, 4 находки |
|
||||
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | ops | закрыта, 3 находки |
|
||||
|
||||
Тем без дома нет, тем без отчёта нет. Своих тем у проекта нет, `basics` не
|
||||
запускался — все темы ядра закрыты именными проходами. Побочное следствие:
|
||||
независимого второго голоса о заниженности метки на прогоне не было.
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. Токен бота уезжает в журнал целиком при опечатке — critical
|
||||
|
||||
`internal/adapter/telegram/bot.go` возвращал отказ конструктора без чистки.
|
||||
Отказ рождается в `http.NewRequest` на разборе адреса — **до** обращения к
|
||||
клиенту, то есть мимо `safeClient` и `WithoutURL`. Токен с управляющим символом
|
||||
или неверной `%`-последовательностью печатался в журнал целиком.
|
||||
|
||||
Оракул: воспроизведено тремя проходами независимо и триажем отдельно. Нарушены
|
||||
инвариант `CLAUDE.md` «Секрет не покидает конфиг» (critical) и MUST дельта-спеки
|
||||
`intake`.
|
||||
|
||||
**Исход: починено инлайн.** Отказ конструктора пропущен через `WithoutURL`.
|
||||
Заодно закрыта дыра в собственной проверке: прежний тест судил запрет **годным**
|
||||
токеном, то есть случаем, который и так работал. Добавлен тест с токеном,
|
||||
ломающим разбор адреса.
|
||||
|
||||
### 2. Молчащий Telegram вешает старт навсегда — major
|
||||
|
||||
Клиент собран из `&http.Client{}` без срока ожидания, сборка стоит до подъёма
|
||||
сервера. При Telegram, отвечающем молчанием, процесс висит бесконечно: порт не
|
||||
слушается, `/health` не отвечает, воркеры не запущены, в журнале ни строки.
|
||||
|
||||
Поведение предсуществует изменению, но изменение **записывает его нормой**.
|
||||
Довод дизайна «различать нечем» проверяемо неверен: отказ Bot API приезжает
|
||||
типом `*tgbotapi.Error` с кодом, транспортный — нашим после чистки.
|
||||
|
||||
**Исход: развилка владельцу.** Цена дописана в таблицу отказов
|
||||
`docs/architecture.md`; выбор поведения — за владельцем.
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 3. Сервис без Telegram выглядит здоровым — major
|
||||
|
||||
`/health` отдаёт статические `200 ok` и о входах не знает; серий `transcriber_*`
|
||||
на `/metrics` при неподнятом боте ноль; поля «доставлено» в схеме нет. Владелец
|
||||
узнаёт о потерянном входе только из журнала контейнера и только до ротации.
|
||||
|
||||
**Исход: развилка владельцу.** Попутно исправлено фактическое: обоснование нормы
|
||||
называло третьим последствием перезапись служебных полей завершённой задачи —
|
||||
такого не бывает, переход в терминальное состояние снимает захват, и повторная
|
||||
запись натыкается на «захват потерян». Довод сведён к двум последствиям.
|
||||
|
||||
### 4. Задача из Telegram, потерявшая чат, считается успешной — major
|
||||
|
||||
Было `ERROR` и отказ шага, стало `WARN` и успех. Тем самым снят самый громкий
|
||||
детектор класса, который `CLAUDE.md` называет самым коварным: колонка, выпавшая
|
||||
из пары `acquireColumns`/`acquiredRow`, обнуляет чат у задачи, попавшей к
|
||||
воркеру.
|
||||
|
||||
**Исход: развилка владельцу** — развести уровни по причине или оставить.
|
||||
|
||||
### 5. Разрез старта не держался ни одним тестом — minor
|
||||
|
||||
Инвертируй разрез — весь набор оставался зелёным.
|
||||
|
||||
**Исход: починено инлайн.** Решение вынесено из `main` в `telegramFromBot` и
|
||||
накрыто тремя случаями. Обращение к Telegram отделено от решения намеренно:
|
||||
обращение ходит в сеть и в проверке недоступно, а разрез проверять надо.
|
||||
|
||||
### 6. Образец конфига и три документа описывали снятое поведение — minor
|
||||
|
||||
`config.dist.toml` оставлял непустой плейсхолдер, хотя собственный комментарий
|
||||
рядом объявлял пустой токен режимом: копия образца старт роняла.
|
||||
`docs/conventions/config.md` описывал снятый механизм строкой «Расхождение», а
|
||||
она в этом проекте выдаёт индульгенцию будущим ревью. `docs/conventions/errors.md`
|
||||
перечислял удалённый тип. Маркер канона в `docs/architecture.md` ссылался на
|
||||
несуществующие capability.
|
||||
|
||||
**Исход: починено инлайн, все четыре места.**
|
||||
|
||||
## Чем кончились развилки — решения владельца 2026-08-13
|
||||
|
||||
- **Находка 2 (молчащий Telegram).** Выбран вариант сверх предложенных:
|
||||
недоступность Telegram на старт не влияет. Разрез перенесён с «пусто /
|
||||
непусто» на «ответил ли Telegram»: ответ «такого бота нет» роняет старт,
|
||||
недоступность даёт подъём без Telegram с записью `WARN`. Из решения следует
|
||||
срок ожидания при сборке — без него недоступность неотличима от подъёма.
|
||||
- **Находка 3 (наблюдаемость).** Выбран счётчик и признак входов: метрика
|
||||
поднятости по каждому входу и счётчик недоставленных ответов с причиной
|
||||
меткой. Колонку в задаче не заводили — это шаг схемы и необратимое.
|
||||
- **Находка 4 (уровень).** Уровни разведены: неподнятый вход — `WARN`,
|
||||
неназванный адресат — `ERROR`.
|
||||
|
||||
**Отдельно о самом прогоне.** Живой прогон, снятый проходом `adversary`, оставил
|
||||
процесс работающим на том же порту, и он держал его ещё час. Часть моих проверок
|
||||
после переделки мерила этот чужой процесс, а не новую сборку; обнаружено по
|
||||
отсутствию новой метрики, исправлено остановкой процесса и повторным прогоном.
|
||||
Кандидат в правило: прогон, поднимающий сервис, обязан снимать его за собой, а
|
||||
проверяющий — убеждаться, что порт занят его собственной сборкой.
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **`{"ok":true,"result":null}` считается успешной доставкой.** Механизм доказан
|
||||
на подставном сервере, вторая половина — что живой Telegram так отвечает — не
|
||||
доказана и по правилам проекта недоказуема. Предсуществует изменению.
|
||||
- **Второй `os.Exit(1)` недостижим сегодня.** Приемлемая страховка, не дефект:
|
||||
конструктор объявляет отказ в сигнатуре, и разобрать его вызывающий обязан.
|
||||
|
||||
## Кандидаты в промоут
|
||||
|
||||
- **Порядок выкладки: конфиг с пустым токеном нельзя выкатывать раньше бинаря.**
|
||||
Воспроизведено на `origin/master` в отдельном worktree: откат бинаря при уже
|
||||
применённом пустом токене останавливает весь сервис. Это правило эксплуатации,
|
||||
которого в проекте нет; дом — `docs/architecture.md`, «Эксплуатация».
|
||||
- **Сверка документов на упоминания удалённых идентификаторов.** Три из четырёх
|
||||
мест находки 6 — прямые ссылки на снесённый код и несуществующие capability.
|
||||
Ловит это `av-dev:doc-healthcheck`, которого зовут руками.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Что не проверил ни один проход** (`docs/review.md`, «Недоступно проверке»):
|
||||
поведение SpeechKit и Object Storage под нагрузкой; реальный профиль нагрузки;
|
||||
стойкость `ffmpeg` к вредоносному входу; поведение настоящей Authelia; поведение
|
||||
браузера с куками.
|
||||
|
||||
**Перестали проверять сознательно:** разбор вывода настоящего `ffprobe`; работа
|
||||
сервиса с настоящими внешними собеседниками. Подъём живьём стал доступен как раз
|
||||
этим изменением, но остаток — приём из Telegram, расшифровка, заливка — не
|
||||
проверяет никто.
|
||||
|
||||
**Чего не принесёт ни один прогон:**
|
||||
|
||||
1. Решения проекта не сверялись — `docs/adr/` процессный, прогон его не
|
||||
открывает. Расхождение с записанным решением ловит `av-dev:doc-healthcheck`.
|
||||
2. Записанные наблюдения не использовались — `docs/research/` тоже процессный.
|
||||
Всякое число этого отчёта снято на этом прогоне.
|
||||
3. Поимённой сверки с руководствами по стилю Go не задавал ни один проход.
|
||||
4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.
|
||||
|
||||
**Сработавшие потолки.** Потолок триажа: 19 находок → 6. Срезано поимённо:
|
||||
дубли в тестах (близко к вкусовщине; починено попутно, тот же файл правился
|
||||
находкой 1); избыточность представлений факта «Telegram не поднят» — шесть
|
||||
вместо четырёх, одно сократимо, последствие не названо; откат бинаря — уехал в
|
||||
промоут; вырожденный ответ библиотеки — в гипотезы.
|
||||
|
||||
**Отдельная находка о самом прогоне:** проходы отдавали сводки пересказом, и
|
||||
свои блоки «Coverage of this pass» с потолками до триажа дошли не все — узнать,
|
||||
срезал ли `code` или `adversary` что-то у себя, из отчёта нельзя.
|
||||
@@ -0,0 +1,90 @@
|
||||
## Purpose
|
||||
|
||||
Приём записи и опрос готовности задачи расшифровки: что считается принятой
|
||||
записью, что уезжает в ответ и что происходит, когда запись не удалось
|
||||
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
|
||||
|
||||
Приём по существу описан пока **только для HTTP** — того, что нормируют
|
||||
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
|
||||
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
|
||||
требованиями по-прежнему не описано — требование, написанное без проверки, это
|
||||
предположение, а не норма. Первая задача, которая трогает поведение приёма из
|
||||
Telegram, дописывает его сюда.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму
|
||||
|
||||
Сервис SHALL подниматься, когда вход Telegram поднять не удалось, и MUST
|
||||
продолжать работу оставшимся входом: приём по HTTP, опрос готовности и конвейер
|
||||
расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в журнале
|
||||
**ровно одной** записью уровня `WARN` при старте — с причиной и без значения
|
||||
токена.
|
||||
|
||||
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
|
||||
бота нет» — ошибка настройки: бот по этому токену не появится ни от ожидания, ни
|
||||
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
|
||||
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
|
||||
бы неоткуда.
|
||||
|
||||
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
|
||||
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и класть его
|
||||
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
|
||||
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
|
||||
|
||||
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
|
||||
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
|
||||
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
|
||||
здоровья.
|
||||
|
||||
Требование нормирует **наличие входа**, а не приём из него.
|
||||
|
||||
#### Scenario: Токен не задан
|
||||
|
||||
- **GIVEN** в настройках сервиса токен бота пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и принимает записи по HTTP
|
||||
- **AND** конвейер расшифровки работает
|
||||
- **AND** бот не заведён, а в журнале ровно одна запись уровня `WARN` о том, что
|
||||
он не поднят и почему
|
||||
|
||||
#### Scenario: Токен задан и годен
|
||||
|
||||
- **GIVEN** в настройках сервиса стоит токен, по которому Telegram признаёт бота
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и работает обоими входами
|
||||
|
||||
#### Scenario: Telegram не отвечает
|
||||
|
||||
- **GIVEN** в настройках сервиса стоит непустой токен
|
||||
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и принимает записи по HTTP
|
||||
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
|
||||
- **AND** запись не несёт значения токена
|
||||
|
||||
#### Scenario: Telegram ответил, что такого бота нет
|
||||
|
||||
- **GIVEN** в настройках сервиса стоит непустой токен
|
||||
- **AND** Telegram отвечает отказом на этот токен
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** ни журнал, ни текст отказа не несут значения токена
|
||||
|
||||
### Requirement: Поднятые входы видны наблюдателю
|
||||
|
||||
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
|
||||
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
|
||||
вход и неподнятый.
|
||||
|
||||
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
|
||||
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
|
||||
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
|
||||
единственный канал наблюдения, который у владельца автоматизирован.
|
||||
|
||||
#### Scenario: Вход Telegram не поднят
|
||||
|
||||
- **GIVEN** сервис поднялся без Telegram
|
||||
- **WHEN** наблюдатель читает метрики
|
||||
- **THEN** признак поднятости входа Telegram равен нулю
|
||||
- **AND** признак поднятости входа HTTP равен единице
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
## Purpose
|
||||
|
||||
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
|
||||
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
|
||||
когда доставить его некуда.
|
||||
|
||||
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
|
||||
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
|
||||
результата держателем захвата и недоставка ответа при неподнятом входе.
|
||||
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
|
||||
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
|
||||
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
|
||||
требования на него не написаны, потому что требование без проверки —
|
||||
предположение, а не норма. Первая задача, которая трогает любое из
|
||||
перечисленного, дописывает его сюда.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Недоставленный ответ не роняет шаг
|
||||
|
||||
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
|
||||
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
|
||||
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
|
||||
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
|
||||
меткой.
|
||||
|
||||
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят** —
|
||||
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
|
||||
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
|
||||
|
||||
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
|
||||
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
|
||||
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
|
||||
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
|
||||
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
|
||||
записей о ненастроенном боте.
|
||||
|
||||
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
|
||||
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
|
||||
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
|
||||
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
|
||||
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
|
||||
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
|
||||
журнала, а не в состоянии задачи.
|
||||
|
||||
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
|
||||
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
|
||||
запись MUST не попадать — приватность содержимого записи требование не
|
||||
ослабляет.
|
||||
|
||||
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
|
||||
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
|
||||
|
||||
#### Scenario: Вход отправителя не поднят
|
||||
|
||||
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
|
||||
- **AND** сервис поднялся без этого входа
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
|
||||
`failed` не переводится
|
||||
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
|
||||
задачи и причиной
|
||||
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
|
||||
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
|
||||
|
||||
#### Scenario: Адресат у задачи не назван
|
||||
|
||||
- **GIVEN** у задачи источником значится Telegram, а чат не назван
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||
- **AND** задача остаётся в достигнутом состоянии
|
||||
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
|
||||
задачи и причиной: неназванный адресат — симптом порчи записи
|
||||
|
||||
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
|
||||
|
||||
- **GIVEN** задача принята по HTTP
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа и без записи о недоставке
|
||||
@@ -0,0 +1,127 @@
|
||||
## 1. Отправитель, который не отправляет
|
||||
|
||||
- [x] 1.1 Завести среди контрактов значение отказа «канал доставки не поднят» —
|
||||
рядом с «работы нет» и «захват потерян», узнаваемое тем же способом
|
||||
- [x] 1.2 Завести в пакете отправителя Telegram заглушку, реализующую контракт
|
||||
отправки: она ничего не отправляет и на всякий ответ возвращает это
|
||||
значение
|
||||
- [x] 1.3 Проверить тестом, что заглушка возвращает именно его и ничего не пишет
|
||||
сама
|
||||
|
||||
## 2. Ответ отправителю в конвейере
|
||||
|
||||
- [x] 2.1 Научить ответ отправителю узнавать это значение: пишется запись уровня
|
||||
`WARN` с идентификатором задачи и причиной, шаг завершается без отказа
|
||||
- [x] 2.2 Свести к тому же исходу вторую причину недоставки — задачу источника
|
||||
Telegram без названного чата: сегодня она даёт отказ шага на уже
|
||||
завершённой работе, то есть ложный сбой в счётчике воркера и перезапись
|
||||
служебных полей
|
||||
- [x] 2.3 Проверить, что в записи нет ни текста расшифровки, ни сообщения
|
||||
отправителя
|
||||
- [x] 2.4 Тест конвейера: задача источника Telegram доходит до ответа через
|
||||
заглушку — шаг без отказа, состояние задачи не откатывается, в повтор она
|
||||
не уходит и в `failed` не переводится
|
||||
- [x] 2.5 Тест: задача источника Telegram без чата даёт тот же исход
|
||||
- [x] 2.6 Тест: задача, принятая по HTTP, до заглушки не доходит и записи о
|
||||
недоставке не порождает
|
||||
|
||||
## 3. Сборка при старте
|
||||
|
||||
- [x] 3.1 Свести сборку клиента бота к одной: отправитель ответов принимает
|
||||
готового клиента вместо токена, транспорт получает того же
|
||||
- [x] 3.2 Поставить разрез в этом единственном месте: пустой токен даёт заглушку
|
||||
и одну запись уровня `WARN` о неподнятом боте, любой другой отказ сборки
|
||||
роняет старт
|
||||
- [x] 3.3 Тест на непустой токен, с которым бот не заводится: старт роняется.
|
||||
Живой Telegram не нужен — адрес подставляется, как в имеющемся тесте
|
||||
клиента
|
||||
- [x] 3.4 Проверить, что ни запись о неподнятом боте, ни текст отказа старта не
|
||||
несут значения токена
|
||||
- [x] 3.5 Проверить остановку: сигнал остановки на конфиге с пустым токеном
|
||||
завершает процесс тем же кодом и в тот же срок, что и с токеном
|
||||
- [x] 3.6 Удалить неупотребляемый тип отказа «токен пуст» в транспорте бота —
|
||||
третье представление того же факта
|
||||
|
||||
## 4. Настройки и их образец
|
||||
|
||||
- [x] 4.1 Описать в образце конфига, что пустой токен означает подъём без
|
||||
Telegram и что при этом перестаёт работать
|
||||
- [x] 4.2 Назвать там же остальные секции, без которых сервис не поднимется:
|
||||
настройки входа и Yandex требуют непустых значений, при локальном прогоне
|
||||
годятся выдуманные, наружу при старте не ходит ни одна
|
||||
|
||||
## 5. Проверки
|
||||
|
||||
- [x] 5.1 Тест на сборку отправителя с непустым токеном: прежний путь сохранён
|
||||
- [x] 5.2 Живой прогон: конфиг с пустым токеном и заполненными по 4.2 секциями,
|
||||
`GET /health` отвечает `200`, в выводе есть запись о неподнятом боте
|
||||
- [x] 5.3 `task gate` зелёный
|
||||
|
||||
## 7. Развилки ревью кода — решения владельца 2026-08-13
|
||||
|
||||
- [x] 7.1 Недоступность Telegram на старт не влияет: разрез перенесён на «ответил
|
||||
ли Telegram». Ответ «такого бота нет» роняет старт, всё прочее даёт подъём
|
||||
без Telegram с записью `WARN`
|
||||
- [x] 7.2 Ограничить ожидание при сборке клиента сроком — без него недоступность
|
||||
неотличима от подъёма; длинный опрос сроком не ограничен
|
||||
- [x] 7.3 Признак поднятости входов метрикой и счётчик недоставленных ответов с
|
||||
причиной меткой
|
||||
- [x] 7.4 Развести уровни недоставки: неподнятый вход — `WARN`, неназванный
|
||||
адресат — `ERROR` (симптом порчи записи)
|
||||
- [x] 7.5 Проверки на все четыре ветки сборки и на оба уровня недоставки
|
||||
|
||||
## 6. Документы
|
||||
|
||||
- [x] 6.1 `CLAUDE.md`, раздел «Запреты»: рядом с запретом на боевой токен встаёт
|
||||
способ подняться без него
|
||||
- [x] 6.2 `docs/review.md`, подраздел «Недоступно проверке»: строка о живом
|
||||
прогоне сужается **с остатком** — подъём и осмотр стали доступны, прогон с
|
||||
пустыми ключами Yandex по-прежнему нет
|
||||
- [x] 6.3 `docs/architecture.md`: перечень capability отражает, что нормируют
|
||||
`intake` и `pipeline` после этого изменения
|
||||
- [x] 6.4 `docs/architecture.md`, таблица отказов внешних зависимостей: строка
|
||||
про Telegram сегодня обещает дежурному «бот не стартует, приложение
|
||||
продолжает работу без него» — привести к новому разрезу ссылкой на
|
||||
требование, не перенося поведение в обзор
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
Первые три — дословно из записи задачи `local-run-without-telegram-token`.
|
||||
**Четвёртый переписан** решением владельца на чекпоинте 2026-08-13: в прежней
|
||||
редакции он требовал, чтобы задача осталась пригодной к повтору либо перешла в
|
||||
`failed`, а дизайн отверг оба исхода с ценой, и норма `pipeline` требует прямо
|
||||
обратного. Прежняя редакция сделала бы приёмку зелёной на поведении, которое это
|
||||
же изменение запрещает. Запись задачи поправлена тем же решением.
|
||||
|
||||
- Сервис поднимается с пустым токеном бота: HTTP отвечает, воркеры идут, бот не
|
||||
создан. Оракул — запуск с конфигом без токена и запрос `GET /health`: код 200.
|
||||
- Отсутствие бота названо в журнале один раз при старте, а не молчанием. Оракул —
|
||||
тот же запуск: в выводе есть строка о том, что бот не поднят и почему.
|
||||
- Поведение с настоящим токеном не изменилось. Оракул — тест на создание
|
||||
отправителя с непустым токеном: прежний путь сохранён.
|
||||
- Задача из Telegram, дошедшая до ответа при отсутствующем боте, не роняет
|
||||
процесс и не теряется молча: она остаётся в достигнутом состоянии, в повтор не
|
||||
уходит и в `failed` не переводится, а недоставка видна записью журнала с
|
||||
идентификатором задачи. Оракул — тест конвейера с задачей источника Telegram и
|
||||
заглушкой вместо отправителя.
|
||||
- Задача источника Telegram без названного чата даёт тот же исход, а не отказ
|
||||
шага. Оракул — тест конвейера на такой задаче: воркеру сбой не засчитан,
|
||||
служебные поля завершённой задачи не переписаны.
|
||||
|
||||
Сверх записи задачи — из ревью дизайна:
|
||||
|
||||
- Непустой токен, с которым бот не заводится, роняет старт. Оракул — тест с
|
||||
подставным адресом Bot API.
|
||||
- Записей о неподнятом боте ровно одна. Оракул — живой прогон с пустым токеном:
|
||||
отбор по журналу даёт одну строку, а не две.
|
||||
- Клиент бота собирается в одном месте. Оракул — отправитель ответов принимает
|
||||
клиента, а не токен, и `NewBot` зовётся из сборки при старте однажды.
|
||||
|
||||
Сверх ревью кода — решения владельца по трём развилкам:
|
||||
|
||||
- Недоступность Telegram подъёму не мешает, ответ «такого бота нет» роняет старт.
|
||||
Оракул — проверки на четыре ветки сборки.
|
||||
- Поднятость входов видна метрикой. Оракул — живой прогон с пустым токеном:
|
||||
признак входа Telegram равен нулю, признак HTTP — единице.
|
||||
- Неназванный адресат пишется уровнем `ERROR`, неподнятый вход — `WARN`. Оракул
|
||||
— проверки конвейера на обе причины.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-13
|
||||
@@ -0,0 +1,240 @@
|
||||
## Context
|
||||
|
||||
Разрез «поднимать ли вход Telegram» сегодня проходит по пустоте ключа доступа:
|
||||
`telegram.bot_token = ""` означает и «вход выключен намеренно», и «ключа нет».
|
||||
Разрез объявлен решением владельца от 2026-08-13 и записан в
|
||||
[ADR-2026-08-13-telegram-outage-does-not-block-startup](../../../docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md);
|
||||
здесь меняется не он, а то, **откуда** сервис узнаёт намерение владельца.
|
||||
|
||||
Ограничения, с которыми считаемся:
|
||||
|
||||
- файл настроек на сервере собирает Ansible из `pet-project-server`, и ключ
|
||||
доступа приезжает туда из внешнего хранилища секретов. Значение, потерянное при
|
||||
сборке, неотличимо от решения владельца;
|
||||
- инвариант «секрет не покидает конфиг» — ни сообщение об отказе старта, ни
|
||||
запись журнала не несут значения ключа. Проверка секции `[auth]` уже устроена
|
||||
так и служит здесь образцом;
|
||||
- локальный прогон боевым токеном запрещён, и подъём без Telegram — его обычный
|
||||
режим. Он не должен стать труднее.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- признак включения объявляет намерение, ключ доступа означает только доступ;
|
||||
- включённый вход без ключа роняет старт с внятным сообщением;
|
||||
- выключенный вход сообщается записью журнала, не поднимая уровень до
|
||||
предупреждения;
|
||||
- локальный прогон одним входом остаётся одной строкой настройки.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- приём записи из Telegram, белый список и доставка ответов;
|
||||
- чистка прочих путей, где секрет мог бы уехать наружу: работа закрывает один
|
||||
названный ревью — текст отказа разбора файла настроек;
|
||||
- единое место проверки настроек для всех секций: `[auth]` и `[telegram]` пока
|
||||
проверяются каждая своим методом, и сведение их в один проход — отдельная
|
||||
работа;
|
||||
- правка шаблона настроек в `pet-project-server`: его правит человек, здесь он
|
||||
только назван.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Признак обязателен, умолчания у него нет
|
||||
|
||||
Файл настроек без ключа `enabled` негоден: загрузка кончается отказом, и процесс
|
||||
выходит с ошибкой настройки. Решение владельца от 2026-08-13.
|
||||
|
||||
Довод: умолчание — это угаданное намерение, а признак заводится ровно затем,
|
||||
чтобы намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
|
||||
стороны, и любое умолчание делает одну из двух ошибок тихой.
|
||||
|
||||
Альтернативы и причина отказа:
|
||||
|
||||
- **умолчание «включён»** — отвергнуто владельцем: файл без признака работал бы
|
||||
«как-нибудь», и разница между объявленным и угаданным намерением исчезала бы
|
||||
ровно там, где её завели;
|
||||
- **умолчание «выключен»** — отвергнуто и по тому же доводу, и отдельно: первый
|
||||
же подъём после выкладки выключил бы бота молча. Это исход, против которого
|
||||
написано само требование.
|
||||
|
||||
Цена решения — порядок выкладки: шаблон настроек обязан получить признак раньше
|
||||
образа. Она названа в разделе «Migration Plan» и на чекпоинте.
|
||||
|
||||
### Отсутствие ключа ловит загрузчик, пустой ключ — проверка секции
|
||||
|
||||
Разрез идёт по тому, **о чём судим**. Отсутствие ключа — свойство файла, и
|
||||
видит его только разбор: `toml.DecodeFile` отдаёт `MetaData`, и `IsDefined`
|
||||
отвечает, был ли ключ в файле вообще. Значение поля — свойство настройки, и
|
||||
судит его `TelegramConfig.Validate()` по образцу `AuthConfig.Validate()`.
|
||||
|
||||
Альтернатива — сделать поле `*bool` и свести обе проверки в `Validate()` —
|
||||
отвергнута: указатель переживает проверку и уезжает к потребителям, где `nil`
|
||||
уже невозможен, но выглядит возможным. Читатель настройки платит за форму,
|
||||
нужную одному разбору.
|
||||
|
||||
`MetaData` из `LoadConfig` наружу не отдаётся: отказ формируется на месте, и
|
||||
знание о разборе не растекается.
|
||||
|
||||
### Проверка ключа живёт в настройках, а не в сборке входа
|
||||
|
||||
`TelegramConfig.Validate()` зовётся из `main.go` сразу после загрузки, рядом с
|
||||
проверкой секции `[auth]`, роняет процесс, называет **имя** незаполненного ключа
|
||||
и не касается значения.
|
||||
|
||||
Альтернатива — оставить проверку внутри сборки клиента, как сейчас, — отвергнута:
|
||||
сборка ходит в сеть, и отказ настройки смешался бы там с отказом Telegram. Читать
|
||||
разрез пришлось бы по типу ошибки, а не по месту.
|
||||
|
||||
### Ветка «токен пуст» в разборе сборки меняет исход
|
||||
|
||||
Сегодня `telegramFromBot` на `telegram.ErrEmptyToken` отдаёт мягкий исход: сервис
|
||||
поднимается без Telegram. После разведения это состояние по построению
|
||||
недостижимо — проверка настроек ловит его раньше, — но ветку не убираем: она
|
||||
получает исход «ошибка настройки, старт роняется» и встаёт рядом с отказом Bot
|
||||
API.
|
||||
|
||||
Причина: удалённая ветка оставила бы пустой ключ падать в общий случай `err !=
|
||||
nil`, то есть в «недоступность», и обход проверки настроек дал бы тихий подъём —
|
||||
ровно то, что мы убираем. Ветка, недостижимая по построению, но дающая верный
|
||||
исход, дешевле ветки, дающей неверный.
|
||||
|
||||
Единая точка `telegram.NewBot` и значение `telegram.ErrEmptyToken` остаются как
|
||||
есть: они держат инвариант «Bot API только через нашего клиента».
|
||||
|
||||
### Отказ разбора файла настроек говорит своими словами
|
||||
|
||||
Найдено ревью дизайна и чинится этой же работой по решению владельца.
|
||||
|
||||
`toml.DecodeFile` отдаёт отказы двух семейств, и значения несёт **только одно**:
|
||||
|
||||
- `toml.ParseError` — сюда сведены отказы лексера и разбора значения, и его поле
|
||||
`Message` собирается из разбираемого куска (`Invalid float value: %q`,
|
||||
`invalid duration: %q`, `%v is out of range`). Незакавыченный токен из криво
|
||||
собранного шаблона выкладки попадает в текст целиком. Из этого отказа берём
|
||||
**строку, столбец и последний ключ** — они безопасны, — а `Message` не берём;
|
||||
- прочие отказы декодера (несовпадение типов, неподдерживаемый тип) собираются
|
||||
из **имён ключей и имён типов**, значений в них нет. Их текст берём как есть:
|
||||
выбрасывать его значило бы платить разборчивостью отказа там, где платить не за
|
||||
что.
|
||||
|
||||
Отвергнутые альтернативы:
|
||||
|
||||
- **выбросить текст обоих семейств** — просто и закрыто наглухо, но за
|
||||
несовпадение типов (`port = "8080"`) владелец получал бы «файл не
|
||||
разбирается» без единого намёка, а значения там нет по построению;
|
||||
- **вычищать значения из текста** — вычищать не с чем: разбор не состоялся, и
|
||||
значений в настройках ещё нет;
|
||||
- **брать `Message`, когда последний ключ не секретный** — перечень секретных
|
||||
ключей живёт в конвенции и разошёлся бы с кодом молча, а расхождение здесь
|
||||
означает утечку.
|
||||
|
||||
**Спеки это не меняет, и требования под себя не заводит.** Норма уже записана и
|
||||
сильнее спеки: инвариант «Секрет не покидает конфиг» в `CLAUDE.md` со степенью
|
||||
`critical`. Работа приводит код в соответствие с записанным, а не заказывает
|
||||
новое поведение. Форма записи отказа уезжает в конвенцию настроек, раздел
|
||||
«Секреты», — там её дом.
|
||||
|
||||
**Дом нормы назначен явно, и это выбор, а не умолчание.** Загрузка настроек не
|
||||
принадлежит ни одной заведённой capability: `intake` сама объявляет, что нормирует
|
||||
наличие входа, а не приём; `access`, `pipeline` и `storage` к разбору файла
|
||||
отношения не имеют. Заводить capability подъёма ради одного семейства отказов
|
||||
дороже выигрыша, а вписывать разбор настроек в `intake` значит переносить туда
|
||||
чужое. Поэтому дом нормы — **инвариант `CLAUDE.md` плюс конвенция
|
||||
`docs/conventions/config.md`**, и спеки загрузку настроек не нормируют.
|
||||
Найдено ревью кода; цена решения в том, что при следующей ревизии семейства
|
||||
отказов спека не скажет ничего и опорой будут конвенция и проверки.
|
||||
|
||||
### Выключенный вход — уровень `INFO`
|
||||
|
||||
Предупреждение говорит «случилось не то, что ты просил». Выключенный вход — ровно
|
||||
то, что просил владелец, и на каждом локальном прогоне это давало бы шум,
|
||||
неотличимый от настоящей недоступности. Недоступность остаётся `WARN`.
|
||||
|
||||
Признак поднятости входа (`IntakeUpGauge`) выставляется во всех случаях, включая
|
||||
выключенный: наблюдателю нужен ответ «работает ли вход сейчас», а не «почему».
|
||||
|
||||
### Сборка входа получает настройки секцией, а решение о выключенном входе — шов
|
||||
|
||||
`buildTelegram` принимает `config.TelegramConfig` целиком вместо одного токена:
|
||||
решение «поднимать или нет» читает оба поля, и разносить их по двум аргументам
|
||||
значит заводить два места, где их сверяют.
|
||||
|
||||
Само решение уезжает в `telegramFromConfig(cfg, newBot, logger)`, где `newBot` —
|
||||
параметр-функция сборки клиента; `buildTelegram` подставляет туда
|
||||
`telegram.NewBot`. Иначе главное утверждение выключенного входа — **обращения к
|
||||
Telegram не уходит ни одного** — проверить нечем: `telegram.NewBot` держит адрес
|
||||
Bot API внутри, и проверка, судящая по исходу, останется зелёной и тогда, когда
|
||||
ветка выключенного входа встанет **после** обращения. Тогда прогон с заполненным
|
||||
ключом ходил бы в живой Telegram боевым токеном, а проверка этого не заметила бы.
|
||||
|
||||
Шов — параметр-функция, а не интерфейс: реализация у него одна, и вводить ради
|
||||
неё тип значит заводить понятие там, где хватает подписи. Прецедент в проекте
|
||||
свой и того же рода — `telegram.newBot(token, endpoint, logger)` принимает адрес
|
||||
отдельно ровно затем, чтобы проверка не ходила в сеть.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Файл настроек на сервере отстал от кода** → сервис не поднимется вовсе:
|
||||
признака в файле нет, загрузка кончается отказом. Это главный риск работы, и
|
||||
снимается он порядком выкладки — сперва шаблон настроек, потом образ. Отказ
|
||||
громкий, называет ключ и виден в первую же минуту; молчаливая потеря бота
|
||||
обошлась бы дороже, но порядок соблюсти обязан человек.
|
||||
- **Локальный файл настроек отстал от кода** → тот же отказ и та же починка:
|
||||
одна строка `enabled = false`.
|
||||
- **Проверок настроек стало две вместо одной** → расхождение между ними ловится
|
||||
только глазами. Сведение в один проход названо Non-Goal и остаётся работой на
|
||||
потом.
|
||||
- **Ошибочный `enabled = false` из шаблона выкладки** → работа закрывает одно
|
||||
русло молчаливой потери бота (потерян ключ доступа) и оставляет второе:
|
||||
признак, отрендеренный ложным из-за пропущенной переменной, отличим от решения
|
||||
владельца **только записью журнала** — `INFO` против `WARN`. Признак
|
||||
поднятости входа тут не помощник: он равен нулю и при выключенном входе, и при
|
||||
недоступности Telegram, то есть от аварии этот случай не отделяет, а
|
||||
собственного оповещения у проекта нет вовсе. Сервис поднимается штатно, и
|
||||
владелец узнаёт о беде от молчащего бота — тем же способом, что и прежде.
|
||||
Ненаписанный риск читается как несуществующий, поэтому он назван здесь: ключ
|
||||
`enabled` в шаблоне выкладки критический.
|
||||
- **Остаточный риск утечки при смене версии библиотеки разбора** → разрез ниже
|
||||
опирается на то, какие семейства отказов несут значения сегодня. Версия
|
||||
библиотеки, переложившая значение в другое семейство, вернёт утечку молча.
|
||||
Держится это проверкой на поломанной строке секретного ключа; она же краснеет
|
||||
при таком переносе.
|
||||
- **Ветка, недостижимая по построению**, живёт в коде и её нельзя проверить
|
||||
через настройки → проверяется напрямую на уровне разбора исхода сборки, как
|
||||
уже устроены соседние ветки.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Порядок обязателен, и нарушение его роняет сервис на сервере.
|
||||
|
||||
1. Код и образец настроек едут вместе: `config.dist.toml` получает
|
||||
`enabled = false` при пустом ключе доступа — это состояние свежей локальной
|
||||
установки.
|
||||
2. **Раньше накатки образа** шаблон настроек в `pet-project-server` получает
|
||||
строку `enabled = true`, и файл на сервере перерисовывается. Правит человек,
|
||||
отдельно от этой работы; пока правки нет, новый образ на сервер не едет.
|
||||
3. Только после этого едет образ.
|
||||
|
||||
Откат: вернуть прежний образ. Файл настроек с ключом `enabled` прежний код
|
||||
разбирает без отказа — лишний ключ TOML разбор не роняет, он просто не читается,
|
||||
и бот поднимается по непустому токену.
|
||||
|
||||
**Откат при выключенном входе допустим только на образ от 2026-08-13 и новее.**
|
||||
На более старом состояния «сервис поднят, бот опущен» не существует вовсе:
|
||||
пустой ключ роняет старт, негодный роняет старт, годный поднимает бота. Откат
|
||||
туда делают с непустым годным ключом, приняв, что бот поднимется; рецепт ниже на
|
||||
таком образе ведёт к выходу с кодом 1 до открытия порта.
|
||||
|
||||
**Один случай отката требует и отката настроек** — `enabled = false` при
|
||||
заполненном ключе доступа, то самое состояние, ради которого два значения и
|
||||
разводятся. Прежний код признака не видит и поднимает бота, то есть отменяет
|
||||
решение владельца молча. Если вход был выключен потому, что бот с этим токеном
|
||||
поднят где-то ещё, два процесса поделят один длинный опрос и часть ответов до
|
||||
людей не дойдёт — прямо тот вред, который называет запрет «Боевым токеном бота не
|
||||
запускаться». Откат в этом состоянии начинается с очистки ключа доступа.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Открытых нет: умолчание признака решено владельцем 2026-08-13 — признак
|
||||
обязателен, умолчания у него нет.
|
||||
@@ -0,0 +1,67 @@
|
||||
## Why
|
||||
|
||||
Сегодня пустой токен бота означает сразу две разные вещи: «вход Telegram
|
||||
выключен намеренно» и «ключа доступа нет». Владелец не может сказать сервису
|
||||
«бот мне нужен» отдельно от «вот ключ», а сервис не может отличить осознанный
|
||||
отказ от входа от криво отрендеренного файла настроек — и в обоих случаях
|
||||
поднимается без бота.
|
||||
|
||||
Цена расхождения падает на выкладку: файл настроек собирает Ansible, и потерянный
|
||||
при сборке ключ выглядит для сервиса ровно так же, как решение владельца обойтись
|
||||
одним входом. Бот молча перестаёт отвечать своим отправителям, а узнать об этом
|
||||
неоткуда.
|
||||
|
||||
## What Changes
|
||||
|
||||
- В настройках входа Telegram появляется отдельный признак включения. Он и
|
||||
объявляет намерение: нужен ли сервису этот вход вообще.
|
||||
- Ключ доступа перестаёт нести второе значение. Он читается и проверяется
|
||||
**только** при включённом входе, а при выключенном не смотрится вовсе.
|
||||
- Включённый вход без ключа доступа становится ошибкой настройки: сервис
|
||||
говорит, какого ключа не хватает, и не поднимается. Прежде такой файл давал
|
||||
тихий подъём без бота.
|
||||
- Выключенный вход перестаёт быть поводом для предупреждения в журнале: решение
|
||||
владельца сообщается обычной записью, а предупреждение остаётся за тем, чего
|
||||
владелец не выбирал, — недоступностью Telegram.
|
||||
- Отказ разбора файла настроек перестаёт пересказывать библиотеку разбора и
|
||||
говорит своими словами: где сломалось и на каком ключе, но не что там
|
||||
написано. Прежде поломанная строка секретного ключа уезжала в журнал вместе со
|
||||
своим значением.
|
||||
- **BREAKING** для файла настроек: у секции Telegram появляется новый
|
||||
**обязательный** ключ. Умолчания у него нет: файл без признака негоден, и
|
||||
сервис выходит с ошибкой настройки. Решение владельца от 2026-08-13 — намерение
|
||||
объявляют, а не угадывают по умолчанию, и файл, где его забыли объявить, не
|
||||
должен работать «как-нибудь».
|
||||
|
||||
Прежние правила подъёма при включённом входе сохраняются целиком: Telegram
|
||||
отвечает «такого бота нет» — старт кончается отказом; Telegram недоступен или
|
||||
молчит дольше срока — сервис поднимается одним входом и говорит об этом
|
||||
предупреждением. Признак поднятости входа наблюдателю виден во всех случаях.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет: речь о том, с какими входами сервис вправе подняться, а это уже
|
||||
нормировано.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `intake`: требование «Недоступный или незаданный вход Telegram не мешает
|
||||
подъёму» перестаёт выводить намерение из ключа доступа. Оно начинает опираться
|
||||
на объявленный признак включения, получает два новых отказа старта — признака
|
||||
в настройках нет и вход включён без ключа — и разводит уровни записей журнала
|
||||
по тому, выбрал ли владелец это состояние.
|
||||
|
||||
## Impact
|
||||
|
||||
- Настройки: секция `[telegram]` в `config.toml` и в образце
|
||||
`config.dist.toml`; структура настроек и умолчания в `internal/config`.
|
||||
- Подъём: разбор случая при сборке входа Telegram (`telegram_build.go`) и вызов
|
||||
проверки настроек в `main.go`.
|
||||
- Выкладка: шаблон настроек в `pet-project-server` обязан получить признак
|
||||
включения **до** накатки нового образа, иначе сервис не поднимется. Правит его
|
||||
человек, здесь только называем.
|
||||
- Документы: запрет на боевой токен в `CLAUDE.md`, конвенция настроек
|
||||
`docs/conventions/config.md`, таблица отказов в `docs/architecture.md`.
|
||||
- Приём записи из Telegram, белый список и доставка ответов не затрагиваются.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Ревью кода — telegram-enabled-flag
|
||||
|
||||
Метка `medium`, режим по графу. Состав: `autotests`, `specs`, `code`, `basics`,
|
||||
`triage`. Проходы `adversary`, `ops`, `architecture` не запускались — живут с
|
||||
метки `large`.
|
||||
|
||||
## План с исходом по каждой теме
|
||||
|
||||
| Тема | Дом | Глубина | Кто закрывает | Исход |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| requirements | `openspec/specs/intake/spec.md` + дельта | разбор | specs | закрыта, 3 находки |
|
||||
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 3 находки |
|
||||
| conventions | `docs/conventions/config.md` | разбор | code | закрыта, 3 находки |
|
||||
| architecture | `docs/architecture.md`, «Компоненты», «Единые точки» | разбор | basics | закрыта, находок нет |
|
||||
| security | `docs/security.md` | разбор | basics | закрыта, находок нет |
|
||||
| operations | `docs/architecture.md`, «Эксплуатация» | разбор | basics | закрыта, 2 находки |
|
||||
|
||||
Тем без отчёта нет, тем без дома нет, своих тем проекта нет.
|
||||
|
||||
## Состояние гейта
|
||||
|
||||
Зелёные: `build`, `vet`, `gofmt`, `tests` (`-race`, флака нет при `-count=1`
|
||||
трижды), `golangci-lint` (0 issues), `shell`, `dockerfile`, `go-version`,
|
||||
`migrations`, `openspec`, `vulns`.
|
||||
|
||||
Красные: `docs` и `tasks` — «проект приведён к раскладке версии 3, текущая — 4».
|
||||
Краснота **унаследована**: проход `autotests` воспроизвёл её в отдельном рабочем
|
||||
дереве на чистом `903941f` без диффа. Чинится операцией `upgrade` скилла
|
||||
`av-dev:canon` и к этой работе не относится.
|
||||
|
||||
`vulns`: единственная уязвимость `GO-2026-5932` в `golang.org/x/crypto/openpgp`
|
||||
недостижима из кода и уже названа в `CLAUDE.md`.
|
||||
|
||||
## Находки и что с ними сделано
|
||||
|
||||
15 сырых находок, после дедупликации по причине — 8 живых.
|
||||
|
||||
| № | Находка | Severity | Исход |
|
||||
| --- | --- | --- | --- |
|
||||
| — | `telegramFromConfig` не прогонялся с включённым входом (покрытие `2 0`) | major | починено до остальных проходов: два теста на связку «собрать клиента → разобрать исход» |
|
||||
| — | Ветка `decodeError` с пустым последним ключом не покрыта (`1 0`) | major | починено: тест на опечатку «незакрытая скобка секции» |
|
||||
| 1 | Раздел «Эксплуатация» предписывает обратный порядок выкладки — по нему сервис не поднимется вовсе | major | **принята**, `docs/architecture.md` переписан: порядок задаётся по ключу, а не по файлу, плюс два случая отката |
|
||||
| 2 | Сторож инварианта «секрет не покидает конфиг» зелен по построению | major | **принята**, проверка переписана честно; оракул триажа — мутация `Validate()` на утечку оставляла её зелёной |
|
||||
| 3 | Шапки `ErrEmptyToken` и `AbsentMessageSender` защищают снятое поведение | minor | **принята**, обе переписаны под новый разрез |
|
||||
| 4 | Признак поднятости входа не держится ни одной проверкой | minor | **принята**, утверждение о нуле добавлено в тест выключенного входа |
|
||||
| 5 | Новый абзац конвенции снимает с учёта непроверяемую границу `update_timeout` | minor | **принята**, утверждение сужено до двух ключей |
|
||||
| 6 | Норма «отказ разбора не несёт значения» живёт вне спек, дом не назначен | minor | **принята как развилка (а)**: дом назначен явно в `design.md` — инвариант плюс конвенция |
|
||||
| — | Смена версии библиотеки разбора могла бы вернуть утечку | гипотеза | действия не требует: ловится добавленными проверками, подтверждено мутацией |
|
||||
| — | Третий случай отката: образ старше 2026-08-13 | гипотеза | свёрнуто в находку 1, записано вопросом владельцу |
|
||||
|
||||
Проход `security` находок не дал: починку утечки он проверил по исходникам
|
||||
библиотеки независимо и признал разрез верным.
|
||||
|
||||
## Сигнал о заниженной метке
|
||||
|
||||
`review-code` подал сигнал: изменение вводит обязательный ключ настроек без
|
||||
умолчания, уже выложенный файл после этого не грузится, а имя ключа конфига
|
||||
проект числит необратимым. На метке `large` порядок выкладки и откат закрывал бы
|
||||
отдельный проход `ops`. Сигнал материализовался находкой 1 — её нашёл `basics`
|
||||
попутно, а не проход, для неё предназначенный. `review-basics` возражений по
|
||||
метке не подавал. Метка прогона не пересматривалась: правило запрещает.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
- **Три прохода не запускались** — `adversary`, `ops`, `architecture`. Уносят с
|
||||
собой враждебный разбор входов, отдельный разбор выкладки и отката, и
|
||||
независимый разбор архитектурного решения.
|
||||
- **Ни один из четырёх проходов не сообщил свой потолок и остаток за срезом.**
|
||||
Это находка о самом прогоне: без такой строки «находок больше нет»
|
||||
неотличимо от «больше не поместилось». У `basics` риск выше прочих — одна
|
||||
квота на три темы.
|
||||
- **Решения проекта не сверялись**: `docs/adr/` — процессный документ, прогон его
|
||||
не открывает. Расхождение с `ADR-2026-08-13-telegram-outage-does-not-block-startup`,
|
||||
который это изменение частично отменяет, ловит не ревью, а сверка документации.
|
||||
- **Записанные наблюдения не использовались**: `docs/research/` не открывался.
|
||||
- **Поимённая сверка с руководствами по стилю Go не задавалась никем** — в
|
||||
частности, для шва-параметра вместо интерфейса.
|
||||
- **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||
нет** — проход независимой реализации снят по стоимости.
|
||||
- **Живьём проверяемо не всё.** Подъём, отказ старта, маршруты и остановка —
|
||||
проверены. Приём из Telegram, расшифровка и заливка — нет: боевым токеном
|
||||
запускаться запрещено, ключи Yandex выдуманы, распознавание подменяется в
|
||||
коде. Новый разрез при включённом входе с настоящим ботом не проверялся ничем,
|
||||
кроме подставной сборки клиента.
|
||||
- Шаблон настроек в `pet-project-server` лежит в чужом репозитории и во вход не
|
||||
входил ни одному проходу.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Ревью дизайна — telegram-enabled-flag
|
||||
|
||||
Метка `medium`, назначена агентом `review-scope` (размер среднее, сложность
|
||||
знакомое). Режим по графу. Состав по метке: `specs` (режим «дизайн ДО кода») и
|
||||
`rubric`. Триажа на этой стадии нет — сток стадии — шаг отработки замечаний.
|
||||
|
||||
## Находки и что с ними сделано
|
||||
|
||||
| Проход | Находка | Severity | Исход |
|
||||
| --- | --- | --- | --- |
|
||||
| specs | У выключенного входа нет оракула: «бот не заведён, отказа нет» остаётся верным и когда ветка встала **после** обращения, а обращение ушло боевым токеном в живой Telegram | major | принята. Заведён шов `telegramFromConfig(cfg, newBot, logger)`, шаг 3.3 судит по счётчику вызовов, а не по исходу |
|
||||
| specs | `ADR-2026-08-13-telegram-outage-does-not-block-startup` утверждает, что старт роняет ровно один исход сборки клиента; после изменения их два | minor | принята. Шаг 4.7: новый ADR и парный статус прежнему |
|
||||
| specs | `docs/architecture.md` (перечень capability) и `docs/review.md` (рецепт живого прогона) останутся ложными: они учат поднимать сервис пустым токеном | minor | принята. Шаги 4.5 и 4.6 |
|
||||
| specs | Ошибочный `enabled = false` из шаблона выкладки — оставшееся русло молчаливой потери бота — в рисках не назван | minor | принята. Строка в `Risks / Trade-offs` |
|
||||
| rubric | Отказ разбора файла настроек может унести секрет в журнал: `toml.ParseError` встраивает разбираемое значение в текст | major | **снята из объёма, ушла в урожай.** Путь существует сегодня (`LoadConfig` заворачивает через `%w`, `main.go:49` печатает целиком) и этой работой не заводится; у починки своя цена — потеря подробности отказа |
|
||||
| rubric | Задача, принятая из Telegram до выключения входа, завершится, а ответ не уйдёт | major | **снята: ложноположительная.** Уже нормировано `openspec/specs/pipeline/spec.md`, «Недоставленный ответ не роняет шаг», сценарий «Вход отправителя не поднят»: `WARN`, метрика, идентификатор задачи. Проход читал только `intake`; `specs` пришёл к тому же выводу независимо |
|
||||
| rubric | Откат образа при `enabled = false` и непустом ключе тихо поднимает выключенного бота | minor | принята. Абзац в `Migration Plan` |
|
||||
| rubric | `docs/conventions/config.md`, раздел «Структура в коде», останется утверждать, что умолчание есть у каждого поля | minor | принята. Шаг 4.4 расширен на второй раздел |
|
||||
|
||||
## Правки, сделанные по урожаю
|
||||
|
||||
Дельта-спеки не менялись ни одной правкой — значит разметка не повторялась и
|
||||
метка осталась `medium`. Правки легли в `design.md` (шов сборки, два риска,
|
||||
абзац отката) и в `tasks.md` (шаги 2.2, 3.2, 3.3, 4.4, 4.5, 4.6, 4.7, рубрика в
|
||||
критерии приёмки).
|
||||
|
||||
## Сознательно не сделано
|
||||
|
||||
Сценарий «Вход выключен» не получил строки `**AND** признак поднятости входа
|
||||
Telegram равен нулю`. Её держит соседнее требование «Поднятые входы видны
|
||||
наблюдателю», чей сценарий стоит на премиссе «сервис поднялся без Telegram» и
|
||||
новое состояние покрывает. Правка изменила бы дельта-спеку и потребовала бы
|
||||
повторной разметки, не дав сегодня ничего.
|
||||
|
||||
## Границы спеки — что осталось неопределённым
|
||||
|
||||
- **Небулево значение признака** (`enabled = "yes"`) попадает в общий отказ
|
||||
разбора и имени ключа не называет, хотя оба соседних сценария отказа этого
|
||||
требуют.
|
||||
- **Несколько негодных секций разом**: `[auth]` и `[telegram]` проверяются
|
||||
порознь, и спека не говорит, обязан ли отказ перечислить все ключи.
|
||||
- **`update_timeout` при выключенном входе** — читается или игнорируется, не
|
||||
нормировано. Вреда нет, но вопрос стал видимым: сборка получает секцию целиком.
|
||||
- **Проба готовности при выключенном входе** ни одним требованием не связана с
|
||||
признаком. Граница существовала и до изменения.
|
||||
|
||||
## Границы покрытия стадии
|
||||
|
||||
- Кода нет по построению: направление `spec → code` недоступно, судилось только
|
||||
задуманное.
|
||||
- Рубрика составлена не открывая код и дизайн — иначе она подстроилась бы под
|
||||
увиденное.
|
||||
- Решения (`docs/adr/`) и измеренные числа (`docs/research/`) прогон ревью не
|
||||
открывает: расхождение изменения с записанным решением ловит не он, а сверка
|
||||
документации. Здесь оно всё же всплыло — проход `specs` наткнулся на ADR через
|
||||
ссылку из `design.md`, а не обходом каталога.
|
||||
- Ничего не запускалось: `openspec validate --strict` — единственная выполненная
|
||||
команда.
|
||||
- `review-architecture` на предложении не запускался: он живёт с метки `large`.
|
||||
Вопрос «не появился ли второй способ делать то же самое» на этом изменении не
|
||||
задавал никто.
|
||||
- Шаблон настроек в `pet-project-server` лежит в чужом репозитории и во вход не
|
||||
входил ни одному проходу.
|
||||
@@ -0,0 +1,113 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму
|
||||
|
||||
**Reason**: Требование выводило намерение владельца из ключа доступа: пустой ключ
|
||||
означал разом и «вход выключен», и «ключа нет». Разведение этих двух значений
|
||||
меняет и премиссу требования — включённый вход без ключа теперь подъёму мешает,
|
||||
и прежнее имя стало неверным.
|
||||
|
||||
**Migration**: Заменено требованием «Признак включения решает, поднимается ли
|
||||
вход Telegram». Прежние правила для включённого входа перенесены в него дословно;
|
||||
добавлены случай выключенного входа и случай включённого входа без ключа.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Признак включения решает, поднимается ли вход Telegram
|
||||
|
||||
Намерение владельца SHALL объявляться отдельным признаком включения входа
|
||||
Telegram, а ключ доступа MUST означать только доступ. При выключенном входе
|
||||
сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе.
|
||||
При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя
|
||||
незаполненного ключа и MUST не нести его значения.
|
||||
|
||||
Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где
|
||||
признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав
|
||||
недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён
|
||||
затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок
|
||||
тихой — либо бот молча пропадает, либо файл без признака молча работает.
|
||||
|
||||
Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO`
|
||||
при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем;
|
||||
предупреждение остаётся за тем, чего владелец не выбирал.
|
||||
|
||||
При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и
|
||||
MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и
|
||||
конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в
|
||||
журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без
|
||||
значения ключа.
|
||||
|
||||
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
|
||||
бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни
|
||||
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
|
||||
опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы
|
||||
неоткуда.
|
||||
|
||||
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
|
||||
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
|
||||
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
|
||||
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
|
||||
|
||||
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
|
||||
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
|
||||
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
|
||||
здоровья.
|
||||
|
||||
Требование нормирует **наличие входа**, а не приём из него.
|
||||
|
||||
#### Scenario: Вход выключен
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram выключен
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и принимает записи по HTTP
|
||||
- **AND** конвейер расшифровки работает
|
||||
- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что
|
||||
вход выключен настройкой
|
||||
|
||||
#### Scenario: Вход выключен, а ключ доступа задан
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram выключен
|
||||
- **AND** ключ доступа при этом заполнен
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается без Telegram, и бот не заводится
|
||||
- **AND** к Telegram не уходит ни одного обращения
|
||||
|
||||
#### Scenario: Вход включён, а ключа доступа нет
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён
|
||||
- **AND** ключ доступа пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** сообщение об отказе называет имя незаполненного ключа
|
||||
|
||||
#### Scenario: Признака включения в настройках нет
|
||||
|
||||
- **GIVEN** в настройках сервиса нет признака включения входа Telegram
|
||||
- **AND** ключ доступа заполнен и Telegram признаёт по нему бота
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом настройки
|
||||
- **AND** сообщение об отказе называет недостающий ключ
|
||||
|
||||
#### Scenario: Вход включён и ключ годен
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён
|
||||
- **AND** стоит ключ, по которому Telegram признаёт бота
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и работает обоими входами
|
||||
|
||||
#### Scenario: Telegram не отвечает
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
|
||||
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и принимает записи по HTTP
|
||||
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
|
||||
- **AND** запись не несёт значения ключа
|
||||
|
||||
#### Scenario: Telegram ответил, что такого бота нет
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
|
||||
- **AND** Telegram отвечает отказом на этот ключ
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** ни журнал, ни текст отказа не несут значения ключа
|
||||
@@ -0,0 +1,160 @@
|
||||
## Критерии приёмки
|
||||
|
||||
Постановка пришла текстом и критериев не назвала. Ниже — **предложенные**;
|
||||
данными они становятся после ответа на чекпоинте.
|
||||
|
||||
- В секции `[telegram]` файла настроек есть ключ `enabled`, и он один решает,
|
||||
поднимается ли вход. Ключ доступа второго значения не несёт.
|
||||
- Файл настроек с `enabled = false` даёт подъём одним входом, к Telegram не
|
||||
уходит ни одного обращения, а в журнале ровно одна запись уровня `INFO`.
|
||||
- Файл настроек с `enabled = true` и пустым `bot_token` роняет старт; сообщение
|
||||
называет имя ключа и не содержит его значения.
|
||||
- Файл настроек без ключа `enabled` негоден: загрузка кончается отказом, и
|
||||
сообщение называет недостающий ключ. Умолчания у признака нет.
|
||||
- Прежние правила при включённом входе сохранены: отказ Bot API роняет старт,
|
||||
недоступность Telegram даёт подъём с записью уровня `WARN`.
|
||||
- Признак поднятости входа Telegram выставляется во всех случаях, включая
|
||||
выключенный.
|
||||
- `task gate` зелёный.
|
||||
|
||||
Ниже — рубрика ревью дизайна, теми же критериями. Пункты, целиком совпавшие с
|
||||
перечнем выше, не повторяются.
|
||||
|
||||
- **Таблица режимов полна.** Для каждой комбинации «признак задан или нет ×
|
||||
признак истинен или ложен × ключ доступа пуст, непуст или подсказка» назван
|
||||
ровно один исход из трёх: подъём с ботом, подъём без бота, отказ старта.
|
||||
- **Опечатка не выключает вход молча.** `enable`, `Enabled`, ключ в чужой
|
||||
секции, отсутствующая секция — каждый случай даёт отказ, а не тихий выбор
|
||||
режима по нулевому значению.
|
||||
- **Проверка целиком предшествует необратимому.** Приговор о настройках выносится
|
||||
до открытия порта, до применения шагов схемы и до создания каталогов.
|
||||
- **Выбранный режим наблюдаем, и наблюдаемость различает основания.** Из журнала
|
||||
и метрик видно и «работает ли вход сейчас», и «по какому основанию он не
|
||||
поднят»: выбор владельца, ошибка настройки, недоступность собеседника.
|
||||
- **Решение о режиме принимается один раз и в одном месте.** Порядок «умолчания
|
||||
→ файл → приговор» зафиксирован; ни один потребитель не пересчитывает
|
||||
«поднят ли вход» из полей настроек самостоятельно.
|
||||
- **Виды отказа различимы по сообщению:** файла нет, файл не разбирается, ключ
|
||||
не задан, ключ задан негодно — по каждому видно, что чинить, и код выхода
|
||||
ненулевой.
|
||||
- **Выключенный вход не оставляет хвостов.** Клиент не заводится, сетевого
|
||||
обращения нет, остановка не ждёт несуществующего собеседника.
|
||||
- **Обратная совместимость файла названа в обе стороны** — что делает новый код
|
||||
со старым файлом и старый код с новым, вместе с порядком выкладки и условиями
|
||||
отката.
|
||||
- **Отсутствие умолчания объявлено там, где записана конвенция**, а не только
|
||||
комментарием в коде.
|
||||
- **Отказ разбора файла настроек не несёт содержимого файла.** Поломанная строка
|
||||
секретного ключа даёт отказ с номером строки и именем ключа, но без единой
|
||||
подстроки значения. Несовпадение типов при этом по-прежнему называет ключ и
|
||||
типы.
|
||||
|
||||
## 1. Настройки
|
||||
|
||||
- [x] 1.1 Добавить поле `Enabled bool` с тегом `toml:"enabled"` в
|
||||
`config.TelegramConfig`; умолчания в `defaultConfig()` для него не заводить
|
||||
и объяснить это комментарием
|
||||
- [x] 1.2 Поднять `MetaData` из `toml.DecodeFile` в `LoadConfig` и отказывать в
|
||||
загрузке, когда ключ `telegram.enabled` в файле не задан; сообщение
|
||||
называет ключ. `MetaData` наружу из `LoadConfig` не отдавать
|
||||
- [x] 1.3 Написать `TelegramConfig.Validate()` по образцу `AuthConfig.Validate()`:
|
||||
при `Enabled` и пустом `BotToken` вернуть отказ с именем ключа `bot_token`
|
||||
и без его значения
|
||||
- [x] 1.4 Позвать `cfg.Telegram.Validate()` в `main.go` рядом с проверкой
|
||||
секции `[auth]`; отказ роняет процесс через `logger.Error` и `os.Exit(1)`
|
||||
- [x] 1.5 Проверить `TelegramConfig.Validate()` тестами: включён и ключ есть —
|
||||
ошибки нет; включён и ключ пуст — ошибка называет `bot_token` и не несёт
|
||||
значения; выключен и ключ пуст — ошибки нет
|
||||
- [x] 1.6 Проверить `LoadConfig` тестом на временном файле: секция `[telegram]`
|
||||
без ключа `enabled` даёт отказ с именем ключа; с ключом — загрузка проходит
|
||||
и значение доезжает обоими значениями
|
||||
|
||||
## 1а. Отказ разбора не несёт содержимого файла
|
||||
|
||||
- [x] 1а.1 В `LoadConfig` перестать заворачивать отказ `toml.DecodeFile` через
|
||||
`%w`: разобрать его по семействам и собрать сообщение самому
|
||||
- [x] 1а.2 `toml.ParseError` (через `errors.As`) — взять путь, строку, столбец и
|
||||
последний ключ; поле `Message` в сообщение не брать
|
||||
- [x] 1а.3 Прочие отказы декодера — взять текст как есть: он собран из имён
|
||||
ключей и типов. Причину разреза записать комментарием, иначе следующая
|
||||
правка сведёт две ветки в одну
|
||||
- [x] 1а.4 Проверить тестом на временном файле: строка `bot_token` с оборванной
|
||||
кавычкой даёт отказ, в тексте которого нет ни одной подстроки значения,
|
||||
но есть номер строки и имя ключа
|
||||
- [x] 1а.5 Проверить тестом, что несовпадение типов (строка вместо числа)
|
||||
по-прежнему называет ключ и типы
|
||||
|
||||
## 2. Сборка входа
|
||||
|
||||
- [x] 2.1 Сменить подпись `buildTelegram` на приём `config.TelegramConfig`
|
||||
целиком и поправить вызов в `main.go`
|
||||
- [x] 2.2 Вынести решение в `telegramFromConfig(cfg, newBot, logger)`, где
|
||||
`newBot` — параметр-функция сборки клиента; `buildTelegram` подставляет
|
||||
`telegram.NewBot`. Ветка выключенного входа стоит **до** вызова `newBot`:
|
||||
выставляет признак поднятости в ноль, пишет одну строку уровня `INFO` и
|
||||
возвращает заглушку отправителя
|
||||
- [x] 2.3 Свести в `telegramFromBot` ветку `telegram.ErrEmptyToken` с веткой
|
||||
отказа Bot API: оба исхода — ошибка настройки, старт роняется
|
||||
- [x] 2.4 Обновить комментарий-разрез над `telegramFromBot`: он описывает три
|
||||
исхода по прежнему разрезу
|
||||
|
||||
## 3. Проверки поведения
|
||||
|
||||
- [x] 3.1 Заменить тест `TestTelegramFromBotOnEmptyTokenGivesAbsentSender`
|
||||
проверкой нового исхода: пустой ключ при включённом входе роняет старт,
|
||||
заглушка не подставляется
|
||||
- [x] 3.2 Написать тест на выключенный вход через `telegramFromConfig`: старт не
|
||||
падает, ядро получает заглушку, в журнале ровно одна запись уровня `INFO`
|
||||
- [x] 3.3 Проверить главное утверждение выключенного входа **счётчиком, а не
|
||||
исходом**: `telegramFromConfig` с `Enabled = false` и заполненным
|
||||
(заведомо ненастоящим) ключом зовёт подставную сборку **ноль раз**. Судить
|
||||
по «бот не заведён, отказа нет» нельзя: эти утверждения остаются верными и
|
||||
тогда, когда ветка встала после обращения, а обращение ушло в живой
|
||||
Telegram
|
||||
- [x] 3.4 Оставшиеся тесты `telegramFromBot` (отказ Bot API, недоступность,
|
||||
живой бот) прогнать без правок по существу
|
||||
|
||||
## 4. Настройки и документы
|
||||
|
||||
- [x] 4.1 Добавить `enabled` в секцию `[telegram]` образца `config.dist.toml`
|
||||
со значением `false` и комментарием: зачем поле, что значит каждое
|
||||
значение, что ключ обязателен и умолчания у него нет
|
||||
- [x] 4.2 Переписать комментарий к `bot_token` в образце: он больше не отвечает
|
||||
за включение входа
|
||||
- [x] 4.3 Поправить запрет «Боевым токеном бота не запускаться» в `CLAUDE.md`:
|
||||
локальный прогон идёт с `enabled = false`, а не с пустым токеном
|
||||
- [x] 4.0 Записать в `docs/conventions/config.md`, раздел «Секреты», правило
|
||||
«отказ загрузки настроек не несёт содержимого файла» с причиной: текст
|
||||
отказа собирает чужая библиотека, и разбираемый кусок попадает в него
|
||||
целиком
|
||||
- [x] 4.4 Поправить `docs/conventions/config.md` в **двух** разделах: «Проверка
|
||||
и остановка на старте» описывает прежний разрез по пустоте токена, а
|
||||
«Структура в коде» утверждает, что новое поле требует правки обоих мест,
|
||||
включая `defaultConfig()`. Записать там форму обязательного поля без
|
||||
умолчания, иначе следующий такой ключ получит угаданное намерение обратно
|
||||
- [x] 4.5 Поправить `docs/architecture.md` в **двух** местах: строку перечня
|
||||
capability («незаданный вход Telegram не мешает подъёму» — после изменения
|
||||
ложно и ссылается на снятое имя требования) и строку про Telegram в
|
||||
таблице отказов
|
||||
- [x] 4.6 Поправить `docs/review.md`: рецепт живого прогона там велит поднимать
|
||||
сервис с пустым `telegram.bot_token`, а после изменения так он не встанет
|
||||
- [ ] 4.7 Завести ADR о том, что намерение объявляется признаком, а не выводится
|
||||
из ключа доступа, и проставить парный статус
|
||||
`ADR-2026-08-13-telegram-outage-does-not-block-startup`: он утверждает, что
|
||||
старт роняет ровно один исход сборки клиента, а после изменения их два.
|
||||
Заводить через скилл `av-dev:doc-sync`, на шаге синка документации
|
||||
|
||||
## 5. Гейт и живой прогон
|
||||
|
||||
- [ ] 5.1 `task gate` зелёный — **не выполнено, и причина не в этой работе**:
|
||||
шаги `docs` и `tasks` красные оба по одной причине — проект приведён к
|
||||
раскладке av-dev версии 3, а плагин ждёт версии 4. Проверено на чистом
|
||||
`HEAD` в отдельном рабочем дереве: там те же два шага и то же
|
||||
расхождение. Чинится операцией `upgrade` скилла `av-dev:canon`, и это
|
||||
отдельная работа. Прочие шаги гейта зелёные
|
||||
- [x] 5.2 Живой прогон: подъём с `enabled = false` — сервис встаёт, в журнале
|
||||
одна запись `INFO`, признак поднятости входа Telegram равен нулю
|
||||
- [x] 5.3 Живой прогон: подъём с `enabled = true` и пустым `bot_token` — процесс
|
||||
выходит с ненулевым кодом, сообщение называет ключ
|
||||
- [x] 5.4 Живой прогон: подъём с секцией `[telegram]` без ключа `enabled` —
|
||||
процесс выходит с ненулевым кодом, сообщение называет недостающий ключ
|
||||
@@ -32,12 +32,13 @@ context: |
|
||||
- docs/architecture.md — устройство; docs/security.md — периметр;
|
||||
docs/database.md — схема и настройки с числами; docs/adr/ — почему решено
|
||||
так; docs/research/ — что уже измерено;
|
||||
- tasks/ROADMAP.md — что приложение уже умеет и чего ещё не умеет.
|
||||
- openspec/specs/ — что приложение уже умеет; tasks/BACKLOG.md — что осталось
|
||||
и в каком порядке это берут.
|
||||
Пересказа этих документов здесь нет намеренно: второй дом факта расходится с
|
||||
первым молча, и заметно это становится в предложении, которое уже написано.
|
||||
|
||||
Ревью: правило выбора метки и состав проходов здесь не пересказываем — их дом
|
||||
скилл av-dev-code:review, проектная настройка — docs/review.md.
|
||||
скилл av-dev:code-review, проектная настройка — docs/review.md.
|
||||
|
||||
Конвенции кода: механизированное проверяет гейт, прозой остаётся
|
||||
docs/conventions/. Ни состав шагов гейта, ни перечень конвенций здесь не
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
|
||||
Приём записи и опрос готовности задачи расшифровки: что считается принятой
|
||||
записью, что уезжает в ответ и что происходит, когда запись не удалось
|
||||
прочитать.
|
||||
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
|
||||
|
||||
Описан пока **только приём по HTTP** — тот, что нормируют проверки. Приём из
|
||||
Telegram делит с ним общий шаг заведения задачи, но требований на него нет:
|
||||
требование, написанное без проверки, — предположение, а не норма. Первая задача,
|
||||
которая трогает поведение приёма из Telegram, дописывает его сюда.
|
||||
Приём по существу описан пока **только для HTTP** — того, что нормируют
|
||||
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
|
||||
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
|
||||
требованиями по-прежнему не описано — требование, написанное без проверки, это
|
||||
предположение, а не норма. Первая задача, которая трогает поведение приёма из
|
||||
Telegram, дописывает его сюда.
|
||||
## Requirements
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
@@ -256,3 +258,120 @@ Telegram делит с ним общий шаг заведения задачи,
|
||||
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
||||
|
||||
### Requirement: Поднятые входы видны наблюдателю
|
||||
|
||||
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
|
||||
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
|
||||
вход и неподнятый.
|
||||
|
||||
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
|
||||
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
|
||||
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
|
||||
единственный канал наблюдения, который у владельца автоматизирован.
|
||||
|
||||
#### Scenario: Вход Telegram не поднят
|
||||
|
||||
- **GIVEN** сервис поднялся без Telegram
|
||||
- **WHEN** наблюдатель читает метрики
|
||||
- **THEN** признак поднятости входа Telegram равен нулю
|
||||
- **AND** признак поднятости входа HTTP равен единице
|
||||
|
||||
### Requirement: Признак включения решает, поднимается ли вход Telegram
|
||||
|
||||
Намерение владельца SHALL объявляться отдельным признаком включения входа
|
||||
Telegram, а ключ доступа MUST означать только доступ. При выключенном входе
|
||||
сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе.
|
||||
При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя
|
||||
незаполненного ключа и MUST не нести его значения.
|
||||
|
||||
Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где
|
||||
признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав
|
||||
недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён
|
||||
затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок
|
||||
тихой — либо бот молча пропадает, либо файл без признака молча работает.
|
||||
|
||||
Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO`
|
||||
при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем;
|
||||
предупреждение остаётся за тем, чего владелец не выбирал.
|
||||
|
||||
При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и
|
||||
MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и
|
||||
конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в
|
||||
журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без
|
||||
значения ключа.
|
||||
|
||||
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
|
||||
бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни
|
||||
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
|
||||
опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы
|
||||
неоткуда.
|
||||
|
||||
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
|
||||
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
|
||||
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
|
||||
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
|
||||
|
||||
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
|
||||
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
|
||||
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
|
||||
здоровья.
|
||||
|
||||
Требование нормирует **наличие входа**, а не приём из него.
|
||||
|
||||
#### Scenario: Вход выключен
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram выключен
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и принимает записи по HTTP
|
||||
- **AND** конвейер расшифровки работает
|
||||
- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что
|
||||
вход выключен настройкой
|
||||
|
||||
#### Scenario: Вход выключен, а ключ доступа задан
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram выключен
|
||||
- **AND** ключ доступа при этом заполнен
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается без Telegram, и бот не заводится
|
||||
- **AND** к Telegram не уходит ни одного обращения
|
||||
|
||||
#### Scenario: Вход включён, а ключа доступа нет
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён
|
||||
- **AND** ключ доступа пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** сообщение об отказе называет имя незаполненного ключа
|
||||
|
||||
#### Scenario: Признака включения в настройках нет
|
||||
|
||||
- **GIVEN** в настройках сервиса нет признака включения входа Telegram
|
||||
- **AND** ключ доступа заполнен и Telegram признаёт по нему бота
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом настройки
|
||||
- **AND** сообщение об отказе называет недостающий ключ
|
||||
|
||||
#### Scenario: Вход включён и ключ годен
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён
|
||||
- **AND** стоит ключ, по которому Telegram признаёт бота
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и работает обоими входами
|
||||
|
||||
#### Scenario: Telegram не отвечает
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
|
||||
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и принимает записи по HTTP
|
||||
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
|
||||
- **AND** запись не несёт значения ключа
|
||||
|
||||
#### Scenario: Telegram ответил, что такого бота нет
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
|
||||
- **AND** Telegram отвечает отказом на этот ключ
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** ни журнал, ни текст отказа не несут значения ключа
|
||||
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
## Purpose
|
||||
|
||||
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
|
||||
когда работы нет, и что считается отказом шага.
|
||||
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
|
||||
когда доставить его некуда.
|
||||
|
||||
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
|
||||
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
|
||||
результата держателем захвата и недоставка ответа при неподнятом входе.
|
||||
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
|
||||
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
|
||||
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
|
||||
требования на него не написаны, потому что требование без проверки —
|
||||
предположение, а не норма. Первая задача, которая трогает любое из
|
||||
перечисленного, дописывает его сюда.
|
||||
|
||||
Описан пока **только пустой прогон воркера** — тот, что нормируют проверки
|
||||
пакета `internal/controller/worker` и перевод признака в `internal/service`.
|
||||
Сознательно не описаны переходы состояний и цепочка `created → converted →
|
||||
transcribe → done | failed`, захват задачи и срок его протухания, отмена
|
||||
контекста посреди шага, освобождение ресурсов внешних клиентов. Это не значит,
|
||||
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
|
||||
потому что требование без проверки — предположение, а не норма. Первая задача,
|
||||
которая трогает любое из перечисленного, дописывает его сюда.
|
||||
## Requirements
|
||||
### Requirement: Пустой прогон воркера — не отказ
|
||||
|
||||
@@ -22,9 +25,9 @@ transcribe → done | failed`, захват задачи и срок его пр
|
||||
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
|
||||
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
|
||||
|
||||
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: три воркера
|
||||
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт три
|
||||
записи отказа в секунду и столько же засчитанных сбоев, которых не было.
|
||||
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
|
||||
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
|
||||
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
|
||||
|
||||
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
|
||||
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
|
||||
@@ -260,3 +263,65 @@ MUST расти с числом её попыток до объявленног
|
||||
- **THEN** задержка до следующей проверки каждый раз одна и та же
|
||||
- **AND** число попыток задачи не растёт
|
||||
|
||||
### Requirement: Недоставленный ответ не роняет шаг
|
||||
|
||||
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
|
||||
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
|
||||
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
|
||||
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
|
||||
меткой.
|
||||
|
||||
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят** —
|
||||
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
|
||||
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
|
||||
|
||||
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
|
||||
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
|
||||
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
|
||||
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
|
||||
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
|
||||
записей о ненастроенном боте.
|
||||
|
||||
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
|
||||
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
|
||||
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
|
||||
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
|
||||
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
|
||||
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
|
||||
журнала, а не в состоянии задачи.
|
||||
|
||||
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
|
||||
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
|
||||
запись MUST не попадать — приватность содержимого записи требование не
|
||||
ослабляет.
|
||||
|
||||
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
|
||||
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
|
||||
|
||||
#### Scenario: Вход отправителя не поднят
|
||||
|
||||
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
|
||||
- **AND** сервис поднялся без этого входа
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
|
||||
`failed` не переводится
|
||||
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
|
||||
задачи и причиной
|
||||
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
|
||||
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
|
||||
|
||||
#### Scenario: Адресат у задачи не назван
|
||||
|
||||
- **GIVEN** у задачи источником значится Telegram, а чат не назван
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||
- **AND** задача остаётся в достигнутом состоянии
|
||||
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
|
||||
задачи и причиной: неназванный адресат — симптом порчи записи
|
||||
|
||||
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
|
||||
|
||||
- **GIVEN** задача принята по HTTP
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа и без записи о недоставке
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# toolchain Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Каким инструментом и какой его версии собирается сервис, и что об этом
|
||||
проверяется до выкладки. Заведена задачей `go-1-26-upgrade` 2026-08-12 по
|
||||
дефекту, записанному в `docs/review.md` за то же число: сборочный образ разошёлся
|
||||
с требованием модуля, образ перестал собираться, а восемь шагов гейта и шесть
|
||||
проходов ревью показали зелёное.
|
||||
|
||||
Capability нормирует **не поведение сервиса** для его потребителей, а поведение
|
||||
инструмента разработки; потребитель у неё другой — тот, кто собирает сервис. Это
|
||||
осознанное исключение, и оно названо в преамбуле `docs/architecture.md`.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Версия инструмента сборки объявлена одним числом
|
||||
|
||||
Проект SHALL объявлять версию Go, на которой собирается сервис, одинаково во
|
||||
всех местах, где она названа. Мест ровно четыре, и перечень закрыт: требование
|
||||
модуля в `go.mod`, сборочный образ в `Dockerfile`, строка стека в `CLAUDE.md`,
|
||||
строка стека в `README.md`.
|
||||
|
||||
Сравниваются мажор и минор. Третье число у сборочного образа MUST оставаться
|
||||
свободным, как и база образа: образ обновляется своим темпом, и требовать от
|
||||
него совпадения по патчу значило бы краснеть на каждом его обновлении. Тег
|
||||
читается по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`, и берутся из него
|
||||
первые два числа.
|
||||
|
||||
Правило множественности у мест разное, потому что места устроены по-разному.
|
||||
|
||||
**Документы** — `CLAUDE.md` и `README.md` — MUST называть версию ровно один раз,
|
||||
и считается это **не по файлу, а по разделу стека**: `## Стек` в памятке,
|
||||
`## Технологии` в README. Второе вхождение числа **в этом разделе** MUST
|
||||
считаться отказом: обновят одно, второе протухнет молча. За пределами раздела
|
||||
число не читается вовсе — иначе памятка, которая по устройству ведёт историю
|
||||
закрытых долгов, роняла бы проверку на первой же правдивой строке о прошлой
|
||||
версии, а сообщение толкало бы чинить не проверку, а исторический документ.
|
||||
|
||||
**Сборочный образ** единственности не требует: каждый слой — настоящий вход
|
||||
сборки, и многослойная сборка законна. От всех вхождений `FROM golang:` MUST
|
||||
требоваться совпадение мажора и минора, а не единственность.
|
||||
|
||||
**Требование модуля** называется директивой `go` и по устройству файла
|
||||
единственно.
|
||||
|
||||
Граница раздела MUST быть определена, а не подразумеваться: раздел кончается
|
||||
следующим заголовком того же или более высокого уровня, заголовок третьего уровня
|
||||
и ниже остаётся внутри раздела, а строка, похожая на заголовок, но лежащая внутри
|
||||
блока кода, заголовком MUST не считаться. Без этого пример в чужом разделе
|
||||
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
|
||||
норма его читать не велит.
|
||||
|
||||
`go.mod` MUST не содержать директиву `toolchain`. Она называет версию **пятым**
|
||||
местом, которого перечень не знает: при `toolchain go1.27.0` четыре объявленных
|
||||
числа сойдутся, а собирать будет пятое — то есть вернётся тот самый класс
|
||||
расхождения, ради которого требование и заведено.
|
||||
|
||||
#### Scenario: Все четыре места названы одинаково
|
||||
|
||||
- **GIVEN** дерево проекта, где `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md`
|
||||
называют версию Go
|
||||
- **WHEN** их читают подряд
|
||||
- **THEN** мажор и минор совпадают во всех четырёх
|
||||
|
||||
#### Scenario: Патч сборочного образа отличается законно
|
||||
|
||||
- **GIVEN** `go.mod` требует `1.26.0`, а образ собирается на `golang:1.26.5-alpine`
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** расхождением это не считается
|
||||
|
||||
#### Scenario: База сборочного образа сменилась
|
||||
|
||||
- **GIVEN** образ переехал с `golang:1.26-alpine` на `golang:1.26-bookworm`
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** расхождением это не считается
|
||||
|
||||
#### Scenario: Раздел стека называет версию дважды
|
||||
|
||||
- **GIVEN** раздел стека в `CLAUDE.md` называет версию два раза
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** это расхождение, даже если оба числа одинаковы
|
||||
|
||||
#### Scenario: Число за пределами раздела стека не читается
|
||||
|
||||
- **GIVEN** `CLAUDE.md` вне раздела стека упоминает прошлую версию Go — например
|
||||
записью о закрытом долге
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** расхождением это не считается
|
||||
|
||||
#### Scenario: Сборочный образ собран в два слоя
|
||||
|
||||
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с одним мажором и минором
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** расхождением это не считается
|
||||
|
||||
#### Scenario: Слои сборочного образа разошлись между собой
|
||||
|
||||
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с разными минорами
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** это расхождение
|
||||
|
||||
#### Scenario: Заголовок раздела встретился внутри блока кода
|
||||
|
||||
- **GIVEN** документ в чужом разделе показывает пример, внутри которого есть
|
||||
строка, совпадающая с заголовком раздела стека, а ниже названо другое число
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** число из примера не читается, и расхождением это не считается
|
||||
|
||||
#### Scenario: Раздел стека закрыт заголовком верхнего уровня
|
||||
|
||||
- **GIVEN** после раздела стека идёт заголовок первого уровня, а ниже названа
|
||||
прошлая версия
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** это число не читается, и расхождением не считается
|
||||
|
||||
#### Scenario: Раздела стека нет вовсе
|
||||
|
||||
- **GIVEN** в документе нет раздела, где называется версия
|
||||
- **WHEN** запускают шаг сверки
|
||||
- **THEN** он завершается отказом и называет недостающий раздел
|
||||
|
||||
#### Scenario: Модуль объявляет версию пятым местом
|
||||
|
||||
- **GIVEN** `go.mod` содержит директиву `toolchain`
|
||||
- **WHEN** версии сравнивают
|
||||
- **THEN** это расхождение
|
||||
|
||||
### Requirement: Объявленное число — то, на котором проект собирается
|
||||
|
||||
Объявленная версия SHALL быть той, на которой сервис действительно собирается и
|
||||
проходит тесты. Согласованность четырёх строк между собой этого не доказывает:
|
||||
четыре одинаковых числа несуществующей версии требованию о согласованности
|
||||
удовлетворяют, а собрать на них нельзя.
|
||||
|
||||
Проверка эта MUST оставаться за человеком и MUST не входить в набор проверок:
|
||||
она требует сборки образа, а сборка образа набором проверок не делается
|
||||
намеренно — дорого. Подъём версии MUST не уезжать в основную ветку, пока сборка
|
||||
образа и тесты на объявленном числе не прогнаны.
|
||||
|
||||
#### Scenario: Версию подняли
|
||||
|
||||
- **GIVEN** объявленную версию Go подняли во всех четырёх местах
|
||||
- **WHEN** изменение готовят к мерджу
|
||||
- **THEN** до мерджа на этой версии прогнаны сборка образа и тесты
|
||||
|
||||
### Requirement: Расхождение версий роняет набор проверок
|
||||
|
||||
Набор проверок `task gate` SHALL включать шаг, который сравнивает объявленные
|
||||
версии между собой и MUST завершаться отказом, когда они разошлись. Сообщение
|
||||
отказа MUST называть **все четыре места и прочитанное в каждом число** — не одну
|
||||
разошедшуюся пару: в дефекте 2026-08-12 три места из четырёх говорили одно и то
|
||||
же и неверными были именно они, а по сообщению о паре человек чинит не то место.
|
||||
|
||||
Шаг MUST судить по содержимому файлов репозитория и MUST не спрашивать
|
||||
установленный инструмент — ни `go version`, ни `go env`, ни `GOTOOLCHAIN`. Исход
|
||||
его MUST быть функцией коммита, а не машины: шаг, чей ответ зависит от того, что
|
||||
стоит на хосте, воспроизводит ровно ту подмену, которая держала дефект
|
||||
2026-08-12 невидимым — там `go build ./...` шёл на хостовом Go, а объявленное
|
||||
число не проверял никто.
|
||||
|
||||
Шаг MUST работать сравнением строк — без сборки образа, без docker и без сети —
|
||||
и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только
|
||||
читать: файлов он не правит и разошедшихся мест не чинит.
|
||||
|
||||
Коды выхода MUST следовать общему словарю проверочных шагов проекта; словарь
|
||||
объявляет раздел «Гейт» в `CLAUDE.md`, и здесь он не повторяется. Своего словаря шаг
|
||||
MUST не заводить: четвёртый шаг с собственной семантикой сделал бы это
|
||||
утверждение неверным.
|
||||
|
||||
Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места.
|
||||
«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы
|
||||
как совпадение.
|
||||
|
||||
Отказ чтения места MUST не выглядеть как отсутствие числа. Место, которое
|
||||
существует, но не читается, — это отказ окружения, и сообщение MUST говорить о
|
||||
нечитаемости, а не о ненайденной версии: иначе шаг отправляет чинить документ, в
|
||||
котором строка на месте, а сломаны права.
|
||||
|
||||
#### Scenario: Разошёлся сборочный образ
|
||||
|
||||
- **GIVEN** `Dockerfile` называет версию, отличную от прочих трёх мест
|
||||
- **WHEN** запускают `task gate`
|
||||
- **THEN** шаг сверки завершается отказом
|
||||
- **AND** сообщение называет все четыре места и число каждого
|
||||
- **AND** весь набор проверок краснеет
|
||||
|
||||
#### Scenario: Разошлось требование модуля
|
||||
|
||||
- **GIVEN** `go.mod` называет версию, отличную от прочих трёх мест
|
||||
- **WHEN** запускают шаг сверки
|
||||
- **THEN** он завершается отказом и называет `go.mod` среди разошедшихся
|
||||
|
||||
#### Scenario: Разошлась памятка
|
||||
|
||||
- **GIVEN** `CLAUDE.md` называет версию, отличную от прочих трёх мест
|
||||
- **WHEN** запускают шаг сверки
|
||||
- **THEN** он завершается отказом и называет `CLAUDE.md` среди разошедшихся
|
||||
|
||||
#### Scenario: Разошёлся README
|
||||
|
||||
- **GIVEN** `README.md` называет версию, отличную от прочих трёх мест
|
||||
- **WHEN** запускают шаг сверки
|
||||
- **THEN** он завершается отказом и называет `README.md` среди разошедшихся
|
||||
|
||||
#### Scenario: Версии совпадают
|
||||
|
||||
- **GIVEN** все четыре места называют одно число
|
||||
- **WHEN** запускают `task gate`
|
||||
- **THEN** шаг сверки проходит с кодом 0
|
||||
- **AND** остальные шаги набора идут как прежде
|
||||
|
||||
#### Scenario: Инструмента сборки нет на машине
|
||||
|
||||
- **GIVEN** в `PATH` нет `go` вовсе
|
||||
- **WHEN** запускают шаг сверки
|
||||
- **THEN** исход и сообщение те же, что и при установленном `go`
|
||||
|
||||
#### Scenario: Ни docker, ни сети нет
|
||||
|
||||
- **GIVEN** docker недоступен и сети нет
|
||||
- **WHEN** запускают шаг сверки
|
||||
- **THEN** он отрабатывает и даёт тот же исход, что и при доступном docker
|
||||
|
||||
#### Scenario: Шаг запущен не из корня проекта
|
||||
|
||||
- **GIVEN** шаг запускают из подкаталога дерева
|
||||
- **WHEN** он ищет свои четыре места
|
||||
- **THEN** исход тот же, что и при запуске из корня
|
||||
|
||||
#### Scenario: Место существует, но не читается
|
||||
|
||||
- **GIVEN** файл одного из мест на диске есть, но прав на чтение нет
|
||||
- **WHEN** запускают шаг сверки
|
||||
- **THEN** он завершается кодом окружения и говорит о нечитаемости места
|
||||
- **AND** сообщения «версия не названа» не печатает
|
||||
|
||||
#### Scenario: Версия не названа там, где должна быть
|
||||
|
||||
- **GIVEN** одно из четырёх мест перестало называть версию Go
|
||||
- **WHEN** запускают шаг сверки
|
||||
- **THEN** он завершается отказом и называет место, где число не нашлось
|
||||
@@ -1,358 +0,0 @@
|
||||
// Package scripts — проверки скриптов репозитория. Рабочего кода на Go в нём
|
||||
// нет: пакет существует ради того, чтобы `go test ./...` гонял и shell.
|
||||
//
|
||||
// Норма шага сверки версий — openspec/specs/toolchain/spec.md. Каждый её
|
||||
// сценарий проверяется здесь мутацией: дерево-образец собирается во временном
|
||||
// каталоге, портится ровно одним способом, и от скрипта требуется объявленный
|
||||
// исход. Прежде сценарии подтверждались разовыми ручными прогонами — после
|
||||
// первой правки образца они перестали бы выполняться молча.
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Дерево-образец: все четыре места называют одну версию.
|
||||
//
|
||||
// `CLAUDE.md` держит второе число **за** разделом стека намеренно: так выглядит
|
||||
// правдивая строка о закрытом долге, и норма велит её не читать.
|
||||
var fixture = map[string]string{
|
||||
"go.mod": "module example\n\ngo 1.26.0\n",
|
||||
"Dockerfile": "FROM docker.io/library/golang:1.26-alpine AS builder\n" +
|
||||
"RUN true\n\n" +
|
||||
"FROM docker.io/library/alpine:3.22\n",
|
||||
"CLAUDE.md": "# CLAUDE.md\n\n" +
|
||||
"## Стек\n\nGo 1.26, встроенная PocketBase.\n\n" +
|
||||
"## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
|
||||
"README.md": "# transcriber\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n",
|
||||
}
|
||||
|
||||
// allPlaces — все четыре места и число каждого: этого требует норма от
|
||||
// сообщения о расхождении. Числа два, потому что разошедшееся место называет
|
||||
// своё.
|
||||
var allPlaces = []string{"go.mod", "Dockerfile", "CLAUDE.md", "README.md", "1.26", "1.25"}
|
||||
|
||||
const (
|
||||
exitOK = 0
|
||||
exitDrift = 1
|
||||
exitUsage = 2
|
||||
exitEnviron = 3
|
||||
)
|
||||
|
||||
func TestСверкаВерсийПоСценариямНормы(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
// mutate портит дерево-образец; nil — дерево не портится.
|
||||
mutate func(t *testing.T, root string)
|
||||
// args — аргументы скрипта.
|
||||
args []string
|
||||
// dir — рабочий каталог прогона относительно корня дерева.
|
||||
dir string
|
||||
want int
|
||||
// says — что обязано прозвучать в сообщении.
|
||||
says string
|
||||
// saysAll — что обязано прозвучать всё разом. Норма требует от сообщения
|
||||
// о расхождении **все четыре места и число каждого**: в дефекте
|
||||
// 2026-08-12 три места из четырёх говорили одно и то же, и неверными
|
||||
// были именно они — по сообщению о паре человек чинит не то место.
|
||||
saysAll []string
|
||||
// saysNot — чего в сообщении быть не должно.
|
||||
saysNot string
|
||||
}{
|
||||
{name: "все четыре места названы одинаково", want: exitOK},
|
||||
{
|
||||
name: "патч сборочного образа отличается законно",
|
||||
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26.5-alpine"),
|
||||
want: exitOK,
|
||||
},
|
||||
{
|
||||
name: "база сборочного образа сменилась",
|
||||
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26-bookworm"),
|
||||
want: exitOK,
|
||||
},
|
||||
{
|
||||
name: "сборочный образ собран в два слоя",
|
||||
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.26-alpine AS tools"),
|
||||
want: exitOK,
|
||||
},
|
||||
{
|
||||
name: "слои сборочного образа разошлись между собой",
|
||||
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.25-alpine AS tools"),
|
||||
want: exitDrift,
|
||||
says: "Dockerfile",
|
||||
},
|
||||
{
|
||||
name: "разошёлся сборочный образ",
|
||||
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.25-alpine"),
|
||||
want: exitDrift,
|
||||
says: "разошлись",
|
||||
saysAll: allPlaces,
|
||||
},
|
||||
{
|
||||
name: "разошлось требование модуля",
|
||||
mutate: replace("go.mod", "go 1.26.0", "go 1.25.0"),
|
||||
want: exitDrift,
|
||||
says: "разошлись",
|
||||
saysAll: allPlaces,
|
||||
},
|
||||
{
|
||||
name: "разошлась памятка",
|
||||
mutate: replace("CLAUDE.md", "Go 1.26, встроенная", "Go 1.25, встроенная"),
|
||||
want: exitDrift,
|
||||
says: "разошлись",
|
||||
saysAll: allPlaces,
|
||||
},
|
||||
{
|
||||
name: "разошёлся README",
|
||||
mutate: replace("README.md", "Go 1.26 и ffmpeg", "Go 1.25 и ffmpeg"),
|
||||
want: exitDrift,
|
||||
says: "разошлись",
|
||||
saysAll: allPlaces,
|
||||
},
|
||||
{
|
||||
name: "раздел стека называет версию дважды",
|
||||
mutate: replace("CLAUDE.md", "встроенная PocketBase.", "встроенная PocketBase, всё та же Go 1.26."),
|
||||
want: exitDrift,
|
||||
says: "больше одного раза",
|
||||
},
|
||||
{
|
||||
name: "число за пределами раздела стека не читается",
|
||||
mutate: replace("CLAUDE.md", "Go 1.24 — долг закрыт.", "Go 1.24 и Go 1.23 — долги закрыты."),
|
||||
want: exitOK,
|
||||
},
|
||||
{
|
||||
name: "заголовок раздела встретился внутри блока кода",
|
||||
mutate: replace("README.md", "## Технологии\n\nGo 1.26 и ffmpeg.\n",
|
||||
"## Пример\n\n```md\n## Технологии\n\nGo 1.19 из примера.\n```\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n"),
|
||||
want: exitOK,
|
||||
},
|
||||
{
|
||||
name: "раздел стека закрыт заголовком верхнего уровня",
|
||||
mutate: replace("CLAUDE.md", "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
|
||||
"# Приложение\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n"),
|
||||
want: exitOK,
|
||||
},
|
||||
{
|
||||
name: "раздела стека нет вовсе",
|
||||
mutate: replace("CLAUDE.md", "## Стек", "## Инструменты"),
|
||||
want: exitDrift,
|
||||
says: "нет раздела",
|
||||
},
|
||||
{
|
||||
name: "версия не названа там, где должна быть",
|
||||
mutate: replace("README.md", "Go 1.26 и ffmpeg.", "ffmpeg и всё остальное."),
|
||||
want: exitDrift,
|
||||
says: "не называет версию",
|
||||
},
|
||||
{
|
||||
name: "модуль объявляет версию пятым местом",
|
||||
mutate: replace("go.mod", "go 1.26.0", "go 1.26.0\n\ntoolchain go1.27.0"),
|
||||
want: exitDrift,
|
||||
says: "toolchain",
|
||||
},
|
||||
{
|
||||
name: "места нет вовсе",
|
||||
mutate: remove("README.md"),
|
||||
want: exitEnviron,
|
||||
says: "нет файла",
|
||||
},
|
||||
{
|
||||
name: "место существует, но не читается",
|
||||
mutate: unreadable("README.md"),
|
||||
want: exitEnviron,
|
||||
says: "нечитаем",
|
||||
saysNot: "не называет версию",
|
||||
},
|
||||
{
|
||||
name: "шаг запущен не из корня проекта",
|
||||
dir: "scripts",
|
||||
want: exitOK,
|
||||
},
|
||||
{
|
||||
name: "шагу переданы аргументы",
|
||||
args: []string{"--base", "origin/master"},
|
||||
want: exitUsage,
|
||||
says: "Использование",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
root := treeWithScript(t)
|
||||
if c.mutate != nil {
|
||||
c.mutate(t, root)
|
||||
}
|
||||
code, out := runScript(t, root, c.dir, c.args)
|
||||
if code != c.want {
|
||||
t.Errorf("код возврата %d, ожидался %d\nвывод:\n%s", code, c.want, out)
|
||||
}
|
||||
if c.says != "" && !strings.Contains(out, c.says) {
|
||||
t.Errorf("в сообщении нет %q\nвывод:\n%s", c.says, out)
|
||||
}
|
||||
for _, want := range c.saysAll {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("сообщение не называет %q\nвывод:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if c.saysNot != "" && strings.Contains(out, c.saysNot) {
|
||||
t.Errorf("в сообщении есть лишнее %q\nвывод:\n%s", c.saysNot, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Норма требует, чтобы исход был функцией коммита, а не машины: скрипт не
|
||||
// спрашивает установленный инструмент. Проверяется это прогоном без `go` в
|
||||
// `PATH` — исход обязан не измениться.
|
||||
func TestИсходНеЗависитОтУстановленногоGo(t *testing.T) {
|
||||
root := treeWithScript(t)
|
||||
|
||||
withGo, outWith := runScript(t, root, "", nil)
|
||||
if withGo != exitOK {
|
||||
t.Fatalf("дерево-образец обязано сходиться, а код %d:\n%s", withGo, outWith)
|
||||
}
|
||||
|
||||
goBin, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
t.Skip("go не найден в PATH — проверять нечего")
|
||||
}
|
||||
var kept []string
|
||||
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||
if dir != filepath.Dir(goBin) {
|
||||
kept = append(kept, dir)
|
||||
}
|
||||
}
|
||||
withoutGo, outWithout := runScript(t, root, "", nil, "PATH="+strings.Join(kept, string(os.PathListSeparator)))
|
||||
if withoutGo != withGo {
|
||||
t.Errorf("без go в PATH код %d, с ним %d\nвывод:\n%s", withoutGo, withGo, outWithout)
|
||||
}
|
||||
}
|
||||
|
||||
// Скрипт не зовёт ни `go`, ни `docker`, ни сеть — это читается из его текста, и
|
||||
// правило держит именно текст: прогон без сети в наборе проверок недоступен.
|
||||
func TestСкриптНеЗоветНиGoНиDocker(t *testing.T) {
|
||||
body, err := os.ReadFile("check-go-version.sh")
|
||||
if err != nil {
|
||||
t.Fatalf("читаю скрипт: %v", err)
|
||||
}
|
||||
// Границы слова обязательны: имя `read_dockerfile` и переменная `dockerfile`
|
||||
// законны — читается файл, а не зовётся демон.
|
||||
code := withoutComments(string(body))
|
||||
for _, forbidden := range []string{`go\s+version`, `go\s+env`, `GOTOOLCHAIN`, `\bdocker\b`, `\bcurl\b`, `\bwget\b`} {
|
||||
if regexp.MustCompile(forbidden).FindString(code) != "" {
|
||||
t.Errorf("скрипт зовёт %s вне комментария: исход перестаёт быть функцией коммита", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Помощники --------------------------------------------------------------
|
||||
|
||||
// treeWithScript собирает дерево-образец и кладёт в него сам скрипт: корень он
|
||||
// считает от своего расположения, поэтому проверяется копия внутри дерева.
|
||||
func treeWithScript(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, body := range fixture {
|
||||
write(t, filepath.Join(root, name), body, 0o644)
|
||||
}
|
||||
script, err := os.ReadFile("check-go-version.sh")
|
||||
if err != nil {
|
||||
t.Fatalf("читаю скрипт: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "scripts"), 0o755); err != nil {
|
||||
t.Fatalf("завожу каталог scripts: %v", err)
|
||||
}
|
||||
write(t, filepath.Join(root, "scripts", "check-go-version.sh"), string(script), 0o755)
|
||||
return root
|
||||
}
|
||||
|
||||
// runScript гоняет скрипт и отдаёт код возврата с объединённым выводом.
|
||||
// `env` — добавка к окружению прогона, `args` — аргументы скрипта.
|
||||
func runScript(t *testing.T, root, dir string, args []string, env ...string) (int, string) {
|
||||
t.Helper()
|
||||
// Контекст проверки: зависший скрипт умирает вместе с ней, а не переживает
|
||||
// прогон осиротевшим процессом.
|
||||
cmd := exec.CommandContext(t.Context(), "sh", append([]string{filepath.Join(root, "scripts", "check-go-version.sh")}, args...)...)
|
||||
cmd.Dir = filepath.Join(root, dir)
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
code := 0
|
||||
if err != nil {
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("прогон скрипта: %v", err)
|
||||
}
|
||||
code = exit.ExitCode()
|
||||
}
|
||||
return code, string(out)
|
||||
}
|
||||
|
||||
func replace(file, old, new string) func(*testing.T, string) {
|
||||
return func(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, file)
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("читаю %s: %v", file, err)
|
||||
}
|
||||
if !strings.Contains(string(body), old) {
|
||||
t.Fatalf("в образце %s нет %q: мутация потеряла предмет", file, old)
|
||||
}
|
||||
write(t, path, strings.Replace(string(body), old, new, 1), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(file string) func(*testing.T, string) {
|
||||
return func(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if err := os.Remove(filepath.Join(root, file)); err != nil {
|
||||
t.Fatalf("убираю %s: %v", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unreadable(file string) func(*testing.T, string) {
|
||||
return func(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, file)
|
||||
if err := os.Chmod(path, 0o000); err != nil {
|
||||
t.Fatalf("снимаю права с %s: %v", file, err)
|
||||
}
|
||||
// Права возвращаются, иначе уборка временного каталога отказала бы.
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Errorf("возвращаю права %s: %v", file, err)
|
||||
}
|
||||
})
|
||||
if body, err := os.ReadFile(path); err == nil {
|
||||
t.Skipf("файл читается и без прав (%d байт) — прогон под root?", len(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, body string, perm os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), perm); err != nil {
|
||||
t.Fatalf("пишу %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// withoutComments снимает строки-комментарии: слово в объяснении вызовом не
|
||||
// является, а объяснения в этом скрипте длиннее самого кода.
|
||||
func withoutComments(body string) string {
|
||||
var kept []string
|
||||
for line := range strings.SplitSeq(body, "\n") {
|
||||
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"tasks": 1
|
||||
}
|
||||
+25
-28
@@ -1,23 +1,27 @@
|
||||
# Беклог
|
||||
|
||||
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
|
||||
+ строка здесь. Целей тут нет — они в [ROADMAP.md](ROADMAP.md): беклог — то, что берут,
|
||||
роадмап — то, подо что берут. **Порядок строк значим:**
|
||||
это очередь, и первая строка — то, что делают следующим. Порядок
|
||||
назначает человек на груминге, машина его не выводит. Одно исключение
|
||||
производно от типа — сырьё (`research` без раздела «Вопрос»)
|
||||
стоит в конце: его не берут. Ведётся скиллом `tasks`.
|
||||
+ строка здесь. Ведётся скиллом `av-dev:task-track`.
|
||||
|
||||
Секция одна — полок домена у проекта нет, и делить очередь на две
|
||||
значило бы держать два порядка вместо одного.
|
||||
<!-- стадия -->
|
||||
Стадия проекта — **стройка** (`[tasks] stage = "build"`).
|
||||
**Порядок строк — зависимость:** это план стройки от базы к деталям,
|
||||
и строка выше сделана раньше не потому, что важнее, а потому, что
|
||||
иначе нельзя. Секция здесь **одна**: разложенный по полкам план
|
||||
перестаёт быть планом. Список пишется вперёд целиком — это не
|
||||
гниение беклога, а замысел. Пустой беклог значит, что стройка
|
||||
окончена: дальше `tasks.py stage support`.
|
||||
<!-- /стадия -->
|
||||
|
||||
**Чем очередь упорядочена на этом этапе — от базы к деталям.**
|
||||
Сначала то, на чём стоит остальное: проверки, которым можно верить,
|
||||
владелец записи, единый контракт API, покрытый тестами конвейер, — и
|
||||
только потом экраны и возможности поверх них. Порядок расставлен на
|
||||
груминге 2026-08-12 и держится, пока сервис не собран целиком:
|
||||
задача, взятая раньше своего основания, стоит дважды — сперва её
|
||||
пишут, потом переписывают под появившееся основание.
|
||||
**Чем основание отличается от детали в этом проекте.** Сначала идёт то,
|
||||
на чём стоит остальное: проверки, которым можно верить, владелец записи,
|
||||
единый контракт API, покрытый тестами конвейер, — и только потом экраны
|
||||
и возможности поверх них. Этот порядок расставили 2026-08-12.
|
||||
Задача, взятая раньше своего основания, стоит дважды: сперва её пишут,
|
||||
потом переписывают под появившееся основание.
|
||||
|
||||
Одно место в очереди назначено не человеком, а типом: сырьё
|
||||
(`research` без раздела «Вопрос») стоит в конце секции — его не берут.
|
||||
|
||||
Отсюда правило для **новых** записей. Заведённая по ходу работы —
|
||||
интейком, урожаем ревью, разбором находок — задача встаёт в конец
|
||||
@@ -39,16 +43,15 @@
|
||||
|
||||
## Очередь
|
||||
|
||||
- [🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
|
||||
- [🧹 Поднимать сервис локально без действующего токена бота](items/local-run-without-telegram-token.md) — Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
|
||||
- [🐞 Убрать код провайдера из журнала запросов хранилища](items/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
|
||||
- [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
|
||||
- [🧹 Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
|
||||
- [✨ Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
|
||||
- [🔬 Четыре недоказанные гипотезы о поверхности входа](items/login-surface-hypotheses.md) — Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было.
|
||||
- [🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы](items/rollback-does-not-undo-schema-step.md) — Откат бинаря оставляет применённый шаг схемы в силе, и на этом строятся решения о выкладке: сегодня об этом не сказано нигде.
|
||||
- [🐞 Починить срок сессии, который ставит откат шага входа](items/rollback-restores-wrong-session-duration.md) — Константа defaultAuthTokenDuration в шаге 202608120001 названа умолчанием библиотеки, но 1209600 — это 14 суток, а умолчание PocketBase 432000, пять суток: откат объявляет возврат к умолчанию и ставит срок вдвое больше выбранных владельцем семи.
|
||||
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
|
||||
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
|
||||
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
|
||||
- [🧹 Запретить обращаться к 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, и по ним читатель строит решения о выкладке и о периметре.
|
||||
- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
|
||||
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
||||
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
||||
@@ -59,6 +62,7 @@
|
||||
- [🧹 Прервать шаг конвейера отменой контекста](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/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
|
||||
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
|
||||
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
|
||||
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
|
||||
@@ -76,7 +80,7 @@
|
||||
- [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит.
|
||||
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
||||
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
||||
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
|
||||
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а работа над долгими записями без них не начинается.
|
||||
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
||||
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
|
||||
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
|
||||
@@ -91,11 +95,4 @@
|
||||
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
|
||||
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
|
||||
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
||||
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
|
||||
- [🧹 Настроить конвейер ревью по итогам прогона go-1-26-upgrade](items/review-config-from-go-upgrade.md) — Прогон вскрыл две прорехи настройки: «Типовые узлы» знают только рантайм и не знают рода «проверочный шаг набора проверок», а «Триггеры метки» не видят оси «изменение трогает канон» — и именно она дала обе блокирующие находки.
|
||||
- [🐞 Починить срок сессии, который ставит откат шага входа](items/rollback-restores-wrong-session-duration.md) — Константа defaultAuthTokenDuration в шаге 202608120001 названа умолчанием библиотеки, но 1209600 — это 14 суток, а умолчание PocketBase 432000, пять суток: откат объявляет возврат к умолчанию и ставит срок вдвое больше выбранных владельцем семи.
|
||||
- [🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go](items/migrations-step-norm-and-tests.md) — Шаг охраняет critical-инвариант «применённый шаг схемы не переписывается», но своих проверок не имеет: дрейф шаблона имени, переезд каталога или потеря grep в конвейере оставят его вечно зелёным, и это не заметит ничто.
|
||||
- [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
|
||||
- [🔬 Шаги гейта, у которых правило может потерять предмет](items/gate-steps-subject-guard.md) — У шага migrations страж предмета есть, у шагов docs, tasks и openspec неизвестно: они зовут чужие скрипты из плагинов, и правило, потерявшее файлы, зеленело бы молча.
|
||||
- [🧹 Свести шесть расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; четыре из них в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
||||
- [🔬 Квота по общему размеру загруженного на пользователя](items/per-user-size-quota.md) — Паспорт и security.md запрещают отказы по квоте пользователю, а заметка владельца просит квоту по умолчанию 5 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас.
|
||||
|
||||
@@ -6,3 +6,19 @@
|
||||
|
||||
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
|
||||
- 2026-08-12 `gate-go-version-sync` — 🧹 Сверять версию Go в образе с директивой go.mod. Причина: слита в go-1-26-upgrade 2026-08-12: сверка версии и само обновление правят одни и те же строки go.mod и Dockerfile, и порознь заводят расхождение заново. Была секция: Очередь.
|
||||
- 2026-08-13 `any-audio-source` — 🎯 Принимается запись любого формата, включая дорожку из видео. Причина: Зонтик над разобранной работой: перечень форматов меряет audio-format-coverage-measure, дорожку из видео берёт video-audio-track-intake. Тип goal упразднён раскладкой av-dev 3. Была секция: Направления.
|
||||
- 2026-08-13 `data-ownership` — 🎯 Человек убирает свою запись из архива вместе со всеми текстами. Причина: Зонтик над разобранной работой: удаление записи со всеми уровнями текста делает delete-record. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
|
||||
- 2026-08-13 `long-recordings` — 🎯 Запись длиной до шести часов доходит до текста. Причина: Зонтик над разобранной работой: потолки меряют speechkit-limits и intake-limits-measure, дальше идут reject-oversized-recording, long-audio-chunking и long-text-delivery. Тип goal упразднён раскладкой av-dev 3. Была секция: Направления.
|
||||
- 2026-08-13 `multi-user` — 🎯 Сервисом пользуются несколько человек, и записи одного не видны другому. Причина: Зонтик над разобранной работой: вход сделан задачей oidc-login, дальше идут record-ownership, telegram-account-link и api-tokens. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
|
||||
- 2026-08-13 `ready-notification` — 🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым. Причина: Зонтик над разобранной работой: доставку делают ntfy-delivery и email-notification. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
|
||||
- 2026-08-13 `service-observability` — 🎯 Состояние сервиса видно без чтения логов. Причина: Зонтик над разобранной работой: словарь метрик выбирает opentelemetry-fit, дальше идут stalled-pipeline-metric, external-service-metrics, job-path-by-request и owner-alerting. Тип goal упразднён раскладкой av-dev 3. Была секция: Сопровождение.
|
||||
- 2026-08-13 `text-insights` — 🎯 Приложение показывает, о чём запись, не читая её целиком. Причина: Зонтик над разобранной работой: уровни текста считает llm-insights-adapter, вычитку даёт literary-text-level, показывает их insights-visible-in-list. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
|
||||
- 2026-08-13 `upload-reliability` — 🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую. Причина: Зонтик над разобранной работой: дедупликацию делает dedup-by-content-hash, пачку файлов multi-file-upload, ход загрузки upload-progress, уборку за обрывом orphan-file-on-failed-intake. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
|
||||
- 2026-08-13 `usage-stats` — 🎯 Владелец видит, кто сколько загрузил и во что это обошлось. Причина: Зонтик над разобранной работой: учёт ведёт usage-accounting, показывает его admin-stats-screen. Тип goal упразднён раскладкой av-dev 3. Была секция: Сопровождение.
|
||||
- 2026-08-13 `user-settings` — 🎯 Пользователь настраивает, что сервис делает с его записями. Причина: Зонтик над разобранной работой: дом настроек заводит settings-screen, читает их в конвейере settings-applied-in-pipeline. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
|
||||
- 2026-08-13 `web-access` — 🎯 Записи загружаются и читаются в приложении, которое ставится на телефон. Причина: Зонтик над разобранной работой: каркас даёт spa-skeleton, контракт json-api-for-spa, экраны upload-and-status-screen, records-list-screen, play-recording-in-app, установку на телефон installable-pwa. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
|
||||
- 2026-08-13 `gate-changed-lines-coverage` — 🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест. Причина: Владелец отменил 2026-08-13: механизировать покрытие изменённых функций не нужно. Прототип шага гейта откачен, в дерево ничего не уехало. Была секция: Очередь.
|
||||
- 2026-08-13 `migrations-step-norm-and-tests` — 🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go. Причина: Владелец отменил 2026-08-13: проверка над проверкой даёт много механики и мало пользы. Сам шаг migrations остаётся и работает — без проверок остаётся только он. Была секция: Очередь.
|
||||
- 2026-08-13 `gate-steps-subject-guard` — 🔬 Шаги гейта, у которых правило может потерять предмет. Причина: Владелец отменил 2026-08-13: разведка того же класса — проверка над проверками. Ведут ли себя шаги docs, tasks и openspec зелёными без предмета, остаётся неизвестным. Была секция: Очередь.
|
||||
- 2026-08-13 `review-config-from-go-upgrade` — 🧹 Настроить конвейер ревью по итогам прогона go-1-26-upgrade. Причина: Владелец отменил 2026-08-13: настройка конвейера ревью даёт много механики и мало пользы. Разделы «Типовые узлы» и «Триггеры метки» в docs/review.md остаются как есть. Была секция: Очередь.
|
||||
- 2026-08-13 `rollback-does-not-undo-schema-step` — 🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы. Причина: Владелец отменил 2026-08-13: задача целиком документационная — одна строка в «Необратимое» о том, что откат бинаря не откатывает шаг схемы. Факт остаётся неназванным нигде. Была секция: Очередь.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Роадмап
|
||||
|
||||
Состояние проекта: что приложение **уже умеет** и чего ещё не умеет.
|
||||
Цель — возможность приложения: файл типа `goal` (🎯) в
|
||||
`items/`. Её задачи здесь **не перечисляются** — перечень даёт
|
||||
`tasks.py list --goal <слаг>`.
|
||||
|
||||
- **Запланировано** — очередь значима и обосновывается прозой;
|
||||
- **Направления** — очереди нет, тянутся долго;
|
||||
- **Сопровождение** — чем держат проект: инструмент,
|
||||
процесс, эксплуатация. Не возможности приложения, и отдельно —
|
||||
чтобы не читаться как обещание продукта;
|
||||
- **Готово** — достигнутое: строку пишет
|
||||
`tasks.py close <цель> --implemented`, ссылки на файл в ней нет —
|
||||
файл удаляется, поведение живёт в спеках. Стоит последней: копится.
|
||||
|
||||
Секции **канонические** и переименованию проектом не подлежат:
|
||||
у каждой свой смысл, и в достигнутое пишет сам `close`. Порядок
|
||||
тоже канонический. Английский
|
||||
вариант — Planned | Directions | Operations | Done, один язык на весь
|
||||
индекс.
|
||||
|
||||
## Запланировано
|
||||
|
||||
- [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
|
||||
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
|
||||
- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
|
||||
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
|
||||
- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
|
||||
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
|
||||
- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
|
||||
|
||||
## Направления
|
||||
|
||||
- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
|
||||
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
||||
|
||||
## Сопровождение
|
||||
|
||||
- [🎯 Состояние сервиса видно без чтения логов](items/service-observability.md) — Отказ замечает пользователь, а не владелец: оповещения нет, а путь записи по конвейеру собирается глазами по логам контейнера.
|
||||
- [🎯 Владелец видит, кто сколько загрузил и во что это обошлось](items/usage-stats.md) — Распознавание и языковая модель оплачиваются по факту, а счёт приходит одной суммой: кто её набрал, из сервиса не выясняется.
|
||||
|
||||
## Готово
|
||||
|
||||
- 2025-08-14 `telegram-transcription` — Голосовое сообщение из Telegram возвращается текстом. Первый вход сервиса: бот принимает голосовое, аудиофайл и документ с аудио и отвечает расшифровкой.
|
||||
- 2025-08-08 `api-transcription` — Запись, отданная по HTTP, возвращается текстом. Программный вход: файл отдаётся формой, готовность и текст забираются опросом статуса задачи.
|
||||
@@ -3,10 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Страница показывает собранный учёт: без учёта показывать нечего.
|
||||
- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
||||
- **Теги:** goal:usage-stats
|
||||
|
||||
Двигает пункты 1, 2 и 3 «Завершения» цели: расход по каждому пользователю виден
|
||||
на странице, и открывается она только владельцу сервиса.
|
||||
Расход по каждому пользователю виден на странице, и открывается она только
|
||||
владельцу сервиса.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 🎯 Принимается запись любого формата, включая дорожку из видео
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Направления — Перечень форматов не замерен, и потолок длины у видео тот же, что у долгих записей: тянется следом за ними.
|
||||
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
||||
- **Теги:** decomposed
|
||||
|
||||
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
|
||||
контейнера или видео, из которого нужна только речь. Подготовка на стороне
|
||||
пользователя не требуется.
|
||||
|
||||
## Завершение
|
||||
|
||||
1. Перечень принимаемых форматов замерен и записан в `research/`, а не выведен
|
||||
из документации ffmpeg.
|
||||
2. Видеофайл принимается, и из него берётся звуковая дорожка.
|
||||
3. Формат, который принять нельзя, отклоняется на приёме — с текстом, из
|
||||
которого понятно почему, а не отказом на конвертации через минуту.
|
||||
4. Расхождение ogg/vorbis против заявленного SpeechKit `OGG_OPUS` разобрано:
|
||||
либо устранено, либо записано как проверенно безвредное.
|
||||
@@ -3,11 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды.
|
||||
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
||||
- **Теги:** goal:multi-user
|
||||
|
||||
Двигает пункты 1 и 6 «Завершения» цели: запрос без токена не проходит (пункт 1),
|
||||
а скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его
|
||||
записи (пункт 6).
|
||||
Запрос без токена не проходит, а скрипт ходит в API по токену, выпущенному
|
||||
пользователем, и видит ровно его записи.
|
||||
|
||||
Токен принадлежит учётной записи и даёт ровно её права: записи, заведённые по
|
||||
токену, видны владельцу в приложении, и наоборот.
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
- **Тип:** research
|
||||
- **Категория:** Очередь — Форматы: сначала замер того, что конвейер берёт на самом деле.
|
||||
- **Зачем:** Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
|
||||
- **Теги:** goal:any-audio-source
|
||||
|
||||
Двигает пункты 1 и 4 «Завершения» цели: перечень принимаемых форматов замерен и
|
||||
записан, а расхождение `ogg/vorbis` против заявленного SpeechKit `OGG_OPUS`
|
||||
разобрано.
|
||||
Замер даёт перечень принимаемых форматов и разбирает расхождение `ogg/vorbis`
|
||||
против заявленного SpeechKit `OGG_OPUS`.
|
||||
|
||||
## Вопрос
|
||||
|
||||
@@ -19,9 +17,9 @@
|
||||
- `docs/research/audio-formats.md` — таблица «формат на входе → исход», с
|
||||
командой замера и версией ffmpeg, на которой он сделан;
|
||||
- расхождение `ogg/vorbis` против `OGG_OPUS`: строка о том, устранено оно или
|
||||
проверенно безвредно, и чем это подтверждено;
|
||||
- форматы, которые принять нельзя, — задачей об отказе на приёме, с провенансом
|
||||
этой разведки.
|
||||
проверено безвредно, и чем это подтверждено;
|
||||
- форматы, которые принять нельзя, — задачей об отказе на приёме, и она
|
||||
называет эту разведку.
|
||||
|
||||
## Рамки
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🧹 Запретить обращаться к Bot API мимо клиента бота
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Класс уже дал утечку токена; сегодня его держат две проверки на сегодняшних местах, а не правило.
|
||||
- **Категория:** Очередь — Правило границы клиента ставится на тот же транспорт, мелочи которого разбирает строка выше: своя обёртка, заведённая раньше правила, вернёт утечку токена молча.
|
||||
- **Зачем:** Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
|
||||
|
||||
Токен бота стоит в пути каждого обращения к Bot API, а `http.Client` кладёт
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# 🧹 Переименовать образец конфига в config.example.toml
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Поднято наверх: шесть задач ниже правят конфиг и каждая допишет старое имя образца, удлиняя перечень мест переименования.
|
||||
- **Зачем:** Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
|
||||
|
||||
Конвенция конфигурации взята из проекта jellybit и **сама называет сегодняшнее
|
||||
имя расхождением**: `docs/conventions/config.md`, строка 7 — «образец называется
|
||||
`config.dist.toml`, а не `config.example.toml`». Но строки 31 и 34 того же
|
||||
документа пишут `config.dist.toml` как правило, с заголовком раздела и всем
|
||||
прочим. Документ противоречит сам себе, и который из двух читать — не выводится.
|
||||
|
||||
Задача закрывает расхождение в пользу конвенции: файл переименовывается, а
|
||||
документ перестаёт спорить сам с собой.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- `config.dist.toml` в корне — переименование;
|
||||
- `docs/conventions/config.md` — строка расхождения, заголовок раздела и все
|
||||
упоминания имени;
|
||||
- `CLAUDE.md`, раздел «Команды» — строка про то, что копировать;
|
||||
- `README.md` — команда `cp` и абзац про недостающий ключ;
|
||||
- `docs/review.md`, «Триггеры метки» — упоминание образца;
|
||||
- `docs/conventions/README.md` — строка про самодокументируемый образец;
|
||||
- `tasks/items/external-call-timeouts.md`, `telegram-account-link.md`,
|
||||
`oidc-login.md` — разделы «Затрагивает» ссылаются на имя.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Имени `config.dist.toml` в репозитории не осталось. Оракул —
|
||||
`grep -rn 'config\.dist\.toml' . --exclude-dir=.git` пуст.
|
||||
- Образец лежит под именем `config.example.toml` и по-прежнему не даёт
|
||||
закоммитить реальный конфиг. Оракул — `git ls-files config.example.toml`
|
||||
отдаёт файл, `git check-ignore config.toml` отдаёт `config.toml`.
|
||||
- Конвенция больше не называет имя образца расхождением. Оракул —
|
||||
`grep -n 'config\.example\.toml' docs/conventions/config.md` не находит строки,
|
||||
противопоставляющей одно имя другому (сегодня это строка 7).
|
||||
- Гейт зелёный: битых ссылок правка не оставила. Оракул — `task docs`.
|
||||
|
||||
## Рамки
|
||||
|
||||
Состав полей образца и его комментарии не пересматриваются — задача про имя и
|
||||
про ссылки на него. Прорехи образца, помеченные в конвенции строками
|
||||
«*Расхождение:*», остаются на месте.
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# 🎯 Человек убирает свою запись из архива вместе со всеми текстами
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано — Очередь у цели появилась: удаление записи стоит 32-й строкой и трогает конвейер, файлы и колонку дедупликации, которые к тому месту готовы.
|
||||
- **Зачем:** Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
|
||||
- **Теги:** decomposed
|
||||
|
||||
Сервис объявлен архивом 2026-08-11, и с тем же решением у человека появляется
|
||||
обратное право: сказать «убери это» и убедиться, что убрано. Речь в записи
|
||||
принадлежит тем, кто говорил, а не хранилищу.
|
||||
|
||||
Стирается всё, что породила запись: сам файл, его фрагменты, объект в Object
|
||||
Storage и все уровни текста. **Учёт расхода при этом остаётся** — деньги уже
|
||||
потрачены, и сводка владельца задним числом не переписывается; строки
|
||||
потребления несут идентификаторы и числа, не текст.
|
||||
|
||||
## Завершение
|
||||
|
||||
1. Своя запись убирается одним действием, и после него не остаётся ни файла, ни
|
||||
объекта в хранилище, ни одного из уровней текста.
|
||||
2. Убранное не возвращается: восстановления нет, и человек предупреждён об этом
|
||||
до подтверждения.
|
||||
3. Чужую запись убрать нельзя — ни по идентификатору, ни по токену.
|
||||
4. Сводка расхода после удаления не меняется: потраченное остаётся видно
|
||||
владельцу сервиса.
|
||||
5. Тот же файл, загруженный снова, обрабатывается как новая запись, а не
|
||||
узнаётся дедупликацией удалённой.
|
||||
@@ -3,10 +3,8 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Дедупликация ищет совпадение в пределах пользователя — то есть после владельца записи, и экономит деньги с первого дня приложения.
|
||||
- **Зачем:** Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
|
||||
- **Теги:** goal:upload-reliability
|
||||
|
||||
Двигает пункт 1 «Завершения» цели: повторная отправка того же файла возвращает
|
||||
прежнюю запись вместо второй задачи.
|
||||
Повторная отправка того же файла возвращает прежнюю запись вместо второй задачи.
|
||||
|
||||
Совпадение ищется **в пределах одного пользователя**: чужая расшифровка по
|
||||
совпадению хеш-суммы не отдаётся и о её существовании отправитель не узнаёт.
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Удаление трогает конвейер, файлы, объект хранилища и колонку дедупликации — всё это к этому месту уже готово.
|
||||
- **Зачем:** Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
||||
- **Теги:** goal:data-ownership
|
||||
|
||||
Двигает все пять пунктов «Завершения» цели: запись убирается одним действием
|
||||
вместе с файлом, объектом в хранилище и всеми уровнями текста. Чужую запись
|
||||
убрать нельзя. Учёт расхода остаётся.
|
||||
Запись убирается одним действием вместе с файлом, объектом в хранилище и всеми
|
||||
уровнями текста. Чужую запись убрать нельзя. Учёт расхода остаётся.
|
||||
|
||||
Удаление необратимо и потому спрашивает подтверждения. Задача, которая ещё в
|
||||
работе, тоже убирается: конвейер обязан заметить исчезнувшую запись и не
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
# 🧹 Свести шесть расхождений между документами канона
|
||||
# 🧹 Свести пять расхождений между документами канона
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Находки одной сверки: чинится одним заходом, пока помнится, чем каждое место было найдено.
|
||||
- **Зачем:** Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; четыре из них в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
||||
- **Категория:** Очередь — Документы правятся до того, как на них обопрутся экраны и контракт: расхождение в таблице зависимостей и в периметре читают, принимая решения ниже по списку.
|
||||
- **Зачем:** Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
||||
|
||||
Находки сверки документов агентами `doc-consistency` и `doc-code-drift`,
|
||||
прогнанной 2026-08-13 вместе с работой о контексте и токене. К той работе
|
||||
расхождения отношения не имеют — они старше, и потому не чинились тем же
|
||||
коммитом.
|
||||
|
||||
Мест было шесть. Шестое — вид временной метки, где `conventions/database.md`
|
||||
требовал RFC 3339 с `T`, а хранилище пишет `2006-01-02 15:04:05.000Z`, — сведено
|
||||
2026-08-13 строкой «*Расхождение:*» в конвенции при повышении раскладки до
|
||||
версии 3. Остальные пять живы.
|
||||
|
||||
Каждое место названо с домом факта, то есть с тем документом, который прав:
|
||||
|
||||
1. **Панель администратора против Authelia.** `architecture.md`, «Открытые
|
||||
@@ -26,11 +31,7 @@
|
||||
4. **gin в `README.md`.** Веб-фреймворка нет: HTTP-поверхность — роутер
|
||||
встроенной PocketBase, и `logging.md` прямо говорит, что вместе с gin ушёл и
|
||||
`sloggin`. Дом стека — `CLAUDE.md`.
|
||||
5. **Вид временной метки.** `conventions/database.md`: RFC 3339 с `T`, секундная
|
||||
точность. `docs/database.md`: `2006-01-02 15:04:05.000Z`, и вид обязателен
|
||||
побайтово — сравнение в SQLite строковое. Дом — `docs/database.md`;
|
||||
конвенции нужна строка «*Расхождение:*».
|
||||
6. **Дубли текста в `CLAUDE.md`** — подавления `hadolint` и настройка
|
||||
5. **Дубли текста в `CLAUDE.md`** — подавления `hadolint` и настройка
|
||||
`errcheck` пересказаны там дословно, хотя обе преамбулы договорились, что
|
||||
дом перечня подавлений — `go-linters.md`.
|
||||
|
||||
@@ -38,7 +39,6 @@
|
||||
|
||||
- `docs/architecture.md` — «Открытые вопросы», таблица внешних зависимостей,
|
||||
раздел «Эксплуатация»;
|
||||
- `docs/conventions/database.md` — вид временной метки;
|
||||
- `docs/security.md` и `docs/database.md` — как дома фактов, если правка
|
||||
потребует уточнить формулировку;
|
||||
- `README.md` — перечень технологий;
|
||||
@@ -46,8 +46,8 @@
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Ни одно из шести мест не отвечает на свой вопрос двумя способами. Оракул —
|
||||
повторный прогон `av-dev-docs:healthcheck`: перечисленные шесть находок не
|
||||
- Ни одно из пяти мест не отвечает на свой вопрос двумя способами. Оракул —
|
||||
повторный прогон `av-dev:doc-healthcheck`: перечисленные пять находок не
|
||||
возвращаются.
|
||||
- Провайдер OIDC стоит в таблице внешних зависимостей со своими четырьмя
|
||||
столбцами отказа, и счёт зависимостей в «Открытых вопросах» сходится с
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Второй канал на той же доставке.
|
||||
- **Зачем:** Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
||||
- **Теги:** goal:ready-notification
|
||||
|
||||
Двигает пункты 1, 2 и 3 «Завершения» цели: готовый текст и отказ доходят
|
||||
письмом, а адрес берётся у учётной записи, а не из общего конфига.
|
||||
Готовый текст и отказ доходят письмом, а адрес берётся у учётной записи, а не из
|
||||
общего конфига.
|
||||
|
||||
Почта — второй канал рядом с тем, что заводит `ntfy-delivery`; выбор канала
|
||||
остаётся в той же единой точке, что и сейчас.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
аргументом;
|
||||
- `internal/controller/worker/worker.go` и `internal/service/transcribe.go` —
|
||||
протаскивание контекста в шаг;
|
||||
- `config.dist.toml` и `internal/config` — числа таймаутов;
|
||||
- `config.example.toml` и `internal/config` — числа таймаутов;
|
||||
- `docs/database.md`, таблица настроек с числовым значением.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Метрики внешних сервисов пишутся в выбранном словаре, а не переписываются потом.
|
||||
- **Зачем:** Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера.
|
||||
- **Теги:** goal:service-observability
|
||||
|
||||
Двигает пункты 2 и 5 «Завершения» цели: у каждого внешнего сервиса появляются
|
||||
вызовы, отказы и длительность, а расход на платные сервисы виден числом.
|
||||
У каждого внешнего сервиса появляются вызовы, отказы и длительность, а расход на
|
||||
платные сервисы становится виден числом.
|
||||
|
||||
Внешних сервисов сегодня четыре — Telegram, Object Storage, SpeechKit и
|
||||
`ffmpeg`/`ffprobe` как внешний процесс; пятым станет языковая модель. Метрика
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# 🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Непокрытую изменённую функцию дважды ловил проход ревью, а не машина; порог решён 2026-08-12, брать можно.
|
||||
- **Зачем:** Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
|
||||
|
||||
`CLAUDE.md`, раздел «Гейт», объявляет прямо: «покрытие изменённых строк не
|
||||
считается ничем». Цена этого измерена дважды. В задаче
|
||||
`http-handler-tests-never-green` тесты обработчика не были зелёными ни разу; в
|
||||
`pocketbase-storage` два из трёх шагов конвейера переписали целиком и не
|
||||
выполнили ни одним тестом — нашёл это проход ревью, а не машина.
|
||||
|
||||
**Единица счёта — функция, а не строка** (решение владельца 2026-08-12). Шаг
|
||||
краснеет на новой или изменённой функции, которую не выполняет ни один тест;
|
||||
доля покрытых строк внутри неё не считается и порогом не ограничивается. Довод:
|
||||
процент изменённых строк роняет гейт на всякой ветке отказа, которую нечем
|
||||
изобразить в тесте, а порог ниже ста пришлось бы брать из ниоткуда. Именно
|
||||
непокрытая целиком функция — то, что дважды ловили руками.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- набор шагов `task gate` в `Taskfile.yml` и переменная `BASE` как база диффа;
|
||||
- семантика гейта в `CLAUDE.md`, раздел «Гейт», строка про покрытие;
|
||||
- `docs/review.md`, раздел настройки конвейера: чем проход `autotests` перестаёт
|
||||
заниматься руками.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Изменённая строка без покрытия роняет гейт. Оракул — прогон на дереве, где в
|
||||
тронутый файл добавлена заведомо невыполняемая ветка: шаг краснеет с её
|
||||
адресом.
|
||||
- Изменение, не трогающее код, шаг не гоняет. Оракул — `task gate` на дереве с
|
||||
правкой одной только документации: шаг сообщает о пропуске с причиной.
|
||||
- Единица счёта названа в `CLAUDE.md` и совпадает с тем, что проверяет шаг.
|
||||
Оракул — `task gate`, шаг `docs.py check`.
|
||||
- Функция, тронутая правкой на одну строку, шаг не роняет, если её вызывает хоть
|
||||
один тест. Оракул — прогон на дереве с однострочной правкой внутри покрытой
|
||||
функции: шаг зелёный.
|
||||
|
||||
## Рамки
|
||||
|
||||
Общее покрытие проекта не считаем и порога на него не ставим: он растёт от
|
||||
тестов на тривиальное и не отвечает ни на один вопрос.
|
||||
@@ -1,34 +0,0 @@
|
||||
# 🔬 Шаги гейта, у которых правило может потерять предмет
|
||||
|
||||
- **Тип:** research
|
||||
- **Категория:** Очередь — Разведка о чужих скриптах: пока ответа нет, неизвестно даже, есть ли работа.
|
||||
- **Зачем:** У шага migrations страж предмета есть, у шагов docs, tasks и openspec неизвестно: они зовут чужие скрипты из плагинов, и правило, потерявшее файлы, зеленело бы молча.
|
||||
|
||||
Класс известен и записан: правило, чей предмет исчез, обходит пустой перечень
|
||||
ноль раз и проходит зелёным. В `internal/archrules` от этого стоит
|
||||
`TestПакетыПравилСуществуют` — он падает, когда пакет из правила переименован. У
|
||||
шага `migrations` страж завёлся 2026-08-13: пустой каталог шагов роняет шаг с
|
||||
кодом 3.
|
||||
|
||||
Чего не знаем: ведут ли себя так же `docs.py check`, `tasks.py check` и
|
||||
`openspec.py check`. Скрипты чужие — они живут в плагинах `av-dev-docs`,
|
||||
`av-dev-tasks` и `av-dev-code`, и править их в этом репозитории нельзя. Отсюда и
|
||||
тип записи: способ починки зависит от ответа. Найдётся страж внутри — делать
|
||||
нечего; не найдётся — либо обёртка в `Taskfile.yml` со своей проверкой предмета,
|
||||
либо разговор с владельцем плагина.
|
||||
|
||||
## Вопрос
|
||||
|
||||
Какие шаги гейта проходят зелёными, когда предмет их правила исчез, — и чем это
|
||||
чинится, если сам скрипт править нельзя?
|
||||
|
||||
## Куда ляжет ответ
|
||||
|
||||
`docs/research/gate-steps-subject-guard.md` — записка с перечнем шагов, снятыми
|
||||
исходами (по каждому: что сделали с предметом, каким кодом ответил шаг) и
|
||||
рекомендацией. Исход разведки — задачи на те шаги, где страж нужен и возможен.
|
||||
|
||||
## Рамки
|
||||
|
||||
Скрипты плагинов не правим: они не в этом репозитории. Прогоны идут на временном
|
||||
клоне репозитория, каталоги `docs/` и `tasks/` рабочего дерева не трогаем.
|
||||
@@ -1,14 +1,13 @@
|
||||
# ✨ Показывать заголовок в списке, отбирать список по темам и считать токены
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Три пункта «Завершения» цели не закрывала ни одна задача: показывать и отбирать можно, когда заголовки и темы уже считаются.
|
||||
- **Категория:** Очередь — Показывать и отбирать можно, когда заголовки и темы уже считаются.
|
||||
- **Зачем:** Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом.
|
||||
- **Теги:** goal:text-insights
|
||||
|
||||
Двигает пункты 1, 4 и 6 «Завершения» цели — те три, где выводы из текста
|
||||
становятся видны человеку и владельцу: заголовок в списке (1), отбор по темам
|
||||
(4), стоимость числом (6). Сами уровни считает `llm-insights-adapter`, показать
|
||||
их некому: экран списка написан раньше и знает только первые слова расшифровки.
|
||||
Выводы из текста становятся видны человеку и владельцу: заголовок в списке,
|
||||
отбор по темам, стоимость числом. Сами уровни считает `llm-insights-adapter`,
|
||||
показать их некому: экран списка написан раньше и знает только первые слова
|
||||
расшифровки.
|
||||
|
||||
Берётся после `llm-insights-adapter`: пока заголовков и тем нет, показывать и
|
||||
отбирать нечего.
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Ставить на телефон есть смысл, когда есть что ставить.
|
||||
- **Зачем:** Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
||||
- **Теги:** goal:web-access
|
||||
|
||||
Двигает пункты 5 и 6 «Завершения» цели: приложение ставится с телефона и
|
||||
запускается с ярлыка без адресной строки, а открытое без сети показывает это
|
||||
состоянием.
|
||||
Приложение ставится с телефона и запускается с ярлыка без адресной строки, а
|
||||
открытое без сети показывает это состоянием.
|
||||
|
||||
Берётся после того, как есть что ставить, — то есть после
|
||||
`records-list-screen`.
|
||||
@@ -41,5 +39,5 @@
|
||||
## Рамки
|
||||
|
||||
Офлайн-чтения готовых расшифровок и очереди отправки без сети **не делаем** —
|
||||
это за границей цели. Web Push не делаем: уведомления идут через apprise и ntfy,
|
||||
цель `ready-notification`.
|
||||
это за границей из паспорта. Web Push не делаем: уведомления идут через apprise
|
||||
и ntfy — задача `ntfy-delivery`.
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
- **Тип:** research
|
||||
- **Категория:** Очередь — Второй замер — остальные четыре звена.
|
||||
- **Зачем:** Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
||||
- **Теги:** goal:long-recordings
|
||||
|
||||
Пункт 1 «Завершения» цели требует замера пяти звеньев, а разведка
|
||||
`speechkit-limits` меряет одно — модель `deferred-general`. Остальные четыре
|
||||
дешевле: они не требуют боевых ключей и считаются локально, кроме заливки.
|
||||
Звеньев пять, а разведка `speechkit-limits` меряет одно — модель
|
||||
`deferred-general`. Остальные четыре дешевле: они не требуют боевых ключей и
|
||||
считаются локально, кроме заливки.
|
||||
|
||||
Числа нужны раньше кода: они назначают потолок, который проверяет приём, и длину
|
||||
фрагмента, на которые режет `long-audio-chunking`.
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
# ✨ Собирать путь одной записи по конвейеру запросом
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Пункт 3 «Завершения» цели не закрывала ни одна задача; применяет словарь, который выберет разведка строкой выше.
|
||||
- **Категория:** Очередь — Применяет словарь метрик, который выберет разведка строкой выше.
|
||||
- **Зачем:** Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами.
|
||||
- **Теги:** goal:service-observability
|
||||
|
||||
Двигает пункт 3 «Завершения» цели: путь одной записи по конвейеру собирается
|
||||
запросом, а не чтением логов глазами.
|
||||
Путь одной записи по конвейеру собирается запросом, а не чтением логов глазами.
|
||||
|
||||
Путь длиной в минуты идёт через четыре внешних сервиса и три воркера. Сегодня
|
||||
его звенья связывает `job_id` в строках журнала, и собирает их человек.
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Единая точка трансляции доменной ошибки — база и для экранов, и для токенов; список своих записей заводится после владельца, а не до.
|
||||
- **Зачем:** Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
||||
- **Теги:** goal:web-access
|
||||
|
||||
Двигает пункты 1, 2 и 4 «Завершения» цели: экраны заводят задачу, видят её
|
||||
состояние и листают список — всё через один контракт.
|
||||
Экраны заводят задачу, видят её состояние и листают список — всё через один
|
||||
контракт.
|
||||
|
||||
Обработчик `GET /api/status/:id` сегодня отвечает `404` на **любую** ошибку
|
||||
чтения, включая сбой базы, а `POST /api/audio` — `500` на любую ошибку заведения,
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Вычитанный текст считается тем же адаптером.
|
||||
- **Зачем:** Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
|
||||
- **Теги:** goal:text-insights
|
||||
|
||||
Двигает пункт «Завершения» цели про литературный текст: у записи появляется
|
||||
второй уровень — тот же разговор, вычитанный до читаемого вида.
|
||||
У записи появляется второй уровень текста — тот же разговор, вычитанный до
|
||||
читаемого вида.
|
||||
|
||||
Вычитку считает та же внешняя модель, что заголовок и темы. Сырой текст
|
||||
остаётся и не переписывается: уровни лежат рядом, а не поверх друг друга.
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Уровни текста: сюда приходит пятая внешняя зависимость, и конвейер к этому моменту покрыт тестами.
|
||||
- **Зачем:** Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
|
||||
- **Теги:** goal:text-insights
|
||||
|
||||
Двигает пункты 1, 3, 4 и 5 «Завершения» цели: у готовой записи появляются
|
||||
заголовок (1), пересказ (3) и темы (4), а отказ и молчание модели не роняют
|
||||
задачу (5).
|
||||
У готовой записи появляются заголовок, пересказ и темы, а отказ и молчание
|
||||
модели не роняют задачу.
|
||||
|
||||
Здесь появляется пятая внешняя зависимость — языковая модель с
|
||||
OpenAI-совместимым интерфейсом за шлюзом bifrost, — и текст расшифровки уходит
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# 🧹 Поднимать сервис локально без действующего токена бота
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Поднято к долгам входа: живой прогон нужен именно им, а сегодня его нет ни у одной задачи.
|
||||
- **Зачем:** Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
|
||||
|
||||
Замечено при попытке проверить вход вживую в задаче `oidc-login` 2026-08-12;
|
||||
подтверждено прогоном: с выдуманным токеном старт кончается отказом создания
|
||||
отправителя раньше, чем поднимается HTTP-сервер.
|
||||
|
||||
Отсюда следствие, которое стоит дороже самого неудобства: **поведенческая
|
||||
верификация живым запуском недоступна проекту вовсе**. Всякая задача, меняющая
|
||||
наблюдаемое поведение, проверяется только тестами, а «поднять и посмотреть»
|
||||
остаётся человеку с боевым конфигом.
|
||||
|
||||
Запрет запускаться боевым токеном снимать не надо: второй процесс с тем же
|
||||
токеном перехватывает обновления у работающего.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- создание отправителя Telegram при старте в `main.go`;
|
||||
- секция `[telegram]` конфига и её образец;
|
||||
- раздел «Запреты» в `CLAUDE.md` — строка про боевой токен остаётся, но рядом
|
||||
появляется способ поднять сервис без него;
|
||||
- `docs/review.md`, подраздел «Недоступно проверке»: строка про недоступность
|
||||
живого прогона снимается или сужается.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Сервис поднимается с пустым токеном бота: HTTP отвечает, воркеры идут, бот не
|
||||
создан. Оракул — запуск с конфигом без токена и запрос `GET /health`: код 200.
|
||||
- Отсутствие бота названо в журнале один раз при старте, а не молчанием. Оракул —
|
||||
тот же запуск: в выводе есть строка о том, что бот не поднят и почему.
|
||||
- Поведение с настоящим токеном не изменилось. Оракул — тест на создание
|
||||
отправителя с непустым токеном: прежний путь сохранён.
|
||||
@@ -4,7 +4,7 @@
|
||||
- **Категория:** Очередь — Разведка закрывает тему входа последней: остальные три задачи меняют то, что она проверяет.
|
||||
- **Зачем:** Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было.
|
||||
|
||||
Провенанс — отчёт триажа ревью задачи `oidc-login` 2026-08-12,
|
||||
Откуда — отчёт триажа ревью задачи `oidc-login` 2026-08-12,
|
||||
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
раздел «Гипотезы без доказательства». Каждая либо становится задачей, либо
|
||||
закрывается с причиной; сегодня они не то и не другое.
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## Куда ляжет ответ
|
||||
|
||||
- подтверждённый путь — задачей в беклоге, с провенансом этой разведки;
|
||||
- подтверждённый путь — задачей в беклоге, и она называет эту разведку;
|
||||
- опровергнутый — строкой в `docs/security.md`, раздел «Что вне модели» либо
|
||||
«Что разграничивает доступ», чтобы следующее ревью не открывало его заново;
|
||||
- то, что зависит от настройки Authelia, — строкой там же, с указанием, какая
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🧹 Строить адрес входа из настроек коллекции, а не из конфига
|
||||
# ✨ Строить адрес входа из настроек коллекции, а не из конфига
|
||||
|
||||
- **Тип:** chore
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Замыкает тройку правок обработчиков входа.
|
||||
- **Зачем:** Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Резка на фрагменты — самая глубокая переделка конвейера, и она идёт по замеренным числам.
|
||||
- **Зачем:** Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
|
||||
- **Теги:** goal:long-recordings
|
||||
|
||||
Двигает пункты 3, 5 и 6 «Завершения» цели: запись в пределах потолка доходит до
|
||||
текста целиком, долгая задача не занимает воркер на часы, а перезапуск на
|
||||
середине продолжает работу с первого неотмеченного фрагмента.
|
||||
Запись в пределах потолка доходит до текста целиком, долгая задача не занимает
|
||||
воркер на часы, а перезапуск на середине продолжает работу с первого
|
||||
неотмеченного фрагмента.
|
||||
|
||||
Запись делится на фрагменты, каждый распознаётся отдельно, готовый фрагмент
|
||||
отмечается в базе. После перезапуска работа продолжается с первого неотмеченного, а
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# 🎯 Запись длиной до шести часов доходит до текста
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Направления — Цель начинается с двух замеров и кончается резкой на фрагменты — самой глубокой переделкой конвейера: очереди внутри нет, пока числа не получены.
|
||||
- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
|
||||
- **Теги:** decomposed
|
||||
|
||||
Лекция, созвон, интервью и диктофонная запись из семейного архива целиком
|
||||
превращаются в текст. Сегодня неизвестно даже, на каком звене такая запись
|
||||
отваливается, — цель начинается с замера, а не с переделки.
|
||||
|
||||
Расчётный потолок — **шесть часов**: он взят с запасом под диктофонные записи и
|
||||
дорожки из видео, и замер проверяет, каким звеном он ограничен на самом деле.
|
||||
|
||||
## Завершение
|
||||
|
||||
1. Потолки каждого звена замерены и записаны в `research/` с командой замера:
|
||||
приём из Telegram, приём по HTTP, конвертация, заливка в Object Storage,
|
||||
модель `deferred-general`.
|
||||
2. Запись, превышающая потолок, отклоняется на приёме понятным текстом, а не
|
||||
висит в конвейере до истечения захвата.
|
||||
3. Запись в пределах потолка доходит до текста и не теряет его хвост.
|
||||
4. Текст в несколько сотен килобайт доходит до получателя: и в браузере, и в
|
||||
Telegram, где предел сообщения — 4000 символов.
|
||||
5. Долгая задача не блокирует короткие: запись на три часа не останавливает
|
||||
конвейер для голосового на десять секунд.
|
||||
6. Перезапуск сервиса на середине долгой расшифровки не начинает её заново:
|
||||
работа продолжается с места остановки.
|
||||
@@ -3,10 +3,8 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Сотни килобайт текста появляются только после долгих записей.
|
||||
- **Зачем:** Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
|
||||
- **Теги:** goal:long-recordings
|
||||
|
||||
Двигает пункт 4 «Завершения» цели: текст в несколько сотен килобайт доходит и в
|
||||
Telegram, и в браузере.
|
||||
Текст в несколько сотен килобайт доходит и в Telegram, и в браузере.
|
||||
|
||||
Деление по словам (`internal/adapter/telegram/split.go`) остаётся для обычной
|
||||
расшифровки; сверх названного числа частей вместо потока сообщений уходит один
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# 🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Очередь — Шаг уже стоит в гейте и уже назван стражем critical-инварианта в двух документах — необеспеченное обещание дороже отсутствующего.
|
||||
- **Зачем:** Шаг охраняет critical-инвариант «применённый шаг схемы не переписывается», но своих проверок не имеет: дрейф шаблона имени, переезд каталога или потеря grep в конвейере оставят его вечно зелёным, и это не заметит ничто.
|
||||
|
||||
Шаг заведён 2026-08-13 и проверен мутацией на восьми исходах вручную — правка
|
||||
уехавшего шага в дереве и в коммите, удаление, переименование, новый шаг, правка
|
||||
`migrations.go`, отсутствующий ключ в `docs/.docs.json`, каталог без шагов,
|
||||
неразрешимая база диффа. Прогон был разовым: в дереве от него не осталось ничего.
|
||||
|
||||
Прецедент рядом. У шага сверки версий Go есть спека
|
||||
[toolchain](../../openspec/specs/toolchain/spec.md) и 20 мутационно проверенных
|
||||
сценариев в `scripts/check_go_version_test.go`; заведены они после дефекта
|
||||
2026-08-12, когда зелёный шаг не проверял ничего и образ перестал собираться.
|
||||
Долг назван строкой в
|
||||
[go-linters.md](../../docs/conventions/go-linters.md), «Границы: где что живёт».
|
||||
|
||||
**Развилка, решаемая внутри задачи:** нормировать шаг спекой (второй capability
|
||||
о проверке, как `toolchain`) либо ограничиться проверками без нормы. Первое
|
||||
дороже и даёт построчную сверку сценариев; второе закрывает регрессию, но
|
||||
оставляет норму в комментарии `Taskfile.yml`.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
- шаг `migrations` в `Taskfile.yml` — его логика разбора `git diff`;
|
||||
- ключ `migrations` в `docs/.docs.json` — из него шаг берёт каталог;
|
||||
- каталог шагов схемы `internal/adapter/repo/pocketbase/migrations/` как предмет
|
||||
правила;
|
||||
- возможно — новая capability в `openspec/specs/` и файл проверок рядом с
|
||||
`scripts/check_go_version_test.go`.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- Переписанный уехавший шаг схемы роняет проверку. Оракул — прогон сценария на
|
||||
временном клоне репозитория: правка файла шага даёт код 1 и называет файл.
|
||||
- Новый файл шага проверку не роняет, и правка `migrations.go` тоже: строка
|
||||
`Register` нового шага прибавляется именно там. Оракул — те же два сценария.
|
||||
- Каталог без единого файла шага и отсутствующий ключ в `docs/.docs.json` дают
|
||||
код 3, а не тихий ноль. Оракул — два сценария на временном каталоге.
|
||||
- Проверка сценариев идёт в гейте, а не руками. Оракул — `task gate` красный при
|
||||
внесённом нарушении шаблона имени файла шага.
|
||||
|
||||
## Рамки
|
||||
|
||||
Боевой каталог данных и файлы шагов схемы не трогаем: сценарии гоняются на
|
||||
временном клоне репозитория. Чужие скрипты проверок (`docs.py`, `tasks.py`,
|
||||
`openspec.py`) — не наши, они в задаче `gate-steps-subject-guard`.
|
||||
@@ -3,10 +3,8 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Пачка файлов заводится на готовом экране загрузки и готовой дедупликации.
|
||||
- **Зачем:** Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
|
||||
- **Теги:** goal:upload-reliability
|
||||
|
||||
Двигает пункт 2 «Завершения» цели: пачка файлов уходит одной загрузкой, и отказ
|
||||
одного не отменяет остальные.
|
||||
Пачка файлов уходит одной загрузкой, и отказ одного не отменяет остальные.
|
||||
|
||||
## Затрагивает
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 🎯 Сервисом пользуются несколько человек, и записи одного не видны другому
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано — Владелец записи — фундамент, на котором стоят список своих записей, дедупликация, удаление, учёт расхода и квота: пока его нет, остальные цели строятся на песке.
|
||||
- **Зачем:** У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
|
||||
- **Теги:** decomposed
|
||||
|
||||
Приложение узнаёт, кто к нему пришёл, и показывает каждому только его записи.
|
||||
Учётные записи заводит и проверяет внешний провайдер — Authelia по OIDC; своей
|
||||
регистрации и своих паролей не делаем, это граница из
|
||||
[паспорта](../../docs/passport.md).
|
||||
|
||||
## Завершение
|
||||
|
||||
1. Неаутентифицированный запрос к записям не проходит: ни к странице, ни к API.
|
||||
2. У задачи и файла есть владелец, и выборка чужой записи по её
|
||||
идентификатору возвращает «не найдено», а не содержимое.
|
||||
3. Вход идёт через OIDC у Authelia; выход из сессии работает.
|
||||
4. Пользователь Telegram сопоставлен с учётной записью, и записи, пришедшие
|
||||
ботом, видны ему же в браузере.
|
||||
5. Белый список Telegram перестаёт быть отдельным механизмом: право писать боту
|
||||
выводится из учётной записи.
|
||||
6. Скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его
|
||||
записи.
|
||||
@@ -3,12 +3,10 @@
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь — Канал уведомлений выбирается в настройках, которые уже есть.
|
||||
- **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
||||
- **Теги:** goal:ready-notification
|
||||
|
||||
Двигает пункты 1, 2, 4 и 5 «Завершения» цели: готовый текст и отказ доходят до
|
||||
пользователя веба без открытого приложения, отказ канала задачу не роняет, а
|
||||
пользователь Telegram получает ответ по-прежнему ботом. Выбор канала самим
|
||||
пользователем (пункт 3) заводит `settings-screen`.
|
||||
Готовый текст и отказ доходят до пользователя веба без открытого приложения,
|
||||
отказ канала задачу не роняет, а пользователь Telegram получает ответ
|
||||
по-прежнему ботом. Выбор канала самим пользователем заводит `settings-screen`.
|
||||
|
||||
Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`;
|
||||
источник `api` не получает ничего. Здесь появляется второй способ доставки, и
|
||||
@@ -46,3 +44,8 @@
|
||||
Web Push с VAPID и своим хранением подписок не делаем. Своего сервера ntfy не
|
||||
поднимаем — адрес приходит конфигом. Текст расшифровки уходит на внешний сервис,
|
||||
и это сдвиг периметра: строка в `docs/security.md` обязательна.
|
||||
|
||||
Отказ от Web Push сегодня живёт открытым вопросом `docs/architecture.md`,
|
||||
«Уведомления», и своего ADR не имеет: заводить его не из чего, пока нет
|
||||
`design.md` этой задачи. Решение промоутится из него, когда задача пойдёт в
|
||||
работу.
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
- **Тип:** research
|
||||
- **Категория:** Очередь — Сопровождение: словарь метрик выбирается до того, как метрик станет втрое больше.
|
||||
- **Зачем:** Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
|
||||
- **Теги:** goal:service-observability
|
||||
|
||||
Эндпоинт `/metrics` остаётся и развивается — это решено. Вопрос в том, чем его
|
||||
развивать: дописывать счётчики в `internal/metrics` напрямую через
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user