[2026-09-26] eagle: family/how-to/jellyfin-config.md personal/index.md personal/plans/balda-mm-endtoend-plan.md personal/projects/balda/balda-kraken-mm-deploy.md personal/projects/balda/balda-mm-delegation-plan.md personal/tech/balda-go-fx-pitfalls.md personal/tech/music-tags-repair-truenas.md personal/tech/yandex-music-api.md personal/yandex-music-library.md
This commit is contained in:
1 parent
4109797f6b
commit
f9b55abb8c
9 files changed
+2190
-94
No files matched your search
@@ -1,6 +1,7 @@
|
||||
# Balda — Go/fx pitfalls и метод правок (зеркало скилла)
|
||||
|
||||
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход).
|
||||
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход +
|
||||
> **приёмка сквозного пути**, Фаза 3 закрыта).
|
||||
> Добавлены: **`bad_seq` — настоящая причина тишины WS** (§«WS-фрейм без seq»),
|
||||
> **`optional:"true"` скрывает отсутствующую зависимость** (§«Дефект 5»),
|
||||
> **`canonicalSubject` и неизвестный транспорт** (§«auth: subject-based»),
|
||||
@@ -9,6 +10,8 @@
|
||||
> **`FROM scratch` без CA** (§«scratch и TLS»),
|
||||
> **`principal` с префиксом в БД** (§«Дефект 7»),
|
||||
> **`state.db` читать вместе с WAL** (§«Метод: чтение sqlite-состояния»).
|
||||
> 🔴 **Новое в этой сессии (приёмка):** §«Приёмка агент-проверяемый» — как доказать ответ
|
||||
> бота без GUI; §«Mattermost Team Edition: что недоступно» — push невозможен.
|
||||
> Уточнён §«авторизация — ОДИН механизм»: челлендж НЕ был причиной №1, им был `bad_seq`.
|
||||
> Зеркало секции скилла `devops/docker-balda-service`, добавленной 2026-09-26.
|
||||
> Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3).
|
||||
@@ -19,6 +22,62 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Приёмка, проверяемая агентом (без GUI-браузера)
|
||||
|
||||
Требование Alex: «приёмка = видно в UI, не "контейнер Up" и не "лог said accepted"».
|
||||
Агент без браузера **не может** посмотреть UI — но может верифицировать ответ бота
|
||||
однозначно, читая **ленту канала** REST'ом и сверяя `user_id` автора:
|
||||
|
||||
```bash
|
||||
ssh kraken 'curl -s -H "Authorization: Bearer *** /home/kraken/balda/secrets/mm-bot-token)" \
|
||||
"http://localhost:8065/api/v4/channels/7bjw4g7o1jdsbmjbqd649xxgaw/posts?per_page=4" \
|
||||
| jq -r ".order[] as \$o | .posts[\$o] | \"\(.user_id) | \(.message)\""'
|
||||
```
|
||||
|
||||
Ожидаемый вид — пост человека и **пост с `user_id` = bot_user_id**:
|
||||
```
|
||||
c6udeze8dfrdxm5jbgq55bd58r | @balda reply with a single word
|
||||
edxrgupoxiyg5gzmr79tjypbme | работает ← это и есть доказательство
|
||||
```
|
||||
|
||||
📌 Это заменяет «видно в UI»: `user_id` автора поста — однозначный факт, не интерпретация.
|
||||
⚠️ `post_user_id` в логах ингресса **не** подходит: там id отправителя запроса, не автора ответа.
|
||||
⚠️ Не отчитываться «готово» по факту `delivery settled` в логах — доставляется ли пост, видно
|
||||
только из ленты.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Mattermost Team Edition: что недоступно (проверено)
|
||||
|
||||
`mattermost version` → `Build Enterprise Ready: false`. На TE **невозможно** включить push
|
||||
в официальных мобильных приложениях — это архитектура продукта, не настройка.
|
||||
|
||||
| Что | Факт |
|
||||
|---|---|
|
||||
| **Тумблер push** | Живёт в `EmailSettings.SendPushNotifications`, **не** в `ServiceSettings` — `mmctl config get ServiceSettings.EnablePushNotifications` даёт `Error: invalid key` |
|
||||
| **Официальный push-прокси** | Жив (HTTP 200), но для TE-`DiagnosticId` отвечает `Did not send message because of missing platform property` — APNs/FCM-сертификаты только у лицензированных EE |
|
||||
| **Свой MPNS** | Требует **собственные подписанные сборки** мобильных приложений + Apple Developer ($99/год) + FCM. Официальные клиенты прошиты на bundle ID Mattermost Inc. и к своему MPNS не подключатся |
|
||||
| **SMTP-почта** | Единственный путь без лицензии. `SendEmailNotifications: true` уже стоит, но SMTP не настроен (`localhost:10025`, на хосте ни одного слушателя) |
|
||||
|
||||
🔴 **Проба прокси — читать ТЕЛО, не статус.** `curl -X POST https://push.mattermost.com/api/v1/send_push`
|
||||
возвращает **HTTP 200** даже на невалидный `server_id`; решение в теле: `{"status":"FAIL","error":"…"}`.
|
||||
HTTP-код здесь — ложный сигнал успеха.
|
||||
|
||||
⚠️ `DiagnosticId` ищется так (таблица **`systems`**, не `Systems`; колонка `ServerId` отсутствует):
|
||||
```bash
|
||||
docker exec mm-postgres psql -U mmuser -d mattermost -t -c \
|
||||
"SELECT name, value FROM systems WHERE name ILIKE '%diagnostic%';"
|
||||
```
|
||||
🔴 **Кавычки внутри `psql -c` в ssh-строке ломаются**: `name IN ('DiagnosticId')` уходит в
|
||||
shell до psql → Postgres видит `column "DiagnosticId" does not exist`. Экранировать `'"'"'`
|
||||
либо использовать `ILIKE` с шаблоном без значения в кавычках.
|
||||
|
||||
📌 MM-стек на Kraken: `/home/kraken/mattermost/docker-compose.yml`, 3 сервиса
|
||||
(`postgres` → `mm-postgres`, `mattermost`, сеть `mm-net` → `mattermost_mm-net`).
|
||||
БД: `docker exec mm-postgres psql -U mmuser -d mattermost`.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 `go build` = exit 0 ≠ рабочий транспорт
|
||||
|
||||
Компилятор не видит ошибок DI-графа. `go build ./...` проходит, а приложение падает
|
||||
@@ -623,8 +682,268 @@ conn.WriteJSON(map[string]any{ // ← 🔴 а эт
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Дефект 6: точка входа онбординга, закрытая проверкой доступа
|
||||
|
||||
Найдено 2026-09-26. `/start owner=<token>` в DM получил от бота
|
||||
**«You are not allowed to use Balda in this channel.»**
|
||||
|
||||
**Причина:** `HandleCommand` проверял доступ **до** обработки, а владельца ещё нет — тупик:
|
||||
привязаться нельзя, потому что не авторизован; авторизоваться нельзя, потому что не привязан.
|
||||
Владелец в canonical store привязывается **этой же командой**, поэтому она обязана быть
|
||||
доступна неавторизованному.
|
||||
|
||||
**Неправильно:**
|
||||
```go
|
||||
if !h.canAccess(ctx, cmd.SenderID) {
|
||||
return h.sendPlain(ctx, cmd.Locator, accessDeniedText)
|
||||
}
|
||||
```
|
||||
|
||||
**Правильно** (образец — `zulip_ingress.go:172`):
|
||||
```go
|
||||
if cmd.Command != commandStart && !h.canAccess(ctx, cmd.SenderID) {
|
||||
return h.sendPlain(ctx, cmd.Locator, accessDeniedText)
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ `commandStart` **уже объявлена** в пакете `handlersfx` (приходит из telegram/zulip-веток).
|
||||
Своя константа даёт `commandStart redeclared in this block`. Использовать общую.
|
||||
|
||||
📌 **Правило для нового транспорта:** онбординг-команда — единственная, что доступна
|
||||
неавторизованному пользователю. Проверять это явно, иначе транспорт невозможно
|
||||
забутстрапить.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Дефект 7: `principal` с транспортным префиксом в `balda_user_bindings`
|
||||
|
||||
Найдено 2026-09-26. `/start owner=<token>` вернул успех («You are now registered as the bot
|
||||
owner»), но каждое обращение к боту оставалось `settlement_stage="unauthorized"`.
|
||||
|
||||
**Механизм.** Авторизация сверяет subject вида `<channel>:<principal>`. `canonicalSubject()`
|
||||
**разрезает** его по первому `:` и **отбрасывает** префикс:
|
||||
|
||||
```go
|
||||
if before, after, ok := strings.Cut(trimmed, ":"); ok {
|
||||
channelType = strings.ToLower(strings.TrimSpace(before))
|
||||
principal = strings.TrimSpace(after) // ← префикс отрезан
|
||||
}
|
||||
```
|
||||
|
||||
А `GetUserByBinding` делает **точное** сравнение:
|
||||
```go
|
||||
return s.getUser(ctx, `b.channel_type = ? AND b.principal = ?`, channelType, principal)
|
||||
```
|
||||
|
||||
Если в БД записался `principal = 'mattermost:<user_id>'` при `channel_type='mattermost'`,
|
||||
то поиск по `principal='<user_id>'` не находит **ничего** — молча, без ошибки.
|
||||
|
||||
🔴 **Правило:** в `balda_user_bindings` `principal` хранится **БЕЗ** транспортного префикса.
|
||||
Префикс живёт только в `channel_type`. Нельзя собирать subject тем же хелпером, которым
|
||||
потом нормализуешь: `userSubject()` в `start/handler.go` оборачивает principal в
|
||||
`<transport>:<principal>` — если на вход уже пришёл обёрнутый principal, выходит двойная
|
||||
обёртка и запись уезжает с префиксом.
|
||||
|
||||
**Диагностика — прочитать таблицу:**
|
||||
```bash
|
||||
sudo -n sqlite3 /srv/…/docker/balda/state/state.db \
|
||||
"SELECT channel_type, principal FROM balda_user_bindings;"
|
||||
# правильно: mattermost | c6udeze8dfrdxm5jbgq55bd58r
|
||||
# неправильно: mattermost | mattermost:c6udeze8dfrdxm5jbgq55bd58r
|
||||
```
|
||||
|
||||
**Лечение** (`tools/mm-fix-owner-principal.sh`: бэкап → стоп → UPDATE → старт):
|
||||
```sql
|
||||
UPDATE balda_user_bindings
|
||||
SET principal = substr(principal, length(channel_type) + 2)
|
||||
WHERE channel_type = 'mattermost' AND principal LIKE channel_type || ':%';
|
||||
```
|
||||
|
||||
⚠️ Штатного пути **нет**: `AttachBinding` отказывает при существующей привязке
|
||||
(`count != 0` → `ErrBindingAlreadyAssigned`), detach-API отсутствует вовсе. Правка БД —
|
||||
вынужденная мера, делать с бэкапом и **остановленным** контейнером (sqlite не любит
|
||||
конкурентной записи).
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Провайдер: `base_url` в YAML мёртв, работает только `OPENAI_BASE_URL`
|
||||
|
||||
Найдено 2026-09-26. Симптом (образ `0.1.7`):
|
||||
|
||||
```json
|
||||
{"component":"balda.turn_dispatcher",
|
||||
"error":"agent run: send openai request: Post \"https://api.openai.com/v1/chat/completions\":
|
||||
tls: failed to verify certificate: x509: certificate signed by unknown authority"}
|
||||
```
|
||||
|
||||
Запрос уходил на **`api.openai.com`**, хотя в `config.yaml` стоял
|
||||
`base_url: "https://api.deepseek.com/v1"`.
|
||||
|
||||
**Причина:** тип `LocalAPIConfig` в `github.com/normahq/runtime/v2@v2.0.11/agentconfig/config.go`
|
||||
имеет **только** `api_key` и `model`. Поля `base_url` в схеме **нет**:
|
||||
|
||||
```go
|
||||
type LocalAPIConfig struct {
|
||||
APIKey string `json:"api_key,omitempty" yaml:"api_key,omitempty" mapstructure:"api_key"`
|
||||
Model string `json:"model,omitempty" yaml:"model,omitempty" mapstructure:"model"`
|
||||
}
|
||||
```
|
||||
|
||||
Базовый URL читается **только из env** — `hostedagent/openai.go`:
|
||||
```go
|
||||
const defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
|
||||
func openAIBaseURL() string {
|
||||
if u := os.Getenv("OPENAI_BASE_URL"); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
return defaultOpenAIBaseURL
|
||||
}
|
||||
```
|
||||
|
||||
📌 **Правило:** для любого OpenAI-совместимого провайдера (DeepSeek, локальные gateway и т.п.)
|
||||
базовый URL задавать **через `OPENAI_BASE_URL`** в `.env` или `environment:` compose.
|
||||
Ключ `base_url` в YAML **выглядит рабочим, но не делает ничего** — молчаливая ловушка.
|
||||
⚠️ Сама переменная попадает в процесс только если `env_file`/`environment` настроены.
|
||||
|
||||
**Фикс:** `OPENAI_BASE_URL=https://api.deepseek.com/v1` в `/home/kraken/balda/.env`
|
||||
(`tools/mm-add-base-url.sh`, идемпотентный, с бэкапом).
|
||||
|
||||
---
|
||||
|
||||
## 🔴 `FROM scratch` и TLS: нет доверенных корней → любой HTTPS падает
|
||||
|
||||
`Dockerfile.release` Балды собирается из `scratch`. В финальном образе **нет
|
||||
`/etc/ssl/certs`**, поэтому попытка установить любое TLS-соединение заканчивается:
|
||||
|
||||
```
|
||||
tls: failed to verify certificate: x509: certificate signed by unknown authority
|
||||
```
|
||||
|
||||
Ошибка **обманчива**: выглядит как «удалённый сервер с неверным сертификатом», а на деле
|
||||
в образе физически нет файла с корневыми CA — Go не может построить цепочку доверия.
|
||||
Симптом проявился ровно один раз, на первом исходящем HTTPS (запрос к LLM).
|
||||
|
||||
**Фикс — дельта `tools/deltas-mattermost-22-ca.json`:**
|
||||
```dockerfile
|
||||
FROM scratch
|
||||
|
||||
# CA bundle: the scratch base has no trust store, so any HTTPS call (the LLM
|
||||
# provider in particular) fails with 'x509: certificate signed by unknown
|
||||
# authority'. The builder stage is a Debian image and carries the bundle.
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
COPY --from=builder /out/balda /usr/local/bin/balda
|
||||
|
||||
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/balda"]
|
||||
```
|
||||
|
||||
📌 **Правило:** любой `scratch`/`distroless`-образ, делающий исходящие HTTPS-запросы, обязан
|
||||
содержать `ca-certificates.crt` **и** выставлять `SSL_CERT_FILE` (у distroless путь тот же).
|
||||
Копировать прямо из builder'а — он на Debian и бандл уже там.
|
||||
⚠️ Альтернатива для локального дебага: `SSL_CERT_FILE` с хоста через bind-mount. Для прода —
|
||||
копировать в образ.
|
||||
|
||||
---
|
||||
|
||||
## Метод: чтение sqlite-состояния Балды (образ `scratch`)
|
||||
|
||||
В образе **нет** ни `sh`, ни `sqlite3`, поэтому `docker exec` невозможен в принципе:
|
||||
|
||||
```
|
||||
OCI runtime exec failed: exec failed: unable to start container process:
|
||||
exec: "sh": executable file not found in $PATH
|
||||
```
|
||||
|
||||
**Рабочий порядок:**
|
||||
1. Файлы состояния: `<state_dir>/state.db` + `state.db-wal` + `state.db-shm`, владелец — **root**.
|
||||
2. `sqlite3` **есть на хосте** Kraken (`/usr/bin/sqlite3`), но не в контейнере.
|
||||
3. Для остановленного контейнера — прямой `sqlite3` под `sudo -n`.
|
||||
4. Для живого — `strings` (только просмотр, без записи).
|
||||
|
||||
🔴 **Читать `state.db` ВМЕСТЕ с `state.db-wal`.** Свежие записи живут в WAL, а основной файл
|
||||
выглядит пустым. Прямая потеря времени: таблица `balda_user_bindings` казалась пустой, хотя
|
||||
привязка владельца уже была записана — она лежала в `-wal`.
|
||||
|
||||
```bash
|
||||
STATE=/srv/…/docker/balda/state
|
||||
for f in state.db state.db-wal state.db-shm; do
|
||||
echo "--- $f"
|
||||
sudo -n strings "$STATE/$f" 2>/dev/null \
|
||||
| grep -oE "(telegram|mattermost|slackagent|zulip):[A-Za-z0-9_-]+" | sort -u
|
||||
done
|
||||
```
|
||||
|
||||
**Полезные ключи KV внутри `state.db`:**
|
||||
- `balda.appowner_auth_token` — owner auth token (32 символа), нужен для `/start owner=<token>`;
|
||||
- `balda_user_bindings` — привязки, `principal` **без** префикса (см. §Дефект 7);
|
||||
- `balda_user_binding_claims` — claim'ы, `generated_binding_count`, `role IN ('administrator','operator')`.
|
||||
|
||||
⚠️ `sudo` через ssh требует `< /dev/null`, иначе `Connection closed by remote host`.
|
||||
⚠️ Правка каталога данных (`sudo cp -a state <dir>`) уходит на апрув и **блокируется** по
|
||||
таймауту. Точечный `UPDATE` одного поля такого апрува не требует — предпочтителен.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Mattermost WS: `bad_seq` — пинг без `seq` убивает подписку молча
|
||||
|
||||
Найдено 2026-09-26 диагностическим логом каждого кадра (`deltas-mattermost-15-diag.json`).
|
||||
**Это и была настоящая причина «connected, но тишина»** — до неё ошибочно считалось, что
|
||||
виноват `authentication_challenge` (он тоже вреден, но лишь как усугубляющий фактор).
|
||||
|
||||
MM отвечал раз в 30 секунд, ровно на каждый пинг:
|
||||
```json
|
||||
{"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error",
|
||||
"message":"Invalid sequence for WebSocket message."}}
|
||||
```
|
||||
|
||||
**Механизм:** Mattermost требует **монотонный `seq` в КАЖДОМ клиентском фрейме**. Балда слала
|
||||
`{"action":"ping"}` **без `seq`** → MM помечал соединение сломанным и **прекращал доставлять
|
||||
`posted`**, при этом **сокет не закрывал**. Поэтому `mattermost websocket connected` в логе
|
||||
остаётся, а событий нет. Особенно коварно после снятия челленджа: счётчик `seq` пропадал
|
||||
вместе с ним, и **ни один** исходящий фрейм его не нёс.
|
||||
|
||||
**Фикс** (`deltas-mattermost-16-seq.json`): поле `seq int64`, `nextSeq()` под мьютексом,
|
||||
единый `writeEvent(conn, action, data)`, сброс `seq = 0` при новом соединении, пинг через
|
||||
`writeEvent`, а фрейм со `status:FAIL` логируется **warn**, а не игнорируется.
|
||||
|
||||
📌 **Правило:** любая отправка в MM-сокет — **только** через `writeEvent`. «Сырой»
|
||||
`conn.WriteJSON`/`WriteMessage` без `seq` = тихо убитая подписка.
|
||||
📌 **Общий урок:** лог «connection established» не значит, что подписка жива. Проверять
|
||||
**фактом получения события**, а не фактом коннекта. Временный лог каждого кадра окупается
|
||||
сразу — и его стоит оставить после фикса.
|
||||
|
||||
---
|
||||
|
||||
## Связанные заметки
|
||||
|
||||
- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2, где всё это найдено
|
||||
- [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3, деплой на Kraken
|
||||
- [[personal/plans/balda-mm-endtoend-plan]] — сквозной путь `@balda` → ответ
|
||||
- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план
|
||||
- [[personal/tech/hermes-secret-redaction-bypass]] — маскировка секретов в tool-выводе
|
||||
|
||||
---
|
||||
|
||||
## Диаграмма потока ингресса (для отладки нового транспорта)
|
||||
|
||||
```
|
||||
MM websocket frame
|
||||
↓ readLoop (frame.messageType == TextMessage?)
|
||||
↓ handleFrame ← лог «event decoded»; status:FAIL → warn
|
||||
↓ dispatchPosted (IsBotEcho / IsSystemPost / IsDeletedPost → drop с логом)
|
||||
↓ processPosted (MentionsBot / StripMention → drop с логом)
|
||||
↓ InboundProcessor.ProcessInbound ← 🔴 ТУТ был nil (Дефект 5)
|
||||
↓ ingressapp.Service.Process
|
||||
├─ Authorizer.Authorize ← subject + canonicalSubject (Дефекты 3, 7)
|
||||
├─ SessionPreparer.Prepare ← GetSession → RestoreSession → EnsureSession
|
||||
└─ Dispatcher.Dispatch ← durable envelope
|
||||
↓ turn_dispatcher → provider (OPENAI_BASE_URL + CA, Дефект F)
|
||||
```
|
||||
|
||||
**Точки отказа, которые надо проверять по порядку** — сверху вниз, по логам:
|
||||
`frame received` → `event decoded` → `posted event accepted` → `post accepted for processing`
|
||||
→ `conversational ingress settled` (`settlement_stage`!) → `assembled provider user content`
|
||||
→ `turn task failed`. Пока нет строки `assembled provider user content` — провайдер не виноват.
|
||||
Reference in new issue
Block a user