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` —
|
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` —
|
||||||
скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в
|
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
|
||||||
[docs/conventions/config.md](docs/conventions/config.md) строками
|
в [docs/conventions/config.md](docs/conventions/config.md) строками
|
||||||
«*Расхождение:*».
|
«*Расхождение:*».
|
||||||
|
|
||||||
## Гейт
|
## Гейт
|
||||||
@@ -141,7 +141,7 @@ task gate # весь набор проверок разом
|
|||||||
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
|
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
|
||||||
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
|
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
|
||||||
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
||||||
сверка версий Go, три сверки документов и `govulncheck`: они смотрят всё
|
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
|
||||||
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
||||||
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
|
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
|
||||||
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
||||||
@@ -155,14 +155,14 @@ task gate # весь набор проверок разом
|
|||||||
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
|
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
|
||||||
образ по-прежнему может только человек — `task image`, и на подъёме версии
|
образ по-прежнему может только человек — `task image`, и на подъёме версии
|
||||||
это обязательно;
|
это обязательно;
|
||||||
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и два его
|
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и часть его
|
||||||
правила подавлены поимённо — `DL3007` до задачи `pin-runtime-image-base` и
|
правил подавлена поимённо — `DL3007` до задачи `pin-runtime-image-base` и
|
||||||
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
|
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
|
||||||
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
|
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
|
||||||
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
|
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
|
||||||
коммита. Полную историю никто не проверяет;
|
коммита. Полную историю никто не проверяет;
|
||||||
- согласованность документов между собой и с кодом — её судят агенты, зовёт
|
- согласованность документов между собой и с кодом — её судят агенты, зовёт
|
||||||
их скилл `av-dev-docs:healthcheck`, и звать его надо руками;
|
их скилл `av-dev:doc-healthcheck`, и звать его надо руками;
|
||||||
- покрытие изменённых строк не считается ничем.
|
- покрытие изменённых строк не считается ничем.
|
||||||
|
|
||||||
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
|
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
|
||||||
@@ -186,12 +186,29 @@ task gate # весь набор проверок разом
|
|||||||
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
|
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
|
||||||
ронять и пересоздавать можно свободно.
|
ронять и пересоздавать можно свободно.
|
||||||
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
|
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
|
||||||
перехватывает обновления у работающего, и пользователь теряет ответы.
|
перехватывает обновления у работающего, и пользователь теряет ответы. Запускай
|
||||||
|
с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит
|
||||||
|
ни одного обращения, и работает он одним входом, по HTTP. Пустого
|
||||||
|
`bot_token` для этого мало и больше не значит ничего: включён вход или нет,
|
||||||
|
решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true`
|
||||||
|
роняет старт. Выключенного входа
|
||||||
|
для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но
|
||||||
|
наружу при этом не ходят, так что годятся выдуманные непустые значения;
|
||||||
|
подробности строками в `config.example.toml`.
|
||||||
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
||||||
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
||||||
подставляй `internal/adapter/recognizer/memory.go`.
|
подставляй `internal/adapter/recognizer/memory.go`.
|
||||||
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
|
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
|
||||||
запускает человек.
|
запускает человек.
|
||||||
|
- **Проверок над проверками не заводить.** Уровень проверки один: линтеры и
|
||||||
|
тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты
|
||||||
|
на шаги гейта и на свои скрипты проверок, стражи предмета у правил,
|
||||||
|
механизация покрытия изменённого кода, мутационная сверка оракулов и
|
||||||
|
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
|
||||||
|
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
|
||||||
|
[tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать
|
||||||
|
сценариев шага сверки версий Go, единственный такой файл в проекте.
|
||||||
|
Исключений у запрета нет.
|
||||||
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
|
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
|
||||||
временном каталоге и убирают за собой.
|
временном каталоге и убирают за собой.
|
||||||
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
|
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
|
||||||
@@ -206,9 +223,9 @@ task gate # весь набор проверок разом
|
|||||||
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
|
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
|
||||||
секрета.
|
секрета.
|
||||||
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
|
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
|
||||||
твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга
|
твоей правки. Такое чинится прежде любой другой работы. Исключений из этого
|
||||||
из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли
|
правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать
|
||||||
задачами.
|
красный шаг больше не на что.
|
||||||
- **Ориентир по размеру порции:** не замерялся.
|
- **Ориентир по размеру порции:** не замерялся.
|
||||||
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
|
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
|
||||||
поимённо.
|
поимённо.
|
||||||
@@ -218,3 +235,18 @@ task gate # весь набор проверок разом
|
|||||||
- Документация, комментарии, сообщения коммитов — русский.
|
- Документация, комментарии, сообщения коммитов — русский.
|
||||||
- Код и идентификаторы — английский.
|
- Код и идентификаторы — английский.
|
||||||
- Текст, который видит пользователь Telegram, — русский.
|
- Текст, который видит пользователь Telegram, — русский.
|
||||||
|
- **Точного числа накопленного в документах нет.** «Три capability», «пять
|
||||||
|
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
|
||||||
|
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
|
||||||
|
такое не считает, а читатель верит написанному. Ссылаться можно только на
|
||||||
|
**конкретную запись** (по имени, со ссылкой) либо на **весь корпус разом**
|
||||||
|
(«заведённые capability», «записи журнала ниже»). Само перечисление при этом
|
||||||
|
законно: перечень обновляют вместе с предметом, а число живёт отдельно от него
|
||||||
|
и потому протухает в одиночку.
|
||||||
|
|
||||||
|
*Изъятие:* число, которое не растёт с работой, остаётся числом — количество
|
||||||
|
уровней журнала в библиотеке, ступеней сборки образа, состояний списка на
|
||||||
|
экране. Так же законно **историческое** число в записи о прошлом: «решением от
|
||||||
|
2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт.
|
||||||
|
Настройки с числовым значением — свой случай, их дом
|
||||||
|
[docs/database.md](docs/database.md).
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
```
|
```
|
||||||
3. Скопируйте образец конфига и заполните его:
|
3. Скопируйте образец конфига и заполните его:
|
||||||
```bash
|
```bash
|
||||||
cp config.dist.toml config.toml
|
cp config.example.toml config.toml
|
||||||
```
|
```
|
||||||
4. Запустите приложение:
|
4. Запустите приложение:
|
||||||
```bash
|
```bash
|
||||||
@@ -62,9 +62,13 @@ inv pl -- transcriber
|
|||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
Четыре маршрута: `POST /api/audio` — приём записи, `GET /api/status/:id` —
|
Семь адресов приложения: `POST /api/audio` — приём записи, `GET /api/status/:id`
|
||||||
готовность задачи, `GET /metrics` — метрики Prometheus с префиксом
|
— готовность задачи, `GET /auth/login`, `GET /auth/callback` и
|
||||||
`transcriber_`, `GET /health` — проверка живости.
|
`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): поля запроса и
|
[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).
|
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|||||||
+13
-12
@@ -15,9 +15,9 @@ vars:
|
|||||||
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
|
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
|
||||||
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
|
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
|
||||||
# по коду скрипта, а не по коду `task`.
|
# по коду скрипта, а не по коду `task`.
|
||||||
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.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-tasks/skills/tasks/scripts/tasks.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-code/skills/openspec/scripts/openspec.py"}}'
|
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/code-openspec/scripts/openspec.py"}}'
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|
||||||
@@ -89,19 +89,20 @@ tasks:
|
|||||||
echo "задай свою: task migrations BASE=<rev>"
|
echo "задай свою: task migrations BASE=<rev>"
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
# Каталог шагов берётся из docs/.docs.json — там он уже записан ключом
|
# Каталог шагов берётся из .av-dev.toml — там он уже записан ключом
|
||||||
# `migrations` для сверки документов. Свой литерал завёл бы факту второй
|
# `migrations` секции `[docs]` для сверки документов. Свой литерал завёл
|
||||||
# дом: каталог переехал бы, а один из двух стражей молча позеленел.
|
# бы факту второй дом: каталог переехал бы, а один из двух стражей молча
|
||||||
dir=$(python3 -c 'import json,sys; print(json.load(open("docs/.docs.json"))["migrations"])' 2>/dev/null) || dir=""
|
# позеленел. До слияния плагинов файл звался 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
|
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||||
echo "каталог шагов схемы не найден: ключ migrations в docs/.docs.json → '$dir'"
|
echo "каталог шагов схемы не найден: ключ [docs] migrations в .av-dev.toml → '$dir'"
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
|
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
|
||||||
# одного переименования — тот же приём, что у правил `internal/archrules`.
|
# одного переименования — тот же приём, что у правил `internal/archrules`.
|
||||||
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
|
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
|
||||||
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
|
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
|
||||||
echo "поправь шаблон имени в этом шаге либо ключ migrations в docs/.docs.json"
|
echo "поправь шаблон имени в этом шаге либо ключ [docs] migrations в .av-dev.toml"
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
|
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
|
||||||
@@ -175,7 +176,7 @@ tasks:
|
|||||||
py=$(eval echo {{.DOCS_PY}})
|
py=$(eval echo {{.DOCS_PY}})
|
||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "docs.py не найден: $py"
|
echo "docs.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
|
echo "поставь плагин av-dev либо задай путь: task docs DOCS_PY=<путь>"
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --base {{.BASE}}
|
python3 "$py" check --base {{.BASE}}
|
||||||
@@ -187,7 +188,7 @@ tasks:
|
|||||||
py=$(eval echo {{.TASKS_PY}})
|
py=$(eval echo {{.TASKS_PY}})
|
||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "tasks.py не найден: $py"
|
echo "tasks.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
|
echo "поставь плагин av-dev либо задай путь: task tasks TASKS_PY=<путь>"
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --dir tasks
|
python3 "$py" check --dir tasks
|
||||||
@@ -199,7 +200,7 @@ tasks:
|
|||||||
py=$(eval echo {{.OPENSPEC_PY}})
|
py=$(eval echo {{.OPENSPEC_PY}})
|
||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "openspec.py не найден: $py"
|
echo "openspec.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
echo "поставь плагин av-dev либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --dir .
|
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
|
- **Дата:** 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
|
- **Источник:** [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 живёт нормой в
|
Требование о согласованности объявленной версии 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`, дата — когда решение реально принято.
|
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
|
||||||
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
|
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
|
||||||
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
|
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
|
||||||
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
|
- Записи неизменяемы **в решении**: передумали — новая запись, старой ставится
|
||||||
|
статус. Уточнить прежнюю запись можно только строкой «*Уточнено ГГГГ-ММ-ДД:*» в
|
||||||
|
разделе «Последствия» и только фактом, который решения не меняет, — например
|
||||||
|
действующим адресом того, что решение завело.
|
||||||
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
|
- Активная запись статуса не имеет. Значений два: `заменено на 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-protected-file-behind-session.md) | |
|
||||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.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-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-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 | [Объявленную версию 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 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](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) | |
|
| 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,
|
`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) — пустой прогон воркера, захват
|
- [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` самой спеки;
|
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||||||
@@ -30,11 +36,7 @@
|
|||||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||||
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
|
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
|
||||||
видит всё, что видел прежде аноним;
|
видит всё, что видел прежде аноним.
|
||||||
- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой
|
|
||||||
его версии собирается сервис: одно число версии Go во всех местах, где она
|
|
||||||
названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade`
|
|
||||||
2026-08-12.
|
|
||||||
|
|
||||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
|
|
||||||
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
||||||
|
|
||||||
<!-- канон: поведение → openspec/specs/intake, delivery -->
|
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам -->
|
||||||
|
|
||||||
| Компонент | Где | Что делает |
|
| Компонент | Где | Что делает |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -81,14 +83,15 @@
|
|||||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||||
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
||||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
| Шаги схемы | `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`. Три
|
Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Каждый
|
||||||
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача,
|
переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что
|
||||||
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг,
|
делает задача, исчерпавшая попытки, нормирует
|
||||||
а тот, кто её захватил.
|
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
|
||||||
|
«мертва»».
|
||||||
|
|
||||||
## Внешние границы и форматы
|
## Внешние границы и форматы
|
||||||
|
|
||||||
@@ -111,15 +114,35 @@
|
|||||||
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
||||||
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
||||||
обратный прокси, который публикует HTTP-порт наружу.
|
обратный прокси, который публикует 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), «Настройки с числовым значением»:
|
наружу — [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 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
||||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||||
@@ -132,7 +155,7 @@
|
|||||||
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
|
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
|
||||||
Отдельного оповещения нет.
|
Отдельного оповещения нет.
|
||||||
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
||||||
три воркера опрашивают базу вхолостую с паузой из
|
воркеры опрашивают базу вхолостую с паузой из
|
||||||
[database.md](database.md), «Настройки с числовым значением».
|
[database.md](database.md), «Настройки с числовым значением».
|
||||||
|
|
||||||
## Единые точки проекта
|
## Единые точки проекта
|
||||||
@@ -191,8 +214,11 @@
|
|||||||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||||||
[security.md](security.md).
|
[security.md](security.md).
|
||||||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
|
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
|
||||||
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный
|
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные
|
||||||
потолок проекта — шесть часов, и он взят с запасом, а не замером.
|
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||||||
|
записи живёт в хранилище»; откуда взято число —
|
||||||
|
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||||||
|
записка не узнала».
|
||||||
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
||||||
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
||||||
обрыв начинает загрузку заново.
|
обрыв начинает загрузку заново.
|
||||||
@@ -216,9 +242,11 @@
|
|||||||
конвертер этот случай не проверялся.
|
конвертер этот случай не проверялся.
|
||||||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
|
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
|
||||||
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера
|
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
|
||||||
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что
|
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
|
||||||
это обходится, никто не мерил.
|
паузы, а не замер
|
||||||
|
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
|
||||||
|
нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
|
||||||
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
||||||
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
||||||
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
|
|||||||
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg` —
|
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg` —
|
||||||
предложение с заглавной буквы вместо константной категории.
|
предложение с заглавной буквы вместо константной категории.
|
||||||
|
|
||||||
Из этого перечня закрыты два. Доменные ошибки проверялись приведением типа до
|
Часть перечня закрыта. Доменные ошибки проверялись приведением типа до
|
||||||
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
|
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
|
||||||
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
|
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
|
||||||
держит линтер. Оба места больше не долг, а регрессия.
|
держит линтер. Образец конфига звался `config.dist.toml` до 2026-08-14, задача
|
||||||
|
`config-example-toml`. Эти места больше не долг, а регрессия.
|
||||||
|
|
||||||
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
||||||
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
||||||
@@ -42,16 +43,15 @@ htmx, а здесь решено делать SPA — и перенесённы
|
|||||||
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
|
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
|
||||||
типизированной.
|
типизированной.
|
||||||
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
|
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
|
||||||
`0600`, самодокументируемый `config.dist.toml`, проверка на старте.
|
`0600`, самодокументируемый `config.example.toml`, проверка на старте.
|
||||||
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
|
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
|
||||||
ULID, разбор на входной границе, естественные ключи у деталей.
|
ULID, разбор на входной границе, естественные ключи у деталей.
|
||||||
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
|
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
|
||||||
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
|
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
|
||||||
над `fetch`, показ ошибок и состояний списка.
|
над `fetch`, показ ошибок и состояний списка.
|
||||||
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: лестница
|
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: два круга
|
||||||
механизации, два круга (pre-commit и гейт), перечень правил и подавлений,
|
(pre-commit и гейт), перечень правил и подавлений, порядок заведения нового
|
||||||
порядок заведения нового правила. Про инструменты, а не про то, как писать
|
правила. Про инструменты, а не про то, как писать тесты.
|
||||||
тесты.
|
|
||||||
|
|
||||||
## Что из этого проверяет машина
|
## Что из этого проверяет машина
|
||||||
|
|
||||||
|
|||||||
+49
-20
@@ -4,9 +4,9 @@
|
|||||||
Правила оформления кода (How), не спецификация поведения.
|
Правила оформления кода (How), не спецификация поведения.
|
||||||
|
|
||||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||||
Главные: образец называется `config.dist.toml`, а не `config.example.toml`;
|
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||||
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
|
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые
|
||||||
проверки пустых ключей внутри адаптеров.
|
ключи `[yandex]` ловит конструктор распознавателя.
|
||||||
|
|
||||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||||
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
||||||
@@ -29,13 +29,13 @@
|
|||||||
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
|
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
|
||||||
процесса.
|
процесса.
|
||||||
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
|
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
|
||||||
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный
|
- Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
|
||||||
`config.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`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
||||||
|
|
||||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||||
@@ -75,7 +75,7 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
|||||||
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
|
- **Проверка — по `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`.
|
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||||
владелец — пользователь процесса (`1000:1000`).
|
владелец — пользователь процесса (`1000:1000`).
|
||||||
- В `config.dist.toml` секретные поля — пустые строки.
|
- В `config.example.toml` секретные поля — пустые строки.
|
||||||
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
|
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
|
||||||
строки, и загрузчик их не отличает от настоящего значения.
|
строки, и загрузчик их не отличает от настоящего значения.
|
||||||
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
|
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
|
||||||
отрендеренный файл) — см. «Проверка и остановка на старте».
|
отрендеренный файл) — см. «Проверка и остановка на старте».
|
||||||
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
|
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
|
||||||
|
- **Отказ загрузки настроек не несёт содержимого файла.** Текст такого отказа
|
||||||
|
собирает библиотека разбора, и собирает она его из разбираемого куска:
|
||||||
|
`toml.ParseError` кладёт в сообщение само значение («Invalid float value: %q»).
|
||||||
|
Оборванная кавычка в строке секретного ключа — типовая поломка криво
|
||||||
|
отрендеренного шаблона выкладки — уносит ключ в журнал контейнера целиком, а
|
||||||
|
инвариант «секрет не покидает конфиг» помечен необратимым. Поэтому отказ
|
||||||
|
разбора пересобирается своими словами: путь, строка, столбец и последний ключ,
|
||||||
|
без сообщения библиотеки. Прочие отказы декодера (несовпадение типов,
|
||||||
|
неподдерживаемый тип) собраны из имён ключей и типов, значений в них нет, и их
|
||||||
|
текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
|
||||||
|
платить не за что.
|
||||||
|
|
||||||
## Проверка и остановка на старте
|
## Проверка и остановка на старте
|
||||||
|
|
||||||
@@ -116,10 +127,19 @@ Ansible из `pet-project-server`). Приложение просто читае
|
|||||||
- ключи внешних сервисов не пусты.
|
- ключи внешних сервисов не пусты.
|
||||||
|
|
||||||
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
|
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
|
||||||
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и
|
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
|
||||||
приложение продолжает работу без бота; пустые ключи Yandex ловятся в
|
выходит с кодом 1. Единого места проверки нет.
|
||||||
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
|
|
||||||
места проверки нет.
|
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак
|
||||||
|
включения и ключ доступа, — и проверок у них две. Третий ключ секции,
|
||||||
|
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
|
||||||
|
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
|
||||||
|
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
|
||||||
|
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
|
||||||
|
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true` —
|
||||||
|
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
|
||||||
|
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
|
||||||
|
включения решает, поднимается ли вход Telegram».
|
||||||
|
|
||||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||||
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
||||||
@@ -131,10 +151,19 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
|
|||||||
## Структура в коде
|
## Структура в коде
|
||||||
|
|
||||||
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
||||||
- Одна корневая структура `Config` с под-структурами по секциям. Перечень секций
|
- Одна корневая структура `Config` с под-структурами по секциям. Перечень
|
||||||
и полей здесь не повторяем: источник истины по составу — `config.dist.toml`,
|
секций и полей здесь не повторяем: источник истины по составу —
|
||||||
действующие числа — [../database.md](../database.md), «Настройки с числовым
|
`config.example.toml`, действующие числа — [../database.md](../database.md),
|
||||||
значением». Каталог данных задаётся одним ключом `[storage] data_dir`
|
«Настройки с числовым значением». Каталог данных задаётся одним ключом
|
||||||
|
`[storage] data_dir`
|
||||||
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
|
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
|
||||||
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
||||||
требует правки обоих мест.
|
требует правки обоих мест.
|
||||||
|
- **Обязательное поле — поле, у которого умолчания нет намеренно.** Умолчание у
|
||||||
|
такого поля было бы угаданным намерением, и одна из двух ошибок стала бы
|
||||||
|
тихой. Форма записи: умолчания нет ни в `defaultConfig()` (причина — строкой
|
||||||
|
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
|
||||||
|
судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что
|
||||||
|
значение отличить «не задано» от «задано нулём» не позволяет. В
|
||||||
|
`config.example.toml` у поля стоит значение свежей установки. Первое такое
|
||||||
|
поле — `telegram.enabled`.
|
||||||
|
|||||||
@@ -58,6 +58,11 @@
|
|||||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||||
вставка падает громко. Измерение длительности — не метка времени.
|
вставка падает громко. Измерение длительности — не метка времени.
|
||||||
|
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`,
|
||||||
|
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»).
|
||||||
|
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид
|
||||||
|
хранилища не меняем — сравнение строк в сыром запросе побайтово, и
|
||||||
|
разошедшийся вид молча обращает условие срока захвата в константу.
|
||||||
- Миграции — шаги PocketBase на Go
|
- Миграции — шаги PocketBase на Go
|
||||||
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
|
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
|
||||||
поля заводятся кодом. При изменении структуры обновляем схему
|
поля заводятся кодом. При изменении структуры обновляем схему
|
||||||
|
|||||||
@@ -65,9 +65,15 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
|
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
|
||||||
где хватает sentinel.
|
где хватает sentinel.
|
||||||
|
|
||||||
Сегодня в проекте три типизированные ошибки, и данные несёт только одна:
|
Типизированные ошибки проекта несут данные все до одной:
|
||||||
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
`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): «Типовые узлы» перечисляют свойства, которые тест
|
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
|
||||||
обязан проверять, и там же записано требование, чтобы проверка была **способна
|
обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
|
||||||
упасть**. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
|
|
||||||
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
|
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
|
||||||
исходники.
|
исходники.
|
||||||
|
|
||||||
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
|
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
|
||||||
будет своя запись, а лестница и два круга останутся общими.
|
будет своя запись, а два круга останутся общими.
|
||||||
|
|
||||||
Устройство ниже **переносимо**: разделы «Лестница механизации», «Два круга» и
|
Устройство ниже **переносимо**: разделы «Два круга» и «Как заводят новое
|
||||||
«Как заводят новое правило» — не особенность transcriber и переносятся в другой
|
правило» — не особенность transcriber и переносятся в другой Go-проект как есть.
|
||||||
Go-проект как есть. Своё здесь — перечень правил и подавлений.
|
Своё здесь — перечень правил и подавлений.
|
||||||
|
|
||||||
## Границы: где что живёт
|
## Границы: где что живёт
|
||||||
|
|
||||||
@@ -36,45 +35,17 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
|
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
|
||||||
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
|
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
|
||||||
спрашивать уже не нужно;
|
спрашивать уже не нужно;
|
||||||
- **поведение сервиса** — нормативные спеки `openspec/specs/`. У шага сверки
|
- **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
|
||||||
версий Go поведение нормировано отдельно, спекой
|
проверок туда не входят: инструментарий спеками не нормируется, и спека
|
||||||
[toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка
|
`toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего
|
||||||
проекта, у которой есть своя capability, и потому единственная, чьи сценарии
|
дома у нормы этого шага теперь нет вовсе — она живёт комментариями в
|
||||||
проверяются построчно (`scripts/check_go_version_test.go`). Второй самодельный
|
`scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены
|
||||||
шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах
|
тем же решением. Второй самодельный
|
||||||
|
шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах
|
||||||
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
||||||
него нет, и дрейф его собственного шаблона имени никто не поймает. Это
|
него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
|
||||||
объявленный долг, а не умолчание.
|
не числится: проверок над проверками проект не заводит —
|
||||||
|
[CLAUDE.md](../../CLAUDE.md), «Запреты».
|
||||||
## Лестница механизации
|
|
||||||
|
|
||||||
Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому,
|
|
||||||
чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже
|
|
||||||
в заведении, поэтому прыгать через ступень без нужды не надо.
|
|
||||||
|
|
||||||
1. **Проза конвенции.** Свойство названо словами, проверяет человек на каждом
|
|
||||||
ревью заново. Это ступень по умолчанию и худшая из всех: она стоит внимания
|
|
||||||
каждого прогона и молча перестаёт работать, когда внимание кончилось.
|
|
||||||
2. **Настройка готового линтера.** Свойство совпало с чужим правилом —
|
|
||||||
включается строкой в `.golangci.yml`. Дешевле всего; ограничение в том, что
|
|
||||||
правило чужое и говорит о том, о чём его написали.
|
|
||||||
3. **Запрет по имени** (`forbidigo`, `depguard`). Свойство выражается через «эту
|
|
||||||
функцию/пакет тут звать нельзя». Дешёво и точно, но требует **единой точки**,
|
|
||||||
куда запрещённое переносят: запрет без дома оставляет код без способа сделать
|
|
||||||
нужное.
|
|
||||||
4. **Тест-сканер исходников** (`internal/archrules`). Свойство — о структуре, а
|
|
||||||
не о вызове: направление зависимостей, согласованность двух перечней,
|
|
||||||
отсутствие идиомы. Пишется руками на `go/parser` или регулярном выражении,
|
|
||||||
зато читается как тест и ломается заметно.
|
|
||||||
5. **Свой шаг проверки** (`scripts/`, шаги `Taskfile.yml`). Свойство выходит за
|
|
||||||
пределы кода на Go: версия инструмента, форма `Dockerfile`, раскладка
|
|
||||||
документов. Дороже всех — у шага появляется своя норма и свои тесты.
|
|
||||||
|
|
||||||
Ступень, выбранная неверно, видна сразу. Запрет по имени, обходимый одной
|
|
||||||
лишней строкой, — это ступень 4, наряженная третьей: так было с правилом о
|
|
||||||
заголовках ответа, которое сначала запретило текст `\.Header\(\)\.Get`, а
|
|
||||||
обходилось присваиванием в переменную. Правило переписано на суждение **по типу
|
|
||||||
приёмника** (`analyze-types`), и это уже настоящая третья ступень.
|
|
||||||
|
|
||||||
## Два круга: pre-commit и гейт
|
## Два круга: pre-commit и гейт
|
||||||
|
|
||||||
@@ -120,7 +91,7 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||||
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||||
| Адаптер не знает ни ядра, ни транспортов | `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` — все четыре, иначе запрет обходится соседним именем |
|
| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml` → `forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем |
|
||||||
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml` → `sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
|
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml` → `sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
|
||||||
|
|
||||||
### Проверки о самих проверках
|
### Код проверок и подавления
|
||||||
|
|
||||||
| Правило | Где механизировано |
|
| Правило | Где механизировано |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
| Проверка судит ответ по готовому ответу (`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` |
|
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `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`) |
|
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||||
|
|
||||||
### Форма кода и файлов вне Go
|
### Форма кода и файлов вне Go
|
||||||
@@ -164,8 +134,8 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
|
|
||||||
| Правило | Где механизировано |
|
| Правило | Где механизировано |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` в `docs/.docs.json`, чтобы у факта не было второго дома; пустой каталог роняет шаг — правило, потерявшее предмет, молчать не должно. `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
|
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `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/.docs.json` |
|
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
|
||||||
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
||||||
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
||||||
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
|
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
|
||||||
@@ -196,8 +166,8 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
остаётся то, чему нет ни готового правила, ни детерминированного оракула:
|
остаётся то, чему нет ни готового правила, ни детерминированного оракула:
|
||||||
уровень лога по адресату, единая логирующая точка на доменной границе, словарь
|
уровень лога по адресату, единая логирующая точка на доменной границе, словарь
|
||||||
имён полей, канонический вид идентификатора, естественные ключи у деталей.
|
имён полей, канонический вид идентификатора, естественные ключи у деталей.
|
||||||
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново — это и
|
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново, и правило,
|
||||||
есть первая ступень лестницы, и подъём с неё всегда выигрыш.
|
снявшее с него эту работу, всегда выигрыш.
|
||||||
|
|
||||||
Названы поимённо и **остатки правил** — то, что правило не ловит и потому
|
Названы поимённо и **остатки правил** — то, что правило не ловит и потому
|
||||||
осталось человеку:
|
осталось человеку:
|
||||||
@@ -239,8 +209,9 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
|
|
||||||
Порядок один и тот же, и последние два шага пропускать нельзя.
|
Порядок один и тот же, и последние два шага пропускать нельзя.
|
||||||
|
|
||||||
1. **Найти дом.** Ступень лестницы выбирается по тому, чем свойство
|
1. **Найти дом.** Дом выбирается по тому, чем свойство выражается, а не по тому,
|
||||||
выражается, а не по тому, что проще включить.
|
что проще включить: настройка готового линтера, запрет по имени, тест-сканер
|
||||||
|
исходников или свой шаг набора проверок.
|
||||||
2. **Написать причину рядом.** Правило без причины снимают при первом же
|
2. **Написать причину рядом.** Правило без причины снимают при первом же
|
||||||
неудобстве: тот, кто снимает, не знает, что оно ловило.
|
неудобстве: тот, кто снимает, не знает, что оно ловило.
|
||||||
3. **Починить находки, а не подавить.** Подавление годится, когда правило
|
3. **Починить находки, а не подавить.** Подавление годится, когда правило
|
||||||
|
|||||||
@@ -163,8 +163,7 @@ log := log.With("job_id", job.Id, "capability", "conversion")
|
|||||||
|
|
||||||
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
|
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
|
||||||
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
|
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
|
||||||
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf`
|
записи.
|
||||||
мимо `slog` целиком.
|
|
||||||
|
|
||||||
## Внешние сервисы: логируем все вызовы
|
## Внешние сервисы: логируем все вызовы
|
||||||
|
|
||||||
@@ -241,9 +240,9 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
|||||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||||
|
|
||||||
Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы —
|
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы —
|
||||||
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
||||||
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из пяти:
|
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех:
|
||||||
|
|
||||||
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
||||||
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
||||||
|
|||||||
@@ -33,11 +33,13 @@
|
|||||||
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
|
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
|
||||||
выполнения нет.
|
выполнения нет.
|
||||||
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
|
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
|
||||||
цели [web-access](../../tasks/items/web-access.md). Без сети приложение
|
из [паспорта](../passport.md). Без сети приложение показывает состояние, а не
|
||||||
показывает состояние, а не пустой экран.
|
пустой экран.
|
||||||
- **Web Push не делаем**: уведомления идут через apprise и ntfy, цель
|
- **Web Push не делаем**: уведомления идут через apprise и ntfy — решение живёт
|
||||||
[ready-notification](../../tasks/items/ready-notification.md).
|
в [architecture.md](../architecture.md), «Уведомления», делает его
|
||||||
- **Записи звука в приложении не делаем** — файл выбирают системным диалогом.
|
[ntfy-delivery](../../tasks/items/ntfy-delivery.md).
|
||||||
|
- **Записи звука в приложении не делаем** — граница из
|
||||||
|
[паспорта](../passport.md), «Диктофон»; файл выбирают системным диалогом.
|
||||||
|
|
||||||
## Фреймворк и сборка
|
## Фреймворк и сборка
|
||||||
|
|
||||||
@@ -55,9 +57,12 @@
|
|||||||
|
|
||||||
## Маршруты
|
## Маршруты
|
||||||
|
|
||||||
- **Четыре экрана, одна таблица маршрутов** через `createRouter`. Маршруты по
|
- **Одна таблица маршрутов** через `createRouter`. Маршруты по файлам не
|
||||||
файлам не включаем: сборочная надстройка роутера пятой версии стоит 34 пакета
|
включаем: сборочная надстройка роутера пятой версии стоит 34 пакета в
|
||||||
в установке и на четырёх маршрутах не окупается.
|
установке и на нашем числе маршрутов не окупается
|
||||||
|
([research/spa-framework.md](../research/spa-framework.md), «Vue»). Сколько
|
||||||
|
экранов и какие — не здесь: состав нормирует спека приложения, а до неё его
|
||||||
|
держит [spa-skeleton](../../tasks/items/spa-skeleton.md).
|
||||||
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
||||||
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`;
|
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`;
|
||||||
пути внутри `/api/` в приложение не проваливаются никогда.
|
пути внутри `/api/` в приложение не проваливаются никогда.
|
||||||
@@ -78,6 +83,9 @@
|
|||||||
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
|
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
|
||||||
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
|
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
|
||||||
не `Response`.
|
не `Response`.
|
||||||
|
- **Сессия живёт кукой `transcriber_session`**, и приложение её не читает: кука
|
||||||
|
`HttpOnly`, браузер шлёт её сам, а вошедшего экран узнаёт по ответу API. Норма
|
||||||
|
— [access](../../openspec/specs/access/spec.md).
|
||||||
|
|
||||||
## Показ ошибок и состояний
|
## Показ ошибок и состояний
|
||||||
|
|
||||||
@@ -100,5 +108,5 @@
|
|||||||
узнала»).
|
узнала»).
|
||||||
- **Устройство service worker и версионирование статики** — задача
|
- **Устройство service worker и версионирование статики** — задача
|
||||||
[installable-pwa](../../tasks/items/installable-pwa.md).
|
[installable-pwa](../../tasks/items/installable-pwa.md).
|
||||||
- **Где живёт сессия и как приложение узнаёт вошедшего** — открытый вопрос
|
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос
|
||||||
«Учётные записи» в [../architecture.md](../architecture.md).
|
«Учётные записи» в [../architecture.md](../architecture.md).
|
||||||
|
|||||||
+13
-10
@@ -16,8 +16,8 @@ CGO сборке не нужен.
|
|||||||
|
|
||||||
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
|
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
|
||||||
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
|
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
|
||||||
пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на
|
пути**, а префикс наводится только на каталог. Где этот префикс задан —
|
||||||
каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
|
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
|
||||||
репозитория берёт их оттуда.
|
репозитория берёт их оттуда.
|
||||||
|
|
||||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
||||||
@@ -39,7 +39,7 @@ CGO сборке не нужен.
|
|||||||
### `files`
|
### `files`
|
||||||
|
|
||||||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
||||||
Object Storage — три разные записи.
|
Object Storage — каждая своей записью.
|
||||||
|
|
||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -78,10 +78,10 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
|
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
|
||||||
этот один.
|
этот один.
|
||||||
|
|
||||||
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит
|
**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует
|
||||||
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого
|
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
|
||||||
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
|
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
|
||||||
сам: это делает тот, кто захватил задачу с превышенным счётчиком.
|
потребует нового шага.
|
||||||
|
|
||||||
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
|
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
|
||||||
может только владелец панели. Проверено прогоном: анонимный запрос к
|
может только владелец панели. Проверено прогоном: анонимный запрос к
|
||||||
@@ -118,8 +118,10 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||||
невоспроизводим.
|
невоспроизводим.
|
||||||
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время
|
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
||||||
работы достался другому, завершается без записи и без ответа отправителю.
|
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
||||||
|
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
||||||
|
что условие проверяется тем же запросом, что и сам захват.
|
||||||
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
||||||
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
||||||
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
||||||
@@ -145,10 +147,11 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
|
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
|
||||||
|
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
|
||||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
| Потолок размера одной записи | 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` | дольше носитель состояния не нужен |
|
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||||
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,7 +1,7 @@
|
|||||||
# Паспорт проекта
|
# Паспорт проекта
|
||||||
|
|
||||||
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
||||||
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «в каком порядке», паспорт —
|
устроено», [tasks/BACKLOG.md](../tasks/BACKLOG.md) — «в каком порядке», паспорт —
|
||||||
«зачем и для кого».
|
«зачем и для кого».
|
||||||
|
|
||||||
## Цель
|
## Цель
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||||
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
||||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только кука, снятая из браузера. Токен приносит `api-tokens` |
|
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
|
||||||
|
|
||||||
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
||||||
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
|
|
||||||
- запись любого распространённого формата принимается без предварительной
|
- запись любого распространённого формата принимается без предварительной
|
||||||
подготовки, включая дорожку из видео;
|
подготовки, включая дорожку из видео;
|
||||||
- запись длиной до шести часов доходит до текста, а не прерывается ошибкой при
|
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается
|
||||||
достижении предела;
|
ошибкой при достижении предела (норма — `openspec/specs/storage`);
|
||||||
- сервисом пользуются несколько человек, и записи одного не видны другому;
|
- сервисом пользуются несколько человек, и записи одного не видны другому;
|
||||||
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
|
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
|
||||||
узнаёт о его готовности, не держа приложение открытым;
|
узнаёт о его готовности, не держа приложение открытым;
|
||||||
@@ -54,7 +54,8 @@
|
|||||||
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
|
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
|
||||||
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
|
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
|
||||||
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
|
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-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
|
||||||
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
||||||
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
|
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
|
||||||
|
|||||||
@@ -49,8 +49,9 @@
|
|||||||
|
|
||||||
## Захват чинится одним запросом
|
## Захват чинится одним запросом
|
||||||
|
|
||||||
Сегодняшний захват — два запроса подряд без транзакции
|
Захват **на момент замера** — два запроса подряд без транзакции; после перехода
|
||||||
([../database.md](../database.md), «Представление данных»). Замер показал, что
|
на PocketBase он свернулся в один с `RETURNING` —
|
||||||
|
[../database.md](../database.md), «Представление данных». Замер показал, что
|
||||||
после перехода на PocketBase он сворачивается в один: движок за
|
после перехода на PocketBase он сворачивается в один: движок за
|
||||||
`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх
|
`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 записано
|
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
|
||||||
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
|
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
|
||||||
|
|
||||||
|
*Уточнено 2026-08-12:* перевод состоялся, и требования CGO в стеке больше нет —
|
||||||
|
[../../CLAUDE.md](../../CLAUDE.md), «Стек»: компилятор C нужен только детектору
|
||||||
|
гонок в гейте.
|
||||||
|
|
||||||
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
|
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
|
||||||
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
|
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
|
||||||
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
|
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
|
||||||
@@ -101,9 +105,9 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
|||||||
|
|
||||||
## Вход через OIDC: что выяснилось при реализации
|
## Вход через OIDC: что выяснилось при реализации
|
||||||
|
|
||||||
Дописано 2026-08-12 задачей `oidc-login`. Провенанс общий: чтение исходников
|
Дописано 2026-08-12 задачей `oidc-login`. Все находки ниже получены одним
|
||||||
`pocketbase@v0.39.10` из кеша модулей плюс прогоны против настоящего хранилища на
|
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
|
||||||
временном каталоге, все — в ходе ревью того change. Живой Authelia в прогонах не
|
против настоящего хранилища на временном каталоге — в ходе ревью того change. Живой Authelia в прогонах не
|
||||||
было ни разу: провайдера подменял свой `httptest`-сервер.
|
было ни разу: провайдера подменял свой `httptest`-сервер.
|
||||||
|
|
||||||
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
|
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ Nuxt, Next — не рассматривали: конвенция
|
|||||||
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
|
правил, и в сборке он у всех троих совпал до байта (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.
|
`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 в
|
- **Размеры** — `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, на изменении
|
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/` — под именем
|
||||||
`fix-http-handler-tests`; его триаж лежит в
|
`triage.md` либо `report.md`: имя менялось по ходу, и оба встречаются. Самый
|
||||||
`openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.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). Вопросы ниже — то, чего
|
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
|
||||||
@@ -68,22 +91,11 @@
|
|||||||
|
|
||||||
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
||||||
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
||||||
становится неотличим от привычного шума (журнал, запись 2026-08-10);
|
становится неотличим от привычного шума (журнал, запись 2026-08-10).
|
||||||
- проверка **способна упасть**. Утверждение, разбирающее ответ в ту же
|
|
||||||
структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним
|
Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
|
||||||
и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется
|
мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
|
||||||
мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал,
|
границы — [CLAUDE.md](../CLAUDE.md), «Запреты».
|
||||||
запись 2026-08-11);
|
|
||||||
- **то же и об оракуле критерия приёмки, не только о тесте.** Критерий, чей
|
|
||||||
единственный оракул — молчание линтера, годится ровно тогда, когда линтер
|
|
||||||
краснеет на **всех** негодных реализациях; проверяется той же мутацией.
|
|
||||||
Прецедент: «отказ `Close` не теряется молча» принимался молчанием `errcheck`,
|
|
||||||
а тот пропускал `_ = conn.Close()` — реализацию, теряющую отказ целиком
|
|
||||||
(журнал, запись 2026-08-11 про недостижимую норму; закрыто
|
|
||||||
[решением](adr/ADR-2026-08-11-errcheck-check-blank.md));
|
|
||||||
- **требование без сценария не имеет оракула** и потому не может быть нарушено
|
|
||||||
заметно. Норма, которую нечем уронить, расходится с кодом молча — и расходится
|
|
||||||
тем вернее, чем убедительнее написана (журнал, запись 2026-08-11).
|
|
||||||
|
|
||||||
### Типовые ложноположительные
|
### Типовые ложноположительные
|
||||||
|
|
||||||
@@ -114,7 +126,7 @@
|
|||||||
|
|
||||||
### Вопросы по темам
|
### Вопросы по темам
|
||||||
|
|
||||||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
Форма: `<тема>: <вопрос> (<откуда>)`.
|
||||||
|
|
||||||
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||||
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||||
@@ -122,7 +134,7 @@
|
|||||||
собеседник»), а исход прерванного шага нормой по-прежнему не описан
|
собеседник»), а исход прерванного шага нормой по-прежнему не описан
|
||||||
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
|
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
|
||||||
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||||||
`transcribe.go`, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с
|
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
|
||||||
дефектом «остановка хоронила запись»).
|
дефектом «остановка хоронила запись»).
|
||||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||||||
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||||
@@ -144,13 +156,16 @@
|
|||||||
`createTranscribeJob` — сегодня через него идут оба входа
|
`createTranscribeJob` — сегодня через него идут оба входа
|
||||||
([architecture.md](architecture.md), «Единые точки проекта»).
|
([architecture.md](architecture.md), «Единые точки проекта»).
|
||||||
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
||||||
заведены две capability (`openspec/specs/intake` и `openspec/specs/pipeline`),
|
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
|
||||||
и каждая описана частично. Поведение прочих узлов живёт в обзоре под маркерами
|
первые две описаны частично. Поведение прочих узлов, включая
|
||||||
долга, а соблазн дописать туда ещё — самый большой.
|
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
|
||||||
|
ещё — самый большой.
|
||||||
- `conventions`: новая колонка правится во всех четырёх местах репозитория
|
- `conventions`: новая колонка правится во всех четырёх местах репозитория
|
||||||
(CLAUDE.md, «Инварианты»).
|
(CLAUDE.md, «Инварианты»).
|
||||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
|
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
|
||||||
тестов два файла, и оба мимо конвейера.
|
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
|
||||||
|
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
|
||||||
|
числа файлов здесь не называем — оно протухает с каждой задачей.
|
||||||
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
|
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
|
||||||
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
|
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
|
||||||
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
|
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
|
||||||
@@ -186,8 +201,10 @@
|
|||||||
|
|
||||||
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
||||||
пользователь приложения, до начала работы назвать нельзя;
|
пользователь приложения, до начала работы назвать нельзя;
|
||||||
- всё, что делается на выбранном фреймворке впервые: форма решения нащупывается
|
- всё, что делается на выбранном фреймворке впервые: правила
|
||||||
по ходу, пока конвенция веб-UI пуста;
|
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
|
||||||
|
на пробном экране, а не из написанного кода, и первая же задача проверяет их
|
||||||
|
собой — форма решения нащупывается по ходу;
|
||||||
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
|
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
|
||||||
до работы назвать нельзя;
|
до работы назвать нельзя;
|
||||||
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
||||||
@@ -199,7 +216,7 @@
|
|||||||
|
|
||||||
- правка текста, который видит пользователь Telegram;
|
- правка текста, который видит пользователь Telegram;
|
||||||
- новая метрика в `internal/metrics`;
|
- новая метрика в `internal/metrics`;
|
||||||
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
|
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
|
||||||
- правка документов канона.
|
- правка документов канона.
|
||||||
|
|
||||||
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
|
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
|
||||||
@@ -231,20 +248,60 @@ API и имя не откатываются обратной правкой по
|
|||||||
длительность от подставного источника. Своего теста у
|
длительность от подставного источника. Своего теста у
|
||||||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||||||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
||||||
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер
|
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||||||
Telegram: он проверяет токен обращением к Telegram, а боевым токеном
|
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
|
||||||
запускаться запрещено. Значит поведенческая верификация живым прогоном
|
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
|
||||||
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера
|
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
|
||||||
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока
|
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
|
||||||
нет.
|
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона
|
||||||
|
сменился с пустого ключа доступа на выключенный вход задачей
|
||||||
|
`telegram-enabled-flag` того же дня.
|
||||||
|
|
||||||
|
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
|
||||||
|
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
|
||||||
|
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
|
||||||
|
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
|
||||||
|
расшифровку и заливку.
|
||||||
|
|
||||||
## Журнал дефектов
|
## Журнал дефектов
|
||||||
|
|
||||||
Верхняя запись найдена конвейером ревью на первом же его прогоне, вторая —
|
Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
|
||||||
прогоном гейта при заведении канона 2026-08-10, две нижние восстановлены по
|
`[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
|
||||||
истории git тогда же. Три нижние помечены `проскочил`: ревью тогда не было, и
|
уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
|
||||||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
истории 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 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
|
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
|
||||||
|
|
||||||
@@ -297,7 +354,7 @@ API и имя не откатываются обратной правкой по
|
|||||||
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
|
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
|
||||||
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
|
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
|
||||||
машина прозу не проверяет
|
машина прозу не проверяет
|
||||||
- **Что меняем:** чистка перенесена с места употребления на **границу клиента** —
|
- **Что меняем:** чистку перенесли с места употребления на **границу клиента** —
|
||||||
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
|
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
|
||||||
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
|
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
|
||||||
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
|
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
|
||||||
@@ -351,9 +408,10 @@ API и имя не откатываются обратной правкой по
|
|||||||
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
|
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
|
||||||
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
|
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
|
||||||
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
|
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
|
||||||
проверено мутацией по каждой. Отсюда же строка в
|
проверено мутацией по каждой. Урок записи: запрет по имени, обходимый лишней
|
||||||
docs/conventions/go-linters.md, «Лестница механизации»: запрет по имени, обходимый лишней строкой, — это ступень
|
строкой, свойства не держит — такому свойству нужен тест-сканер. Строка об этом
|
||||||
тест-сканера, наряженная запретом
|
стояла в `docs/conventions/go-linters.md`, разделе «Лестница механизации»;
|
||||||
|
раздел снят 2026-08-13, урок остался здесь
|
||||||
|
|
||||||
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
|
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
|
||||||
|
|
||||||
@@ -465,7 +523,9 @@ API и имя не откатываются обратной правкой по
|
|||||||
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
|
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
|
||||||
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
|
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
|
||||||
согласованно, и парная сверка не увидела бы документ, разошедшийся с
|
согласованно, и парная сверка не увидела бы документ, разошедшийся с
|
||||||
согласованным кодом. Норма — capability `toolchain`.
|
согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain`
|
||||||
|
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
|
||||||
|
комментариями в самом скрипте.
|
||||||
|
|
||||||
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
||||||
|
|
||||||
|
|||||||
+35
-10
@@ -122,8 +122,10 @@ Telegram отправителю.
|
|||||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
||||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
||||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
||||||
есть доступны они только владельцу панели; проверено прогоном — записи отдают
|
есть доступны они только владельцу панели; коды, снятые прогоном, —
|
||||||
`403`, служебные разделы `401`.
|
[database.md](database.md), «Коллекции», норма —
|
||||||
|
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
|
||||||
|
то, что заказано».
|
||||||
|
|
||||||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||||||
|
|
||||||
@@ -143,9 +145,10 @@ Telegram отправителю.
|
|||||||
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
||||||
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
||||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||||
кукой `transcriber_session`, живёт семь суток, обесценивается выходом.
|
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
|
||||||
|
([database.md](database.md), «Настройки с числовым значением»).
|
||||||
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
||||||
бессрочно, и семисуточный срок — единственное, чем отзыв доступа у провайдера
|
бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
|
||||||
доходит до сервиса, — не значил бы ничего.
|
доходит до сервиса, — не значил бы ничего.
|
||||||
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
||||||
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
||||||
@@ -271,13 +274,34 @@ Telegram отправителю.
|
|||||||
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
|
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
|
||||||
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
|
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
|
||||||
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
|
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
|
||||||
скачивания и отправки пишутся в журнал. Теперь адрес снимается на границе
|
скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает
|
||||||
клиента — `internal/adapter/telegram`, `NewBot`: свой `Do` чистит отказ, а
|
свой `Do` — `internal/adapter/telegram`, `NewBot`: он чистит отказ, а
|
||||||
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
|
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
|
||||||
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
|
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
|
||||||
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
|
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
|
||||||
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
|
случай — [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)).
|
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
|
||||||
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
|
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
|
||||||
нет и не будет: решено считать расход и показывать его владельцу, а не
|
нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход
|
||||||
отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда
|
||||||
Authelia. Рост каталога данных при этом ничем не наблюдается —
|
следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и
|
||||||
открытый вопрос `architecture.md`.
|
защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом
|
||||||
|
ничем не наблюдается — открытый вопрос `architecture.md`.
|
||||||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||||||
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
||||||
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
|
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module git.vakhrushev.me/av/transcriber
|
module git.vakhrushev.me/av/transcriber
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
@@ -49,6 +49,7 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // 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-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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
|
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/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrEmptyToken — токен бота не задан. Отдельным значением, потому что подъём
|
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
|
||||||
// без Telegram — законный исход: сервис продолжает работать с HTTP API.
|
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
|
||||||
|
// недоступности Telegram, у которой исход обратный — подъём без бота.
|
||||||
|
//
|
||||||
|
// Отказ от входа Telegram этим значением больше не выражается: намерение
|
||||||
|
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
|
||||||
|
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
|
||||||
|
// поэтому сюда он доходит только в обход проверки.
|
||||||
var ErrEmptyToken = errors.New("telegram bot token is empty")
|
var ErrEmptyToken = errors.New("telegram bot token is empty")
|
||||||
|
|
||||||
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
|
// 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 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 — клиент, чей отказ не несёт адреса. Библиотека объявляет
|
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
|
||||||
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
|
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
|
||||||
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
|
// нетронутым, поэтому чистка отсюда доходит до каждого вызова 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`,
|
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
|
||||||
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
|
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
|
||||||
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
|
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
|
||||||
@@ -92,10 +113,21 @@ func TestLibraryLoggerRedactsToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
|
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
|
||||||
|
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
|
||||||
|
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
|
||||||
|
// отказ, и молча потеряет бота.
|
||||||
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
|
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
|
||||||
_, err := NewBot("", slog.New(slog.DiscardHandler))
|
_, err := NewBot("", slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
require.ErrorIs(t, err, ErrEmptyToken)
|
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` по цепочке продолжает
|
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
|
||||||
|
|||||||
@@ -15,18 +15,15 @@ type TelegramMessageSender struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
|
// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
|
||||||
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ
|
// заводит сборка при старте — одного на отправителя и на транспорт бота: пока
|
||||||
// конструктора несёт — `NewBotAPI` зовёт `getMe`.
|
// его строили здесь и там порознь, два пути одного старта разошлись в том,
|
||||||
bot, err := NewBot(botToken, logger)
|
// терпеть ли негодный токен, и согласовывать их приходилось руками.
|
||||||
if err != nil {
|
func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TelegramMessageSender{
|
return &TelegramMessageSender{
|
||||||
bot: bot,
|
bot: bot,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
|
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -41,11 +42,33 @@ type YandexConfig struct {
|
|||||||
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
|
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
|
||||||
|
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
|
||||||
|
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
|
||||||
|
// и сервис поднимался без бота в обоих случаях.
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
|
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
|
||||||
|
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
|
||||||
|
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
|
||||||
|
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
BotToken string `toml:"bot_token"`
|
BotToken string `toml:"bot_token"`
|
||||||
UpdateTimeout int `toml:"update_timeout"`
|
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. Адреса, идентификатор
|
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
|
||||||
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
||||||
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
||||||
@@ -129,6 +152,7 @@ func defaultConfig() *Config {
|
|||||||
ObjStorageRegion: "ru-central1",
|
ObjStorageRegion: "ru-central1",
|
||||||
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
|
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
|
||||||
},
|
},
|
||||||
|
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
|
||||||
Telegram: TelegramConfig{
|
Telegram: TelegramConfig{
|
||||||
BotToken: "",
|
BotToken: "",
|
||||||
UpdateTimeout: 10,
|
UpdateTimeout: 10,
|
||||||
@@ -149,9 +173,48 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
config := defaultConfig()
|
config := defaultConfig()
|
||||||
|
|
||||||
// Load configuration from file
|
// Load configuration from file
|
||||||
if _, err := toml.DecodeFile(path, &config); err != nil {
|
meta, err := toml.DecodeFile(path, &config)
|
||||||
return nil, fmt.Errorf("failed to decode config file: %w", err)
|
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
|
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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
package contract
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят.
|
||||||
|
// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не
|
||||||
|
// настроен.
|
||||||
|
//
|
||||||
|
// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние,
|
||||||
|
// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата,
|
||||||
|
// и запись о недоставке делает шаг, у которого задача под рукой.
|
||||||
|
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
|
||||||
|
|
||||||
type JobNotFoundError struct {
|
type JobNotFoundError struct {
|
||||||
State string
|
State string
|
||||||
|
|||||||
@@ -328,9 +328,3 @@ func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool {
|
|||||||
|
|
||||||
return false
|
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"},
|
[]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(
|
OutputFileSizeHistogram = promauto.NewHistogramVec(
|
||||||
prometheus.HistogramOpts{
|
prometheus.HistogramOpts{
|
||||||
|
|||||||
@@ -551,19 +551,43 @@ func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jo
|
|||||||
return s.send(job, errorMessage)
|
return s.send(job, errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// send отвечает отправителю там, откуда пришла запись, и отказ отправки
|
// send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает
|
||||||
// поднимает вверх: он принадлежит шагу.
|
// вверх: он принадлежит шагу.
|
||||||
|
//
|
||||||
|
// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит
|
||||||
|
// после того, как достигнутое состояние сохранено: работа к этой минуте
|
||||||
|
// сделана, и объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу
|
||||||
|
// записью отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не
|
||||||
|
// появятся, — поэтому причина недоставки живёт в журнале, а не в состоянии
|
||||||
|
// задачи.
|
||||||
|
//
|
||||||
|
// Служебные поля завершённой задачи отказ бы при этом не переписал: переход в
|
||||||
|
// терминальное состояние снимает захват, и повторная запись натыкается на
|
||||||
|
// «захват потерян». Довод держится на счётчике и журнале, а не на этом.
|
||||||
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
|
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
|
||||||
if job.Source != entity.SourceTelegram {
|
if job.Source != entity.SourceTelegram {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Адресата у задачи нет: отвечать некуда, и повторять нечего. Уровень здесь
|
||||||
|
// выше, чем у неподнятого канала, и это не педантизм: пустой чат у задачи
|
||||||
|
// из Telegram — симптом порчи записи, а самый коварный её источник назван
|
||||||
|
// инвариантом «колонки очереди правятся в четырёх местах». Утони этот
|
||||||
|
// сигнал в одном ряду со штатным «бот не настроен» — и обнуление колонки
|
||||||
|
// заметит только отправитель, переставший получать ответы.
|
||||||
if job.TgChatId == nil {
|
if job.TgChatId == nil {
|
||||||
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
|
s.undelivered(job, slog.LevelError, "chat is not specified")
|
||||||
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != 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)
|
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)
|
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
|
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 отвечает отправителю там, где поднимать отказ некуда: задача уже
|
// notify отвечает отправителю там, где поднимать отказ некуда: задача уже
|
||||||
// доведена до конца, и отказ отправки остаётся записью в журнале владельца.
|
// доведена до конца, и отказ отправки остаётся записью в журнале владельца.
|
||||||
func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) {
|
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"
|
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
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/config"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
||||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||||
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
@@ -60,6 +59,14 @@ func main() {
|
|||||||
os.Exit(1)
|
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 файла
|
// Загружаем переменные окружения из .env файла
|
||||||
if err := godotenv.Load(); err != nil {
|
if err := godotenv.Load(); err != nil {
|
||||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||||
@@ -89,9 +96,9 @@ func main() {
|
|||||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||||
converter := ffmpegconv.NewFfmpegConverter()
|
converter := ffmpegconv.NewFfmpegConverter()
|
||||||
|
|
||||||
tgSender, err := telegram.NewTelegramMessageSender(cfg.Telegram.BotToken, logger)
|
tgBot, tgSender, err := buildTelegram(cfg.Telegram, logger)
|
||||||
if err != nil {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +148,17 @@ func main() {
|
|||||||
UserWhiteList: cfg.Server.UsersWhiteList,
|
UserWhiteList: cfg.Server.UsersWhiteList,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Клиента бота заводит единая точка: её отказ не несёт токена, тогда как
|
// Транспорт поднимается только там, где есть клиент: о том, что бота нет,
|
||||||
// отказ `NewBotAPI` несёт — он ходит за `getMe`.
|
// сказано выше единственной записью, и вторая здесь была бы записью о том
|
||||||
tgController, err := newTelegramController(cfg.Telegram.BotToken, tgConfig, transcribeService, jobRepo, logger)
|
// же факте.
|
||||||
|
var tgController *tgcontroller.TelegramController
|
||||||
|
if tgBot != nil {
|
||||||
|
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to create Telegram controller", "error", err)
|
logger.Error("Failed to create Telegram controller", "error", err)
|
||||||
// Не останавливаем приложение, если Telegram бот не создан
|
os.Exit(1)
|
||||||
} else {
|
}
|
||||||
|
|
||||||
// Запускаем Telegram бот в отдельной горутине
|
// Запускаем Telegram бот в отдельной горутине
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -179,6 +190,11 @@ func main() {
|
|||||||
}(w)
|
}(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
|
||||||
|
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
|
||||||
|
// обоих входах одним отбором.
|
||||||
|
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
|
||||||
|
|
||||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||||
// и второму серверу на нём взяться неоткуда.
|
// и второму серверу на нём взяться неоткуда.
|
||||||
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
|
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
|
||||||
@@ -328,21 +344,3 @@ func main() {
|
|||||||
|
|
||||||
logger.Info("Transcriber service stopped")
|
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/architecture.md — устройство; docs/security.md — периметр;
|
||||||
docs/database.md — схема и настройки с числами; docs/adr/ — почему решено
|
docs/database.md — схема и настройки с числами; docs/adr/ — почему решено
|
||||||
так; docs/research/ — что уже измерено;
|
так; 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/. Ни состав шагов гейта, ни перечень конвенций здесь не
|
docs/conventions/. Ни состав шагов гейта, ни перечень конвенций здесь не
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
Приём записи и опрос готовности задачи расшифровки: что считается принятой
|
Приём записи и опрос готовности задачи расшифровки: что считается принятой
|
||||||
записью, что уезжает в ответ и что происходит, когда запись не удалось
|
записью, что уезжает в ответ и что происходит, когда запись не удалось
|
||||||
прочитать.
|
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
|
||||||
|
|
||||||
Описан пока **только приём по HTTP** — тот, что нормируют проверки. Приём из
|
Приём по существу описан пока **только для HTTP** — того, что нормируют
|
||||||
Telegram делит с ним общий шаг заведения задачи, но требований на него нет:
|
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
|
||||||
требование, написанное без проверки, — предположение, а не норма. Первая задача,
|
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
|
||||||
которая трогает поведение приёма из Telegram, дописывает его сюда.
|
требованиями по-прежнему не описано — требование, написанное без проверки, это
|
||||||
|
предположение, а не норма. Первая задача, которая трогает поведение приёма из
|
||||||
|
Telegram, дописывает его сюда.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Приём записи по HTTP
|
### Requirement: Приём записи по HTTP
|
||||||
|
|
||||||
@@ -256,3 +258,120 @@ Telegram делит с ним общий шаг заведения задачи,
|
|||||||
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
- **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
|
## Purpose
|
||||||
|
|
||||||
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
|
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
|
||||||
когда работы нет, и что считается отказом шага.
|
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
|
||||||
|
когда доставить его некуда.
|
||||||
|
|
||||||
|
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
|
||||||
|
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
|
||||||
|
результата держателем захвата и недоставка ответа при неподнятом входе.
|
||||||
|
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
|
||||||
|
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
|
||||||
|
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
|
||||||
|
требования на него не написаны, потому что требование без проверки —
|
||||||
|
предположение, а не норма. Первая задача, которая трогает любое из
|
||||||
|
перечисленного, дописывает его сюда.
|
||||||
|
|
||||||
Описан пока **только пустой прогон воркера** — тот, что нормируют проверки
|
|
||||||
пакета `internal/controller/worker` и перевод признака в `internal/service`.
|
|
||||||
Сознательно не описаны переходы состояний и цепочка `created → converted →
|
|
||||||
transcribe → done | failed`, захват задачи и срок его протухания, отмена
|
|
||||||
контекста посреди шага, освобождение ресурсов внешних клиентов. Это не значит,
|
|
||||||
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
|
|
||||||
потому что требование без проверки — предположение, а не норма. Первая задача,
|
|
||||||
которая трогает любое из перечисленного, дописывает его сюда.
|
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Пустой прогон воркера — не отказ
|
### Requirement: Пустой прогон воркера — не отказ
|
||||||
|
|
||||||
@@ -22,9 +25,9 @@ transcribe → done | failed`, захват задачи и срок его пр
|
|||||||
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
|
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
|
||||||
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
|
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
|
||||||
|
|
||||||
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: три воркера
|
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
|
||||||
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт три
|
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
|
||||||
записи отказа в секунду и столько же засчитанных сбоев, которых не было.
|
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
|
||||||
|
|
||||||
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
|
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
|
||||||
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
|
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
|
||||||
@@ -260,3 +263,65 @@ MUST расти с числом её попыток до объявленног
|
|||||||
- **THEN** задержка до следующей проверки каждый раз одна и та же
|
- **THEN** задержка до следующей проверки каждый раз одна и та же
|
||||||
- **AND** число попыток задачи не растёт
|
- **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`
|
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
|
||||||
+ строка здесь. Целей тут нет — они в [ROADMAP.md](ROADMAP.md): беклог — то, что берут,
|
+ строка здесь. Ведётся скиллом `av-dev:task-track`.
|
||||||
роадмап — то, подо что берут. **Порядок строк значим:**
|
|
||||||
это очередь, и первая строка — то, что делают следующим. Порядок
|
|
||||||
назначает человек на груминге, машина его не выводит. Одно исключение
|
|
||||||
производно от типа — сырьё (`research` без раздела «Вопрос»)
|
|
||||||
стоит в конце: его не берут. Ведётся скиллом `tasks`.
|
|
||||||
|
|
||||||
Секция одна — полок домена у проекта нет, и делить очередь на две
|
<!-- стадия -->
|
||||||
значило бы держать два порядка вместо одного.
|
Стадия проекта — **стройка** (`[tasks] stage = "build"`).
|
||||||
|
**Порядок строк — зависимость:** это план стройки от базы к деталям,
|
||||||
|
и строка выше сделана раньше не потому, что важнее, а потому, что
|
||||||
|
иначе нельзя. Секция здесь **одна**: разложенный по полкам план
|
||||||
|
перестаёт быть планом. Список пишется вперёд целиком — это не
|
||||||
|
гниение беклога, а замысел. Пустой беклог значит, что стройка
|
||||||
|
окончена: дальше `tasks.py stage support`.
|
||||||
|
<!-- /стадия -->
|
||||||
|
|
||||||
**Чем очередь упорядочена на этом этапе — от базы к деталям.**
|
**Чем основание отличается от детали в этом проекте.** Сначала идёт то,
|
||||||
Сначала то, на чём стоит остальное: проверки, которым можно верить,
|
на чём стоит остальное: проверки, которым можно верить, владелец записи,
|
||||||
владелец записи, единый контракт API, покрытый тестами конвейер, — и
|
единый контракт API, покрытый тестами конвейер, — и только потом экраны
|
||||||
только потом экраны и возможности поверх них. Порядок расставлен на
|
и возможности поверх них. Этот порядок расставили 2026-08-12.
|
||||||
груминге 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/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
|
||||||
- [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
|
- [🐞 Вести учёт употреблённых состояний входа на сервере](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/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 объекта, из журнала снова собирается ссылка на чужую запись.
|
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
|
||||||
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
|
- [🧹 Разобрать мелочи 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/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
|
||||||
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
||||||
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
||||||
@@ -59,6 +62,7 @@
|
|||||||
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен.
|
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен.
|
||||||
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
|
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
|
||||||
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
|
- [🧹 Разобрать мелочи слоя хранилища](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/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
|
||||||
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
|
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
|
||||||
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
|
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
|
||||||
@@ -76,7 +80,7 @@
|
|||||||
- [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит.
|
- [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит.
|
||||||
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
||||||
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
||||||
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
|
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а работа над долгими записями без них не начинается.
|
||||||
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
||||||
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
|
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
|
||||||
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
|
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
|
||||||
@@ -91,11 +95,4 @@
|
|||||||
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
|
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
|
||||||
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
|
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
|
||||||
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.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 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас.
|
- [🔬 Квота по общему размеру загруженного на пользователя](items/per-user-size-quota.md) — Паспорт и security.md запрещают отказы по квоте пользователю, а заметка владельца просит квоту по умолчанию 5 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас.
|
||||||
|
|||||||
@@ -6,3 +6,19 @@
|
|||||||
|
|
||||||
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
|
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
|
||||||
- 2026-08-12 `gate-go-version-sync` — 🧹 Сверять версию Go в образе с директивой go.mod. Причина: слита в go-1-26-upgrade 2026-08-12: сверка версии и само обновление правят одни и те же строки go.mod и Dockerfile, и порознь заводят расхождение заново. Была секция: Очередь.
|
- 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
|
- **Тип:** 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
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды.
|
- **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды.
|
||||||
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
||||||
- **Теги:** goal:multi-user
|
|
||||||
|
|
||||||
Двигает пункты 1 и 6 «Завершения» цели: запрос без токена не проходит (пункт 1),
|
Запрос без токена не проходит, а скрипт ходит в API по токену, выпущенному
|
||||||
а скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его
|
пользователем, и видит ровно его записи.
|
||||||
записи (пункт 6).
|
|
||||||
|
|
||||||
Токен принадлежит учётной записи и даёт ровно её права: записи, заведённые по
|
Токен принадлежит учётной записи и даёт ровно её права: записи, заведённые по
|
||||||
токену, видны владельцу в приложении, и наоборот.
|
токену, видны владельцу в приложении, и наоборот.
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
- **Тип:** research
|
- **Тип:** research
|
||||||
- **Категория:** Очередь — Форматы: сначала замер того, что конвейер берёт на самом деле.
|
- **Категория:** Очередь — Форматы: сначала замер того, что конвейер берёт на самом деле.
|
||||||
- **Зачем:** Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
|
- **Зачем:** Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
|
||||||
- **Теги:** goal:any-audio-source
|
|
||||||
|
|
||||||
Двигает пункты 1 и 4 «Завершения» цели: перечень принимаемых форматов замерен и
|
Замер даёт перечень принимаемых форматов и разбирает расхождение `ogg/vorbis`
|
||||||
записан, а расхождение `ogg/vorbis` против заявленного SpeechKit `OGG_OPUS`
|
против заявленного SpeechKit `OGG_OPUS`.
|
||||||
разобрано.
|
|
||||||
|
|
||||||
## Вопрос
|
## Вопрос
|
||||||
|
|
||||||
@@ -19,9 +17,9 @@
|
|||||||
- `docs/research/audio-formats.md` — таблица «формат на входе → исход», с
|
- `docs/research/audio-formats.md` — таблица «формат на входе → исход», с
|
||||||
командой замера и версией ffmpeg, на которой он сделан;
|
командой замера и версией ffmpeg, на которой он сделан;
|
||||||
- расхождение `ogg/vorbis` против `OGG_OPUS`: строка о том, устранено оно или
|
- расхождение `ogg/vorbis` против `OGG_OPUS`: строка о том, устранено оно или
|
||||||
проверенно безвредно, и чем это подтверждено;
|
проверено безвредно, и чем это подтверждено;
|
||||||
- форматы, которые принять нельзя, — задачей об отказе на приёме, с провенансом
|
- форматы, которые принять нельзя, — задачей об отказе на приёме, и она
|
||||||
этой разведки.
|
называет эту разведку.
|
||||||
|
|
||||||
## Рамки
|
## Рамки
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 🧹 Запретить обращаться к Bot API мимо клиента бота
|
# 🧹 Запретить обращаться к Bot API мимо клиента бота
|
||||||
|
|
||||||
- **Тип:** chore
|
- **Тип:** chore
|
||||||
- **Категория:** Очередь — Класс уже дал утечку токена; сегодня его держат две проверки на сегодняшних местах, а не правило.
|
- **Категория:** Очередь — Правило границы клиента ставится на тот же транспорт, мелочи которого разбирает строка выше: своя обёртка, заведённая раньше правила, вернёт утечку токена молча.
|
||||||
- **Зачем:** Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
|
- **Зачем:** Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
|
||||||
|
|
||||||
Токен бота стоит в пути каждого обращения к Bot API, а `http.Client` кладёт
|
Токен бота стоит в пути каждого обращения к 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
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Дедупликация ищет совпадение в пределах пользователя — то есть после владельца записи, и экономит деньги с первого дня приложения.
|
- **Категория:** Очередь — Дедупликация ищет совпадение в пределах пользователя — то есть после владельца записи, и экономит деньги с первого дня приложения.
|
||||||
- **Зачем:** Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
|
- **Зачем:** Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
|
||||||
- **Теги:** goal:upload-reliability
|
|
||||||
|
|
||||||
Двигает пункт 1 «Завершения» цели: повторная отправка того же файла возвращает
|
Повторная отправка того же файла возвращает прежнюю запись вместо второй задачи.
|
||||||
прежнюю запись вместо второй задачи.
|
|
||||||
|
|
||||||
Совпадение ищется **в пределах одного пользователя**: чужая расшифровка по
|
Совпадение ищется **в пределах одного пользователя**: чужая расшифровка по
|
||||||
совпадению хеш-суммы не отдаётся и о её существовании отправитель не узнаёт.
|
совпадению хеш-суммы не отдаётся и о её существовании отправитель не узнаёт.
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Удаление трогает конвейер, файлы, объект хранилища и колонку дедупликации — всё это к этому месту уже готово.
|
- **Категория:** Очередь — Удаление трогает конвейер, файлы, объект хранилища и колонку дедупликации — всё это к этому месту уже готово.
|
||||||
- **Зачем:** Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
- **Зачем:** Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
||||||
- **Теги:** goal:data-ownership
|
|
||||||
|
|
||||||
Двигает все пять пунктов «Завершения» цели: запись убирается одним действием
|
Запись убирается одним действием вместе с файлом, объектом в хранилище и всеми
|
||||||
вместе с файлом, объектом в хранилище и всеми уровнями текста. Чужую запись
|
уровнями текста. Чужую запись убрать нельзя. Учёт расхода остаётся.
|
||||||
убрать нельзя. Учёт расхода остаётся.
|
|
||||||
|
|
||||||
Удаление необратимо и потому спрашивает подтверждения. Задача, которая ещё в
|
Удаление необратимо и потому спрашивает подтверждения. Задача, которая ещё в
|
||||||
работе, тоже убирается: конвейер обязан заметить исчезнувшую запись и не
|
работе, тоже убирается: конвейер обязан заметить исчезнувшую запись и не
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
# 🧹 Свести шесть расхождений между документами канона
|
# 🧹 Свести пять расхождений между документами канона
|
||||||
|
|
||||||
- **Тип:** chore
|
- **Тип:** chore
|
||||||
- **Категория:** Очередь — Находки одной сверки: чинится одним заходом, пока помнится, чем каждое место было найдено.
|
- **Категория:** Очередь — Документы правятся до того, как на них обопрутся экраны и контракт: расхождение в таблице зависимостей и в периметре читают, принимая решения ниже по списку.
|
||||||
- **Зачем:** Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; четыре из них в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
- **Зачем:** Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
||||||
|
|
||||||
Находки сверки документов агентами `doc-consistency` и `doc-code-drift`,
|
Находки сверки документов агентами `doc-consistency` и `doc-code-drift`,
|
||||||
прогнанной 2026-08-13 вместе с работой о контексте и токене. К той работе
|
прогнанной 2026-08-13 вместе с работой о контексте и токене. К той работе
|
||||||
расхождения отношения не имеют — они старше, и потому не чинились тем же
|
расхождения отношения не имеют — они старше, и потому не чинились тем же
|
||||||
коммитом.
|
коммитом.
|
||||||
|
|
||||||
|
Мест было шесть. Шестое — вид временной метки, где `conventions/database.md`
|
||||||
|
требовал RFC 3339 с `T`, а хранилище пишет `2006-01-02 15:04:05.000Z`, — сведено
|
||||||
|
2026-08-13 строкой «*Расхождение:*» в конвенции при повышении раскладки до
|
||||||
|
версии 3. Остальные пять живы.
|
||||||
|
|
||||||
Каждое место названо с домом факта, то есть с тем документом, который прав:
|
Каждое место названо с домом факта, то есть с тем документом, который прав:
|
||||||
|
|
||||||
1. **Панель администратора против Authelia.** `architecture.md`, «Открытые
|
1. **Панель администратора против Authelia.** `architecture.md`, «Открытые
|
||||||
@@ -26,11 +31,7 @@
|
|||||||
4. **gin в `README.md`.** Веб-фреймворка нет: HTTP-поверхность — роутер
|
4. **gin в `README.md`.** Веб-фреймворка нет: HTTP-поверхность — роутер
|
||||||
встроенной PocketBase, и `logging.md` прямо говорит, что вместе с gin ушёл и
|
встроенной PocketBase, и `logging.md` прямо говорит, что вместе с gin ушёл и
|
||||||
`sloggin`. Дом стека — `CLAUDE.md`.
|
`sloggin`. Дом стека — `CLAUDE.md`.
|
||||||
5. **Вид временной метки.** `conventions/database.md`: RFC 3339 с `T`, секундная
|
5. **Дубли текста в `CLAUDE.md`** — подавления `hadolint` и настройка
|
||||||
точность. `docs/database.md`: `2006-01-02 15:04:05.000Z`, и вид обязателен
|
|
||||||
побайтово — сравнение в SQLite строковое. Дом — `docs/database.md`;
|
|
||||||
конвенции нужна строка «*Расхождение:*».
|
|
||||||
6. **Дубли текста в `CLAUDE.md`** — подавления `hadolint` и настройка
|
|
||||||
`errcheck` пересказаны там дословно, хотя обе преамбулы договорились, что
|
`errcheck` пересказаны там дословно, хотя обе преамбулы договорились, что
|
||||||
дом перечня подавлений — `go-linters.md`.
|
дом перечня подавлений — `go-linters.md`.
|
||||||
|
|
||||||
@@ -38,7 +39,6 @@
|
|||||||
|
|
||||||
- `docs/architecture.md` — «Открытые вопросы», таблица внешних зависимостей,
|
- `docs/architecture.md` — «Открытые вопросы», таблица внешних зависимостей,
|
||||||
раздел «Эксплуатация»;
|
раздел «Эксплуатация»;
|
||||||
- `docs/conventions/database.md` — вид временной метки;
|
|
||||||
- `docs/security.md` и `docs/database.md` — как дома фактов, если правка
|
- `docs/security.md` и `docs/database.md` — как дома фактов, если правка
|
||||||
потребует уточнить формулировку;
|
потребует уточнить формулировку;
|
||||||
- `README.md` — перечень технологий;
|
- `README.md` — перечень технологий;
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
|
|
||||||
## Критерии приёмки
|
## Критерии приёмки
|
||||||
|
|
||||||
- Ни одно из шести мест не отвечает на свой вопрос двумя способами. Оракул —
|
- Ни одно из пяти мест не отвечает на свой вопрос двумя способами. Оракул —
|
||||||
повторный прогон `av-dev-docs:healthcheck`: перечисленные шесть находок не
|
повторный прогон `av-dev:doc-healthcheck`: перечисленные пять находок не
|
||||||
возвращаются.
|
возвращаются.
|
||||||
- Провайдер OIDC стоит в таблице внешних зависимостей со своими четырьмя
|
- Провайдер OIDC стоит в таблице внешних зависимостей со своими четырьмя
|
||||||
столбцами отказа, и счёт зависимостей в «Открытых вопросах» сходится с
|
столбцами отказа, и счёт зависимостей в «Открытых вопросах» сходится с
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Второй канал на той же доставке.
|
- **Категория:** Очередь — Второй канал на той же доставке.
|
||||||
- **Зачем:** Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
- **Зачем:** Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
||||||
- **Теги:** goal:ready-notification
|
|
||||||
|
|
||||||
Двигает пункты 1, 2 и 3 «Завершения» цели: готовый текст и отказ доходят
|
Готовый текст и отказ доходят письмом, а адрес берётся у учётной записи, а не из
|
||||||
письмом, а адрес берётся у учётной записи, а не из общего конфига.
|
общего конфига.
|
||||||
|
|
||||||
Почта — второй канал рядом с тем, что заводит `ntfy-delivery`; выбор канала
|
Почта — второй канал рядом с тем, что заводит `ntfy-delivery`; выбор канала
|
||||||
остаётся в той же единой точке, что и сейчас.
|
остаётся в той же единой точке, что и сейчас.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
аргументом;
|
аргументом;
|
||||||
- `internal/controller/worker/worker.go` и `internal/service/transcribe.go` —
|
- `internal/controller/worker/worker.go` и `internal/service/transcribe.go` —
|
||||||
протаскивание контекста в шаг;
|
протаскивание контекста в шаг;
|
||||||
- `config.dist.toml` и `internal/config` — числа таймаутов;
|
- `config.example.toml` и `internal/config` — числа таймаутов;
|
||||||
- `docs/database.md`, таблица настроек с числовым значением.
|
- `docs/database.md`, таблица настроек с числовым значением.
|
||||||
|
|
||||||
## Критерии приёмки
|
## Критерии приёмки
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Метрики внешних сервисов пишутся в выбранном словаре, а не переписываются потом.
|
- **Категория:** Очередь — Метрики внешних сервисов пишутся в выбранном словаре, а не переписываются потом.
|
||||||
- **Зачем:** Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера.
|
- **Зачем:** Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера.
|
||||||
- **Теги:** goal:service-observability
|
|
||||||
|
|
||||||
Двигает пункты 2 и 5 «Завершения» цели: у каждого внешнего сервиса появляются
|
У каждого внешнего сервиса появляются вызовы, отказы и длительность, а расход на
|
||||||
вызовы, отказы и длительность, а расход на платные сервисы виден числом.
|
платные сервисы становится виден числом.
|
||||||
|
|
||||||
Внешних сервисов сегодня четыре — Telegram, Object Storage, SpeechKit и
|
Внешних сервисов сегодня четыре — Telegram, Object Storage, SpeechKit и
|
||||||
`ffmpeg`/`ffprobe` как внешний процесс; пятым станет языковая модель. Метрика
|
`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
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Три пункта «Завершения» цели не закрывала ни одна задача: показывать и отбирать можно, когда заголовки и темы уже считаются.
|
- **Категория:** Очередь — Показывать и отбирать можно, когда заголовки и темы уже считаются.
|
||||||
- **Зачем:** Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом.
|
- **Зачем:** Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом.
|
||||||
- **Теги:** goal:text-insights
|
|
||||||
|
|
||||||
Двигает пункты 1, 4 и 6 «Завершения» цели — те три, где выводы из текста
|
Выводы из текста становятся видны человеку и владельцу: заголовок в списке,
|
||||||
становятся видны человеку и владельцу: заголовок в списке (1), отбор по темам
|
отбор по темам, стоимость числом. Сами уровни считает `llm-insights-adapter`,
|
||||||
(4), стоимость числом (6). Сами уровни считает `llm-insights-adapter`, показать
|
показать их некому: экран списка написан раньше и знает только первые слова
|
||||||
их некому: экран списка написан раньше и знает только первые слова расшифровки.
|
расшифровки.
|
||||||
|
|
||||||
Берётся после `llm-insights-adapter`: пока заголовков и тем нет, показывать и
|
Берётся после `llm-insights-adapter`: пока заголовков и тем нет, показывать и
|
||||||
отбирать нечего.
|
отбирать нечего.
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Ставить на телефон есть смысл, когда есть что ставить.
|
- **Категория:** Очередь — Ставить на телефон есть смысл, когда есть что ставить.
|
||||||
- **Зачем:** Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
- **Зачем:** Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
||||||
- **Теги:** goal:web-access
|
|
||||||
|
|
||||||
Двигает пункты 5 и 6 «Завершения» цели: приложение ставится с телефона и
|
Приложение ставится с телефона и запускается с ярлыка без адресной строки, а
|
||||||
запускается с ярлыка без адресной строки, а открытое без сети показывает это
|
открытое без сети показывает это состоянием.
|
||||||
состоянием.
|
|
||||||
|
|
||||||
Берётся после того, как есть что ставить, — то есть после
|
Берётся после того, как есть что ставить, — то есть после
|
||||||
`records-list-screen`.
|
`records-list-screen`.
|
||||||
@@ -41,5 +39,5 @@
|
|||||||
## Рамки
|
## Рамки
|
||||||
|
|
||||||
Офлайн-чтения готовых расшифровок и очереди отправки без сети **не делаем** —
|
Офлайн-чтения готовых расшифровок и очереди отправки без сети **не делаем** —
|
||||||
это за границей цели. Web Push не делаем: уведомления идут через apprise и ntfy,
|
это за границей из паспорта. Web Push не делаем: уведомления идут через apprise
|
||||||
цель `ready-notification`.
|
и ntfy — задача `ntfy-delivery`.
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
- **Тип:** research
|
- **Тип:** research
|
||||||
- **Категория:** Очередь — Второй замер — остальные четыре звена.
|
- **Категория:** Очередь — Второй замер — остальные четыре звена.
|
||||||
- **Зачем:** Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
- **Зачем:** Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
||||||
- **Теги:** goal:long-recordings
|
|
||||||
|
|
||||||
Пункт 1 «Завершения» цели требует замера пяти звеньев, а разведка
|
Звеньев пять, а разведка `speechkit-limits` меряет одно — модель
|
||||||
`speechkit-limits` меряет одно — модель `deferred-general`. Остальные четыре
|
`deferred-general`. Остальные четыре дешевле: они не требуют боевых ключей и
|
||||||
дешевле: они не требуют боевых ключей и считаются локально, кроме заливки.
|
считаются локально, кроме заливки.
|
||||||
|
|
||||||
Числа нужны раньше кода: они назначают потолок, который проверяет приём, и длину
|
Числа нужны раньше кода: они назначают потолок, который проверяет приём, и длину
|
||||||
фрагмента, на которые режет `long-audio-chunking`.
|
фрагмента, на которые режет `long-audio-chunking`.
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
# ✨ Собирать путь одной записи по конвейеру запросом
|
# ✨ Собирать путь одной записи по конвейеру запросом
|
||||||
|
|
||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Пункт 3 «Завершения» цели не закрывала ни одна задача; применяет словарь, который выберет разведка строкой выше.
|
- **Категория:** Очередь — Применяет словарь метрик, который выберет разведка строкой выше.
|
||||||
- **Зачем:** Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами.
|
- **Зачем:** Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами.
|
||||||
- **Теги:** goal:service-observability
|
|
||||||
|
|
||||||
Двигает пункт 3 «Завершения» цели: путь одной записи по конвейеру собирается
|
Путь одной записи по конвейеру собирается запросом, а не чтением логов глазами.
|
||||||
запросом, а не чтением логов глазами.
|
|
||||||
|
|
||||||
Путь длиной в минуты идёт через четыре внешних сервиса и три воркера. Сегодня
|
Путь длиной в минуты идёт через четыре внешних сервиса и три воркера. Сегодня
|
||||||
его звенья связывает `job_id` в строках журнала, и собирает их человек.
|
его звенья связывает `job_id` в строках журнала, и собирает их человек.
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Единая точка трансляции доменной ошибки — база и для экранов, и для токенов; список своих записей заводится после владельца, а не до.
|
- **Категория:** Очередь — Единая точка трансляции доменной ошибки — база и для экранов, и для токенов; список своих записей заводится после владельца, а не до.
|
||||||
- **Зачем:** Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
- **Зачем:** Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
||||||
- **Теги:** goal:web-access
|
|
||||||
|
|
||||||
Двигает пункты 1, 2 и 4 «Завершения» цели: экраны заводят задачу, видят её
|
Экраны заводят задачу, видят её состояние и листают список — всё через один
|
||||||
состояние и листают список — всё через один контракт.
|
контракт.
|
||||||
|
|
||||||
Обработчик `GET /api/status/:id` сегодня отвечает `404` на **любую** ошибку
|
Обработчик `GET /api/status/:id` сегодня отвечает `404` на **любую** ошибку
|
||||||
чтения, включая сбой базы, а `POST /api/audio` — `500` на любую ошибку заведения,
|
чтения, включая сбой базы, а `POST /api/audio` — `500` на любую ошибку заведения,
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Вычитанный текст считается тем же адаптером.
|
- **Категория:** Очередь — Вычитанный текст считается тем же адаптером.
|
||||||
- **Зачем:** Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
|
- **Зачем:** Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
|
||||||
- **Теги:** goal:text-insights
|
|
||||||
|
|
||||||
Двигает пункт «Завершения» цели про литературный текст: у записи появляется
|
У записи появляется второй уровень текста — тот же разговор, вычитанный до
|
||||||
второй уровень — тот же разговор, вычитанный до читаемого вида.
|
читаемого вида.
|
||||||
|
|
||||||
Вычитку считает та же внешняя модель, что заголовок и темы. Сырой текст
|
Вычитку считает та же внешняя модель, что заголовок и темы. Сырой текст
|
||||||
остаётся и не переписывается: уровни лежат рядом, а не поверх друг друга.
|
остаётся и не переписывается: уровни лежат рядом, а не поверх друг друга.
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Уровни текста: сюда приходит пятая внешняя зависимость, и конвейер к этому моменту покрыт тестами.
|
- **Категория:** Очередь — Уровни текста: сюда приходит пятая внешняя зависимость, и конвейер к этому моменту покрыт тестами.
|
||||||
- **Зачем:** Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
|
- **Зачем:** Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
|
||||||
- **Теги:** goal:text-insights
|
|
||||||
|
|
||||||
Двигает пункты 1, 3, 4 и 5 «Завершения» цели: у готовой записи появляются
|
У готовой записи появляются заголовок, пересказ и темы, а отказ и молчание
|
||||||
заголовок (1), пересказ (3) и темы (4), а отказ и молчание модели не роняют
|
модели не роняют задачу.
|
||||||
задачу (5).
|
|
||||||
|
|
||||||
Здесь появляется пятая внешняя зависимость — языковая модель с
|
Здесь появляется пятая внешняя зависимость — языковая модель с
|
||||||
OpenAI-совместимым интерфейсом за шлюзом bifrost, — и текст расшифровки уходит
|
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),
|
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||||
раздел «Гипотезы без доказательства». Каждая либо становится задачей, либо
|
раздел «Гипотезы без доказательства». Каждая либо становится задачей, либо
|
||||||
закрывается с причиной; сегодня они не то и не другое.
|
закрывается с причиной; сегодня они не то и не другое.
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## Куда ляжет ответ
|
## Куда ляжет ответ
|
||||||
|
|
||||||
- подтверждённый путь — задачей в беклоге, с провенансом этой разведки;
|
- подтверждённый путь — задачей в беклоге, и она называет эту разведку;
|
||||||
- опровергнутый — строкой в `docs/security.md`, раздел «Что вне модели» либо
|
- опровергнутый — строкой в `docs/security.md`, раздел «Что вне модели» либо
|
||||||
«Что разграничивает доступ», чтобы следующее ревью не открывало его заново;
|
«Что разграничивает доступ», чтобы следующее ревью не открывало его заново;
|
||||||
- то, что зависит от настройки Authelia, — строкой там же, с указанием, какая
|
- то, что зависит от настройки Authelia, — строкой там же, с указанием, какая
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 🧹 Строить адрес входа из настроек коллекции, а не из конфига
|
# ✨ Строить адрес входа из настроек коллекции, а не из конфига
|
||||||
|
|
||||||
- **Тип:** chore
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Замыкает тройку правок обработчиков входа.
|
- **Категория:** Очередь — Замыкает тройку правок обработчиков входа.
|
||||||
- **Зачем:** Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
|
- **Зачем:** Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
- **Тип:** feature
|
- **Тип:** 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
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Сотни килобайт текста появляются только после долгих записей.
|
- **Категория:** Очередь — Сотни килобайт текста появляются только после долгих записей.
|
||||||
- **Зачем:** Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
|
- **Зачем:** Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
|
||||||
- **Теги:** goal:long-recordings
|
|
||||||
|
|
||||||
Двигает пункт 4 «Завершения» цели: текст в несколько сотен килобайт доходит и в
|
Текст в несколько сотен килобайт доходит и в Telegram, и в браузере.
|
||||||
Telegram, и в браузере.
|
|
||||||
|
|
||||||
Деление по словам (`internal/adapter/telegram/split.go`) остаётся для обычной
|
Деление по словам (`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
|
- **Тип:** 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
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь — Канал уведомлений выбирается в настройках, которые уже есть.
|
- **Категория:** Очередь — Канал уведомлений выбирается в настройках, которые уже есть.
|
||||||
- **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
- **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
||||||
- **Теги:** goal:ready-notification
|
|
||||||
|
|
||||||
Двигает пункты 1, 2, 4 и 5 «Завершения» цели: готовый текст и отказ доходят до
|
Готовый текст и отказ доходят до пользователя веба без открытого приложения,
|
||||||
пользователя веба без открытого приложения, отказ канала задачу не роняет, а
|
отказ канала задачу не роняет, а пользователь Telegram получает ответ
|
||||||
пользователь Telegram получает ответ по-прежнему ботом. Выбор канала самим
|
по-прежнему ботом. Выбор канала самим пользователем заводит `settings-screen`.
|
||||||
пользователем (пункт 3) заводит `settings-screen`.
|
|
||||||
|
|
||||||
Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`;
|
Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`;
|
||||||
источник `api` не получает ничего. Здесь появляется второй способ доставки, и
|
источник `api` не получает ничего. Здесь появляется второй способ доставки, и
|
||||||
@@ -46,3 +44,8 @@
|
|||||||
Web Push с VAPID и своим хранением подписок не делаем. Своего сервера ntfy не
|
Web Push с VAPID и своим хранением подписок не делаем. Своего сервера ntfy не
|
||||||
поднимаем — адрес приходит конфигом. Текст расшифровки уходит на внешний сервис,
|
поднимаем — адрес приходит конфигом. Текст расшифровки уходит на внешний сервис,
|
||||||
и это сдвиг периметра: строка в `docs/security.md` обязательна.
|
и это сдвиг периметра: строка в `docs/security.md` обязательна.
|
||||||
|
|
||||||
|
Отказ от Web Push сегодня живёт открытым вопросом `docs/architecture.md`,
|
||||||
|
«Уведомления», и своего ADR не имеет: заводить его не из чего, пока нет
|
||||||
|
`design.md` этой задачи. Решение промоутится из него, когда задача пойдёт в
|
||||||
|
работу.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
- **Тип:** research
|
- **Тип:** research
|
||||||
- **Категория:** Очередь — Сопровождение: словарь метрик выбирается до того, как метрик станет втрое больше.
|
- **Категория:** Очередь — Сопровождение: словарь метрик выбирается до того, как метрик станет втрое больше.
|
||||||
- **Зачем:** Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
|
- **Зачем:** Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
|
||||||
- **Теги:** goal:service-observability
|
|
||||||
|
|
||||||
Эндпоинт `/metrics` остаётся и развивается — это решено. Вопрос в том, чем его
|
Эндпоинт `/metrics` остаётся и развивается — это решено. Вопрос в том, чем его
|
||||||
развивать: дописывать счётчики в `internal/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