# 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__*` переменные **молча игнорируются**. Порядок для нового транспорта: 1. блок в `cmd/balda/balda.yaml` (иначе env мёртв) 2. `MattermostConfig`-подобная структура + поле в `BaldaConfig` 3. провайдеры в `app.go` 4. `validateConfig` + вызов рядом с `validateSlackConfig` 5. включить в `validateBaldaChannelConfiguration` (`cmd/balda/command.go`) — иначе деплой только на этом транспорте отбивается «at least one channel is required» 6. `.go-arch-lint.yml` — компоненты `_transport`/`_wiring` + права у `composition_root` и `ingress_wiring` (см. §go-arch-lint выше) 7. тест на `validateBaldaApplication` с `.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-.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 `. Без третьего — `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: ` в запросах даёт `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-выводе