Files
obsidian-vault/personal/tech/balda-go-fx-pitfalls.md
T

474 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Balda — Go/fx pitfalls и метод правок (зеркало скилла)
> Создано 2026-09-26. Обновлено 2026-09-26 (ночь, root-cause сессия) — добавлены:
> **WS-авторизация MM** (§«авторизация — ОДИН механизм»), **`Token:`-заголовок мёртв в MM 11.x**,
> **формат JSON дельт** (плоский массив + 3 аргумента у обёртки).
> Зеркало секции скилла `devops/docker-balda-service`, добавленной 2026-09-26.
> Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3).
> Проектные доки: [[personal/projects/balda/balda-mattermost-connector]],
> [[personal/projects/balda/balda-kraken-mm-deploy]],
> [[personal/projects/balda/balda-mm-delegation-plan]].
---
## 🔴 `go build` = exit 0 ≠ рабочий транспорт
Компилятор не видит ошибок DI-графа. `go build ./...` проходит, а приложение падает
на старте с `fx.ValidateApp`. Проверять транспорт **только** реальным запуском валидации:
```go
prepared, err := loadBaldaCommandConfig(true) // нужен os.Chdir в tempdir с .config/balda/config.yaml
err = validateBaldaApplication(prepared) // ← вот это и есть проверка графа
```
Эталонный тест: `TestValidateCommandRunsValidationWithMattermostTransport`
в `cmd/balda/command_test.go`. На нём поймано 3 дефекта, которые `go build` пропустил.
---
## 🔴 Дефект 1: `fx.In` + внешний `fx.ParamTags`
**Симптом:**
```
Failed: invalid annotation function func(mattermostfx.IngressParams) (*mattermost.Ingress, error):
fx.In structs cannot be annotated with fx.ParamTags or fx.From
```
**Причина:** `fx.Annotate(NewIngress, fx.ParamTags(...))`, где `IngressParams` **уже** содержит `fx.In`.
Теги обязаны жить внутри структуры, снаружи аннотировать нельзя.
**Неправильно:**
```go
fx.Annotate(NewIngress,
fx.ParamTags(``, ``, ``, `name:"balda_mattermost_enabled"`,
`name:"balda_mattermost_bot_user_id"`,
`name:"balda_mattermost_bot_username"`, ``),
),
```
**Правильно:**
```go
// структура — теги на полях
type IngressParams struct {
fx.In
Processor mattermost.InboundProcessor `optional:"true"`
Client *mattermost.Client
Commands *commandcmd.Registry // обязательный, не optional
Enabled bool `name:"balda_mattermost_enabled"`
BotUserID string `name:"balda_mattermost_bot_user_id"`
BotUsername string `name:"balda_mattermost_bot_username"`
Logger zerolog.Logger
}
// предоставление — голое, без fx.Annotate
fx.Provide(NewIngress),
```
Эталон в апстриме: `zulip.ServerParams` + `zulip.NewServer` в `zulipfx/module.go` (строка 24) —
`zulip.NewServer` объявлен **без** `fx.Annotate`.
⚠️ Поле-порт (`*commandcmd.Registry`) делать **обязательным**. `optional:"true"` на нём — лишнее:
в графе он есть всегда, а optional маскирует ошибки конфигурации.
---
## 🔴 Дефект 2: нет провайдеров зарегистрированных строк
**Симптом:**
```
missing types:
- string[name="balda_mattermost_server_url"] (did you mean to Provide it?)
- string[name="balda_mattermost_token"]
- string[name="balda_mattermost_bot_user_id"]
```
**Причина:** модуль читает значения через `name:`-теги, а провайдеров этих имён в composition root нет.
У Zulip они есть (`balda_zulip_*`), для нового транспорта забыли.
**Правило: каждый `name:`-тег в модуле = обязательный провайдер в `app.go`.**
```go
fx.Provide(
fx.Annotate(
func() string { return strings.TrimSpace(cfg.Balda.Mattermost.ServerURL) },
fx.ResultTags(`name:"balda_mattermost_server_url"`),
),
),
```
`strings.TrimSpace` для отсутствующего ключа YAML даёт `""` — без паники, как у Zulip.
Образец: блок `balda_zulip_*` в `internal/apps/balda/app.go` (~строки 576-606).
---
## 🔴 Дефект 3: забытый импорт корневого пакета
`app.go:965: undefined: mattermost` при вызове `mattermost.ValidateConfig`, если импортирован
только `mattermostfx`. Транспорт требует **оба** импорта:
```go
"github.com/baldaworks/balda/internal/apps/balda/channel/mattermost"
"github.com/baldaworks/balda/internal/apps/balda/channel/mattermost/mattermostfx"
```
---
## 🔴🔴 Дефект 4: объявленная команда без actor-хендлера (роняет ВЕСЬ fx-граф)
Найден **только реальным запуском на Kraken** (Фаза 3). До этого Фаза 2 считалась закрытой:
`go build` = 0, `go vet` = 0, `golangci-lint` = 0 issues, `go-arch-lint` = OK, тесты зелёные.
**Ни один из этих гейтов его не видит.**
**Симптом** — crash-loop контейнера (`Restarting (1)`), в логах одна строка на тысячи
`"message":"provided"`:
```
Error: starting Balda app: could not build arguments for function
".../execution".init.func1: failed to build *execution.ActorHost: ...
failed to build *command.Router: received non-nil error from function
"reflect".makeFuncStub: advertised commands have no actor handler: goal
```
**Причина:** в `mattermostfx/module.go` в `commandcmd.Advertisement{Names: [...]}` была
объявлена команда `goal`, которой **не существует** как actor-хендлер. `goal` — это
**payload kind** (`goalkeepercmd.PayloadKindGoal`, `actorcmd.KindGoal`,
`actors/job_actor.go:100`), а снаружи цикл GoalKeeper называется `goalkeeper`.
**Механизм:** `actors/command/router.go:49 ValidateAdvertised()` вызывается из
`commandfx/module.go:306`. Он валидирует **весь** список `Names` одного advertisement и при
первом отсутствующем имени возвращает ошибку → fx не может построить `*command.Router` →
падает `*execution.ActorHost` → падает приложение. Одна опечатка в списке = полный отказ старта,
а не «недоступна одна команда».
**Правильно — сверять `Names` с реальными `Name()`:**
```bash
cd ~/Developer/balda
grep -rn 'func (h \*Handler) Name() string' internal/apps/balda/actors/command/
```
Эталонный фактический список (2026-09-26, `balda@de273d8f`):
```
locator · reset · usage · auto · cancel · close · start · user · topic · skill
goalkeeper · help · plugin
```
`mattermostfx/module.go` после фикса:
```go
Names: []string{
"locator", "reset", "usage", "auto", "cancel",
"goalkeeper", "topic", "close", "start",
"user", "skill",
},
```
`channel/mattermost/inbound.go` — тот же список в inbound allow-list, синхронно.
⚠️ `Zulip`-адаптер изначально был **прав** — там `"goalkeeper"` без `"goal"`. Регрессию внёс
MM-адаптер при копировании «на глаз».
⚠️ **`docs/commands.md` — НЕ источник истины** для имён команд. Там таблица доступности по
транспортам; реестр хендлеров — только `actors/command/*/handler.go`.
⚠️ Фикс — дельта-апплаером: `tools/deltas-goal-command-fix.json` (2 дельты, обе APPLIED).
📌 **Общий вывод (расширяет §«go build = exit 0 ≠ рабочий транспорт» выше):** недостаточно
проверять DI-граф тестом `validateBaldaApplication`. Тест должен прогоняться **с реальным
списком `Names` из модуля**. Но и это не поймало бы опечатку, если тест использует тот же
неверный advertisement — поэтому единственная надёжная проверка нового транспорта =
**живой запуск бинаря**. Для нового транспорта добавлять в чек-лист шаг:
`docker run` (или локальный `balda start`) с включённым транспортом **до** объявления готовности.
✅ **Фикс подтверждён живым запуском (2026-09-26).** После снятия `goal` контейнер `balda-mm`
на Kraken перестал падать и дошёл до рабочего состояния:
```
{"component":"balda.session_manager","balda_provider":"deepseek","message":"session manager ready"}
{"component":"balda.channel.mattermost.ingress","url":"ws://mattermost:8065/api/v4/websocket",
"message":"mattermost websocket connected"}
{"message":"Balda bot started. Press Ctrl+C to stop."}
```
📌 Порядок проверки, который сработал: правка дельтой → `go build ./...` → `go test
./internal/apps/balda/actors/command/...` → пересборка образа → `docker compose up -d` →
`docker logs | grep -E "started|websocket connected"`. **Не** объявлять транспорт готовым
по «exit 0» и «conтейнер Up» — только по этим двум строкам в логах.
---
## ⚠️ Env-override работает только по ключам, уже присутствующим в YAML
`appconfig/loader.go:367` — `SetEnvPrefix("BALDA")` + `SetEnvKeyReplacer(".", "_")` +
`AutomaticEnv()` **по leaf-ключам конфига**. Поэтому блок нового транспорта обязан быть
в `cmd/balda/balda.yaml` — иначе `BALDA_<X>_*` переменные **молча игнорируются**.
Порядок для нового транспорта:
1. блок в `cmd/balda/balda.yaml` (иначе env мёртв)
2. `MattermostConfig`-подобная структура + поле в `BaldaConfig`
3. провайдеры в `app.go`
4. `validate<X>Config` + вызов рядом с `validateSlackConfig`
5. включить в `validateBaldaChannelConfiguration` (`cmd/balda/command.go`) —
иначе деплой только на этом транспорте отбивается «at least one channel is required»
6. `.go-arch-lint.yml` — компоненты `<x>_transport`/`<x>_wiring` + права у
`composition_root` и `ingress_wiring` (см. §go-arch-lint выше)
7. тест на `validateBaldaApplication` с `<x>.enabled=true` (см. §«go build ≠ …» выше)
8. тест env-override в `cmd/balda/command_test.go` (по образцу
`TestLoadConfigDocument_AppliesZulipEnvOverrides`)
9. если добавлял маршрут в `BuiltinRoutes()` — обновить `TestBuiltinRoutes`
10. доки: `docs/reference/configuration.md` (ключи), `docs/commands.md` (колонка транспорта)
11. 🔴 **сверить `Advertisement.Names` с реальными `Name()`** в
`actors/command/*/handler.go` — опечатка роняет весь fx-граф (см. §«Дефект 4» выше)
12. 🔴 **живой запуск** `balda start` (или `docker run`) с включённым транспортом —
единственная проверка, которая ловит дефект 4
**Финальные quality gates (все обязательны, из `AGENTS.md`):**
```bash
go build ./... # 0
go vet ./... # 0
go tool golangci-lint run # 0 issues
go tool go-arch-lint check --project-path . # OK
go test -race ./...
```
⚠️ `go-arch-lint` **не входит** в `golangci-lint` — запускать отдельно, иначе ошибка
архитектуры всплывёт только на CI/ревью.
---
## 🔴 `go-arch-lint` — обязательный шаг для нового транспорта
⚠️ **Это не «опциональная придирка»: `AGENTS.md` прямо требует обновлять
`.go-arch-lint.yml` в том же изменении, что и архитектуру.** Без правки линтер падает
с двумя классами ошибок:
```
Component composition_root shouldn't depend on .../channel/mattermost
File /internal/apps/balda/channel/mattermost/adapter.go not attached to any component in archfile
```
**Что добавить в `.go-arch-lint.yml` (4 правки) — по образцу `zulip`:**
1. **Секция `components:`** — пары `in:`-компонентов:
```yaml
# Provider-owned Mattermost transport. It may depend only on
# transport-neutral contracts; application/runtime adapters live in wiring.
mattermost_transport:
in:
- internal/apps/balda/channel/mattermost
- internal/apps/balda/channel/mattermost/presentation
# Composition-root adapters for Mattermost delivery, prompt, and structured renderers.
mattermost_wiring:
in:
- internal/apps/balda/channel/mattermost/mattermostfx
```
⚠️ Root-пакет и `presentation/` идут в **один** компонент `_transport`, а `_fx`-пакет —
отдельно в `_wiring`. Именно так у `zulip`/`telegram`/`slackagent`.
2. **Правила `mayDependOn`:**
- `mattermost_transport` → `foundation, contracts, neutral_registry, mattermost_transport`
- `mattermost_wiring` → `foundation, contracts, neutral_registry, application, delivery,
storage, mattermost_transport, mattermost_wiring`
3. **`composition_root`** → добавить `mattermost_transport`, `mattermost_wiring`
(иначе «composition_root shouldn't depend on …» — это ожидаемый отказ, там
перечислены все транспорты).
4. **`ingress_wiring`** → добавить `mattermost_transport` (соседствует с `telegram_transport`,
`zulip_transport`).
Проверка: `go tool go-arch-lint check --project-path .` → должно быть `OK - No warnings found`.
---
## 🔴 `golangci-lint` на новом транспорте: мёртвый typing-throttle
Первый прогон дал 4 замечания — все в новом коде. Важный урок: **не гейтить линтер
nolint-комментарием, а удалять мёртвый код.**
| Замечание | Что было | Фикс |
|---|---|---|
| `inamedparam` ×2 | `Start(context.Context) error` в interface-assertion | `Start(ctx context.Context) error` |
| `unused` | `shouldSendTyping()` — недостижим, т.к. `SendTyping` всегда no-op | удалён |
| `unused` | `websocketReceiveTimeout` — константа без использования | удалена |
| `unlambda` | `func(client, logger) *Adapter { return NewAdapter(...) }` | заменено на `mattermost.NewAdapter` |
**Корневая причина `unused`:** механизм typing-throttle (`shouldSendTyping`, поля
`typingThrottleInterval`/`typingLastSentAt`, `typingMu sync.Mutex`,
`SetTypingThrottleInterval`) был скопирован из Zulip по аналогии. Но у Mattermost
**ботов нет typing API** (`SendTyping` осознанно возвращает `nil`), поэтому весь механизм
недостижим. Удалён целиком — не заглушён.
**Правило: при копировании транспорта с Zulip проверять каждую фичу на применимость.**
Zulip-специфичное, что НЕ переносится в транспорт без поддержки:
- typing-throttle (нужен интерактивный typing API)
- логика `sendWithPlainFallback` — переносится, но только на content-rejection
⚠️ **Каскад после удаления полей:** удаление `typingThrottleInterval` потянуло `SetTypingThrottleInterval`
→ `sync` (импорт) → `zerolog` (импорт в `module.go`). Удалять надо **всей группой**,
иначе серия `undefined`/`imported and not used`.
---
## ✅ Тест-регрессия: `BuiltinRoutes` и длина списка
Добавление маршрута в `BuiltinRoutes()` (`deliveryfmt/routes.go`) **ломает** `TestBuiltinRoutes`
в `deliveryfmt/registry_test.go`, если не обновить ожидание:
```
len(BuiltinRoutes()) = 5, want 4
```
Фикс — добавить строку в map `want`:
```go
{transport: TransportMattermost, deliveryFormat: DeliveryFormatNone}: NamePlainText,
```
📌 Нашлось только сравнением «с правками / без правок» через `git stash` — в списке
предсуществующих падений апстрима этого теста **не было**, значит это наша регрессия.
---
## ✅ 13 пакетов падают в апстриме изначально
Не пытаться «чинить» как часть своей задачи — это дефект апстрима. Проверять `git stash`
перед выводами о регрессии.
Падающие в `baldaworks/balda@de273d8f`:
- `TestInitCommand_NonInteractiveAutoSelectsRootAndGeneratesDetectedAgents` (cmd/balda)
- `TestMigrateSessionMemoryLayout_*` ×4 (state)
- `TestWorkspaceMCPImport/Export_UsesPersistedSessionMetadata` (session)
- `TestPluginResolverResolvesContainedArgvFilteredEnvAndLaunchSecrets` (mcpruntime)
- `TestResolveWorkspaceBaseBranch_ConfigPreferredWhenValidVal`, `TestCanonicalSessionMemory*` (apps/balda)
**Метод проверки регрессии:**
```bash
git stash push -q -m baseline --include-untracked
go test ./<пакет>/ -count=1 # смотрим падения БЕЗ правок
git stash pop -q # правки возвращаются
```
⚠️ `git stash` может задеть чужие стэши — в форке лежат `whale-20260623` и
`feat/zulip-events-polling`. Проверять `git stash list` до и после.
---
## Метод правок: дельта-апплаер вместо sed
Правило Alex: **никаких in-place скриптов и sed**. Инструмент в `~/Developer/balda/tools/`:
| Файл | Роль |
|---|---|
| `apply-project-deltas.py` | движок (JSON-дельты + состояние) |
| `run-project-deltas.sh` | обёртка `check` / `apply` / `list` |
| `deltas-<topic>.json` | группы правок |
| `.deltas-state.json` | sha256 применённых якорей |
```bash
cd ~/Developer/balda
bash tools/run-project-deltas.sh check tools/deltas-mattermost.json tools/.deltas-state.json
bash tools/run-project-deltas.sh apply tools/deltas-mattermost.json tools/.deltas-state.json
```
**Формат дельты:**
```json
{ "file": "путь/от/корня", "note": "что делаем",
"search": "уникальный фрагмент текущего текста",
"replace": "чем заменить" }
```
**Три исхода:** `replace` уже есть → `skipped` (идемпотентно); `search` ровно 1 раз → `applied`;
ни того ни другого → `error: ЯКОРЬ НЕ НАЙДЕН`.
📌 `check` **обязателен перед `apply`** — на нём поймано, что дельта валидации целилась
в несуществующий `validate()` в `config.go` (валидация живёт в `app.go`), и переписана до записи.
⚠️ Дельты с длинным `replace` писать в JSON через `write_file`, **не** heredoc — кавычки и `\n`
ломаются молча. Это тот же питфолл, что у SSH-heredoc с вложенными кавычками.
🔴 **JSON дельт — ПЛОСКИЙ МАССИВ, не объект.** Формат-обёртка
`{"deltas": [...]}` (или любой объект с ключами) роняет апплаер:
```
TypeError: string indices must be integers
File "apply-project-deltas.py", line 91, in main
path = root / d["file"]
```
Причина: `main()` итерирует `for i, d in enumerate(deltas)` — если `deltas` объект, `d`
становится строкой-ключом. Проверено 2026-09-26 на `deltas-mattermost-14.json`.
**Правильно:** файл начинается с `[`, а не с `{`.
⚠️ Обёртка `run-project-deltas.sh` требует **ТРИ** аргумента, а не два:
`run-project-deltas.sh <check|apply|list> <deltas.json> <state.json>`.
Без третьего — `line 9: 3: нужен файл состояния`.
И `check` не поддерживает `--list`: `list` — отдельный глагол (`$1 = list`).
---
## 🔴 Mattermost WebSocket: авторизация — ОДИН механизм, не два
Найдено живым запуском (Фаза 3, root-cause). Симптом: в логе
`mattermost websocket connected`, но **ни одного** события `posted` в инбаунде, даже
на `logger.level: debug`. При этом пробник (pure Go stdlib) ловил `posted` на том же сокете.
**Две причины, наложившиеся друг на друга:**
### 1. `Origin` в handshake → 403 CORS
Если клиент шлёт `Origin: http://mattermost:8065`, а `SiteURL=https://mm.qentra.top` —
MM отвечает:
```
HTTP/1.1 403 Forbidden
{"id":"api.web_socket.connect.upgrade.app_error","message":"URL Blocked because of CORS. Url: …"}
```
📌 **`Origin` не слать вообще.** Балда его не шлёт — с ней этой проблемы нет.
Это ловушка для пробников и внешних клиентов.
### 2. `authentication_challenge` поверх `Authorization: Bearer` → подписка умирает
`channel/mattermost/ingress.go`, `streamOnce`:
```go
conn, _, err := dialer.DialContext(ctx, endpoint, header) // header: Authorization: Bearer ***
// …
s.logger.Info().Msg("mattermost websocket connected") // ← лог УЖЕ «здоровый»
conn.WriteJSON(map[string]any{ // ← 🔴 а это ломает подписку
"seq": 1, "action": "authentication_challenge",
"data": map[string]string{"token": token},
})
```
**Эмпирика (пробник, `-authheader`):**
- с челленджем: `hello` → `status_change` → **тишина 40 с**, хотя пост в канал создаётся;
- без челленджа: `hello` → `posted` штатно.
То есть избыточный челлендж заставляет MM переинициализировать сессию сокета, и `posted`
на него больше не пушится. **Диагностически коварно:** `websocket connected` логируется
**до** челленджа, поэтому лог выглядит здоровым.
**Правило:** сокет авторизуется **либо** заголовком на dial, **либо** `authentication_challenge`.
Оба сразу — тишина. Если заголовок есть, челлендж слать **нельзя**.
**Фикс** — дельта `tools/deltas-mattermost-14.json` (2 дельты: убрать `WriteJSON`-блок
и unused-константу `websocketActionAuthChallenge`). Образ `balda-mm:0.1.2`.
📌 **Общий урок для транспортов:** лог «connection established» ничего не говорит о том,
что подписка жива. Проверять **фактом получения события**, а не фактом коннекта.
Для MM — пробник из `~/Developer/balda-wsprobe/` (pure stdlib, RFC6455, собирается нативно
на Mac, на хост кладётся статический бинарь).
### 🔴 MM 11.x: заголовок `Token:` для API мёртв
`POST /users/login` отдаёт сессию в **заголовке ответа** `Token:`. Но использовать её нужно
**только** как `Authorization: Bearer *** — заголовок `Token: <session>` в запросах даёт
`401 api.context.session_expired.app_error` при живой сессии (в БД `expiresat` = +180 дней).
Старый стиль MM 5.x больше не принимается. Проверено 2026-09-26.
📌 Отсюда же: если скрипт падает с `session_expired` — сначала проверить, каким **именем
заголовка** шлётся токен, а не искать проблему в пароле/лимите сессий.
---
## Связанные заметки
- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2, где всё это найдено
- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план
- [[personal/tech/hermes-secret-redaction-bypass]] — маскировка секретов в tool-выводе