# Фаза 3 — Балда на Kraken + Mattermost (деплой) > Создан 2026-09-26. Обновлён 2026-09-26 (**✅ ФАЗА ЗАКРЫТА — боевая приёмка пройдена**). > Статус: **✅ РАБОТАЕТ. Бот отвечает аккаунту Alex.** Образ `0.1.10` (коммит `9f1e6c9c`, > он же PR baldaworks/balda#8). > **Доказательство (лента `balda-test`):** `cbwsorzwoigbtrdnteugnx4aqo` (Alex) — > `@balda скажи одно слово` → `edxrgupoxiyig5gzmr79tjypbme` (бот) — `слово`. > `settlement_stage=accepted`, `bad_seq`=0, `RestartCount`=0. > Подробный разбор приёмки — [[personal/plans/balda-mm-pr-plan]] §«БОЕВАЯ ПРИЁМКА». > Pull request (Фаза 2 → апстрим): **https://github.com/baldaworks/balda/pull/8** — 8/8 CI PASS. > Питфоллы и метод — [[personal/tech/balda-go-fx-pitfalls]]. > Часть проекта [[personal/projects/balda/balda-mm-delegation-plan]]. > Предыдущие фазы: [[personal/projects/balda/mattermost-kraken-deploy]] (Фаза 1, ✅), > [[personal/projects/balda/balda-mattermost-connector]] (Фаза 2, ✅ — код + PR #8). --- ## ✅ ПРИЁМКА ПРОЙДЕНА (2026-09-26, образ `balda-mm:0.1.10`) **Сквозной путь проверен ОТ АККАУНТА ALEX — не от тестового.** Прошлая «приёмка» от `testuser` была ложной; эта — настоящая. ### Лента канала `balda-test` (`7bjw4g7o1jdsbmjbqd649xxgaw`) ``` 1790413414689 | edxrgupoxiyig5gzmr79tjypbme | слово ← ОТВЕТ БОТА 1790413409055 | cbwsorzwoigbtrdnteugnx4aqo | @balda скажи одно слово ← пост ALEX ``` ### Лог обработки — авторизация прошла ```json {"component":"balda.handlersfx.mattermost","session_id":"mm-c-7aafea13", "settlement_outcome":"accepted","settlement_stage":"accepted","error_class":"none", "message":"conversational ingress settled"} {"message":"assembled provider user content","input_text_char_count":30} ``` **`settlement_stage=accepted`** — не `unauthorized`. Именно этого не хватало всё время. ### Привязка владельца — чистая ``` channel_type = mattermost principal = cbwsorzwoigbtrdnteugnx4aqo ← БЕЗ префикса (требование GetUserByBinding) display_name = mattermost:cbwsorzwoigbtrdnteugnx4aqo ← ОДИНАРНЫЙ префикс, мусора нет ``` Двойной префикс вылечен **в коде** (идемпотентный `MattermostSubject`), а не в БД — в этом была ошибка прошлого раза. ### Метрики здоровья | Метрика | Значение | |---|---| | `bad_seq` / `Invalid sequence` | **0** ✅ | | `pong` | 6 (пинг работает) | | `RestartCount` | **0** ✅ | | Образ | `balda-mm:0.1.10` (коммит `9f1e6c9c`) | | Старт | `mattermost transport configured` → `websocket connected` → `Balda bot started.` | ### Как деплоилось Сборка на Mac из PR-коммита → `docker save` → zstd (**распаковка на Mac**) → `docker load` на Kraken → `tools/mm-switch-image.sh` (бэкап compose + `--force-recreate`). ⚠️ Проверка CA в образе: `docker save` даёт OCI-формат, файлы в `blobs/sha256/*` — `tar -tf image.tar | grep ca-certificates` даст **ложный 0**. Смотреть `docker inspect` → `SSL_CERT_FILE` в `Config.Env`, либо распаковывать слои. ### ⚠️ Известные не-блокеры 1. **`warn: getMe response missing result`** при старте — косметика startup-лога, на работу не влияет (`bot_user_id_set=true`, ответы идут). 2. **Сессия `mm-c-7aafea13` хранит `transport_user_id = c6udeze8dfrdxm5jbgq55bd58r`** (это `testuser` — id прошлого владельца, записан при создании сессии в прошлой сессии). Авторизация идёт по `principal` из bindings и проходит верно, ответ доставляется. Чистая сессия — `/reset` в канале. На работу не влияет (проверено). ### Инструменты деплоя | Скрипт (в `~/Developer/balda/tools/`, git-ignored) | Назначение | |---|---| | `mm-switch-image.sh` | смена тега образа в compose + бэкап + recreate (идемпотентно) | | `mm-whoami.sh` | какой MM-аккаунт у PAT (`/users/me`) — **обязательно перед приёмкой** | | `mm-post-as.sh`, `mm-post-user.sh` | пост от человека (единственный валидный тест ингресса) | | `mm-get-owner-token.sh` | `owner_auth_token` из `state.db` через `strings` | | `mm-fix-owner-principal.sh` | снятие транспортного префикса с `principal` (легаси-фикс) | 🔴 **Приёмка обязывает сверять аккаунт PAT ДО постинга** (`mm-whoami.sh /tmp/admin-tok` → `cbwsorzwoigbtrdnteugnx4aqo | admin`). Иначе тест уходит от чужого аккаунта = ложная приёмка. --- ## 🔴 ИСТОРИЧЕСКИЙ РАЗДЕЛ: как искали причину тишины (все закрыто) > Ниже — путь диагностики. Все причины устранены, приёмка пройдена (см. выше). --- ## 🔴 СТАТУС (устаревший): НЕ ГОТОВО — бот не отвечает аккаунту Alex 🔴 **Предыдущая запись «ИТОГ: бот работает, приёмка пройдена» была ЛОЖНОЙ.** Сквозной путь проверялся от `testuser` (аккаунт, созданный для тестов), и именно `testuser` оказался привязан владельцем. Alex пишет `@balda привет` — в логе `settlement_stage="unauthorized"`. **Актуальная лента канала `balda-test` (`7bjw4g7o1jdsbmjbqd649xxgaw`):** ``` 1790409344288 | cbwsorzwoigbtrdnteugnx4aqo | @balda привет ← Alex, БЕЗ ответа 1790409329367 | cbwsorzwoigbtrdnteugnx4aqo | Привет ← без @balda, отброшен намеренно 1790409148277 | edxrgupoxiyg5gzmr79tjypbme | работает ← ответ бота (testuser) 1790409145401 | c6udeze8dfrdxm5jbgq55bd58r | @balda reply with a single word ``` **Лог на пост Alex:** ```json {"component":"balda.handlersfx.mattermost", "inbound_id":"mattermost:dqagqce7pibiipt3qtqnkgfd8c", "settlement_outcome":"terminal","settlement_stage":"unauthorized"} ``` **Корень (причина №7):** `balda_user_bindings` содержит одну строку — `mattermost | c6udeze8dfrdxm5jbgq55bd58r` = **`testuser`**. Аккаунт Alex — `cbwsorzwoigbtrdnteugnx4aqo` (`admin`, `system_admin`) — **в таблице отсутствует**. `IsOwnerSubject` ищет строго по `(channel_type='mattermost', principal=)` → для Alex пары нет → все проверки авторизации false. 📌 **Урок приёмки (durable): проверять ОТ ИМЕНИ ЗАКАЗЧИКА.** Сквозной путь, подтверждённый чужим тестовым аккаунтом, — это НЕ приёмка. Если владелец — Alex, тест обязан идти с его MM user_id. Подробный разбор замкнутого круга привязки — [[personal/plans/balda-mm-endtoend-plan]] §«ПОЧЕМУ НЕ РАБОТАЕТ У ALEX». **Остаток работы — одна строка в БД** (ожидает апрува Alex): ```sql UPDATE balda_user_bindings SET principal = 'cbwsorzwoigbtrdnteugnx4aqo', display_name = 'mattermost:cbwsorzwoigbtrdnteugnx4aqo' WHERE channel_type = 'mattermost' AND principal = 'c6udeze8dfrdxm5jbgq55bd58r'; ``` + бэкап `state.db` с `-wal` до правки; + правка кода `handler.go:420` (двойной префикс, см. §«Причина №7» в плане) и пересборка `0.1.9`, чтобы дефект не вернулся. --- ## 📋 ИСТОРИЯ: что реально работало (образ `0.1.8`, от `testuser`) Лента канала `balda-test` на момент тестов от `testuser` — REST `GET /channels/{id}/posts`: ``` c6udeze8dfrdxm5jbgq55bd58r (testuser) | @balda reply with a single word edxrgupoxiyg5gzmr79tjypbme (bot) | работает ``` Провайдер ответил: `event_author=deepseek`, `text_char_count=16`, `final_response=true`, `delivery settled` → `settlement_class=success`. `x509` и `api.openai.com` ушли из логов. 📌 **Проверка «виден в UI» агентом без GUI:** читать ленту 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)\""' ``` --- ## 🔴 История причин тишины (шесть штук, все закрыты) | № | Причина | Фикс | Проверено | |---|---|---|---| | 3 | `bad_seq` — WS-пинг без `seq` убивал подписку молча | дельта 16 | ✅ `bad_seq` 0 вхождений, `pong seq_reply:1,2,3` | | 4 | `Processor == nil` — MM-хендлер отсутствовал в DI | новый файл + дельта 19 | ✅ лог `post accepted for processing` | | 5 | `ChannelMattermost` отсутствовал в `auth` → `unsupported transport` | дельта 17, 18 | ✅ | | 5a | `/start` отбивался проверкой доступа **до** привязки (тупик онбординга) | правка `HandleCommand` | ✅ команда прошла в runtime | | 5b | `principal` в БД с транспортным префиксом → `unauthorized` | UPDATE с бэкапом | ✅ `assembled provider user content` | | **6** | **`base_url` в YAML мёртв + `FROM scratch` без CA** → `api.openai.com` + `x509` | дельта 22 + `.env` | ✅ **ответ провайдера получен, 16 символов** | **Итоговый проход в логах (образ `0.1.8`):** ``` mattermost websocket event decoded event=posted mattermost post accepted for processing text="reply with a single word" session created successfully session_id=mm-c-7aafea13 assembled provider user content input_text_char_count=424 received provider event event_author=deepseek text_char_count=16 final_response=true delivery settled settlement_class=success ``` **Актуальные артефакты:** образ **`balda-mm:0.1.8`** · `.env` — **6 строк** (включая `OPENAI_BASE_URL=https://api.deepseek.com/v1`) · владелец привязан как `channel_type='mattermost'`, `principal='c6udeze8dfrdxm5jbgq55bd58r'` (без префикса!) · бэкапы состояния в `…/docker/balda/backups/`. ### 🔴 Причина 6 (последняя) — двойной дефект провайдера `runtime.providers.deepseek.openai.base_url` в YAML **молча игнорируется**: тип `LocalAPIConfig` (`normahq/runtime/v2@v2.0.11/agentconfig/config.go:82`) имеет **только** `api_key` и `model`. Базовый URL читается **только** из env — `hostedagent/openai.go:24`: ```go func openAIBaseURL() string { if u := os.Getenv("OPENAI_BASE_URL"); u != "" { return strings.TrimRight(u, "/") } return "https://api.openai.com/v1" } ``` 📌 Для любого OpenAI-совместимого провайдера базовый URL задавать **только** через `OPENAI_BASE_URL` в `.env`. Ключ `base_url` в YAML выглядит рабочим — это ловушка. Второй дефект — тот же корень: `FROM scratch` **не содержит `/etc/ssl/certs`**, поэтому **любой** исходящий HTTPS падал с `x509: certificate signed by unknown authority`. Фикс (дельта `tools/deltas-mattermost-22-ca.json`): ```dockerfile COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ``` 📌 Правило: любой `scratch`/`distroless`-образ, делающий исходящие HTTPS-запросы, обязан копировать `ca-certificates.crt` из builder'а **и** выставлять `SSL_CERT_FILE`. ⚠️ Симптом обманчив — выглядит как «сервер с неверным сертификатом», а на деле доверять нечему. --- ## 🔴 ГЛАВНОЕ: третья причина тишины найдена — это был НЕ ингресс, а MM-авторизация сокета Прошлая сессия заканчивалась версией «событие теряется между `readLoop` и `handleFrame`». **Версия оказалась неверной.** Диагностический лог каждого кадра (дельта 15) показал иную картину: события **не приходили вообще**, а MM отвечал на пинги ошибкой. ### Причина 3 (настоящая) — `bad_seq`: MM отбрасывал пинг и переставал пушить события ``` {"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error", "message":"Invalid sequence for WebSocket message."}} ``` раз в 30 секунд. Балда слала пинг как `{"action":"ping"}` — **без `seq`**. Mattermost требует монотонный `seq` в каждом клиентском фрейме; на невалидном он помечал соединение сломанным и **переставал доставлять `posted`, не разрывая сокет**. Отсюда обманчивый «`websocket connected`, но тишина». ⚙️ Усугублено дельтой 14: убрав `authentication_challenge`, я убрал и его `seq:1` — после этого **ни один** исходящий фрейм не нёс `seq`. **Фикс** — дельта `tools/deltas-mattermost-16-seq.json`: поле `seq int64` в `Ingress`, `nextSeq()` под мьютексом, `writeEvent(conn, action, data)` как единственный путь отправки, сброс счётчика на новом соединении, `status:FAIL` логируется warn'ом. ✅ Проверено: `bad_seq` — 0 вхождений, в логах `pong seq_reply:1, 2, 3`. ### Причина 4 — `Processor == nil`: посты принимались и молча отбрасывались После фикса `bad_seq` событие дошло до инбаунда, залогировалось как `mattermost post accepted for processing` — и дальше тишина. В `processPosted`: ```go if s.processor == nil { return // ← сюда и попадали } ``` В `mattermostfx/ingress.go` поле объявлено `Processor mattermost.InboundProcessor \`optional:"true"\``, но **никто в DI его не предоставлял**: в `handlersfx/` были только `zulip_ingress.go` и `telegram_ingress.go`. **Ингресс работал глухим приёмником.** ⚠️ `optional:"true"` сделал разрыв невидимым: fx не падает, `go build`/`go vet` молчат, тестов на MM-транспорт нет. --- ## Что сделано в этой сессии ### 1. Написан `handlersfx/mattermost_ingress.go` (~320 строк, новый файл) Своя реализация `mattermost.InboundProcessor` — апстрим такого не содержит (`git ls-tree upstream/main` даёт только `telegram*` и `zulip_ingress.go`). Ключевое: **вся логика уже была вынесена в `ingressapp.Service`** (транспорт-нейтральный: authorize → prepare session → dispatch), поэтому хендлер — **тонкая обёртка**, а не копия 1203-строчного zulip-хендлера. Образец — `telegram_ingress.go` (695 строк). Методы: `ProcessInbound`, `HandleCommand` (публикует в `commandcmd.Ingress`), `HandleUnsupportedCommand`, плюс `ingressService()` / `authorizeInbound` / `prepareSession` / `dispatchInbound`. 🔴 **Отличие от telegram:** `mattermost.InboundMessage` **не содержит** поля `Post` (есть `Text`, `SenderID`, `SenderName`, `PostID`, `ReceivedAt`), а `NormalizeInbound` принимает `Post` — поэтому `mattermost.Post` собирается руками. ### 2. `auth` — поддержка `mattermost:` subject (дельты 17, 18) | Файл | Изменение | Почему | |---|---|---| | `auth/channel_token.go` | `ChannelMattermost = "mattermost"` + добавлен в список bind-токенов | иначе MM не получает owner-bind токен | | `auth/canonical_users.go` | ветка `case ChannelMattermost` | 🔴 без неё `canonicalSubject` → `unsupported transport "mattermost"` → **все проверки авторизации молча false** | | `auth/owner.go` | `MattermostSubject(userID string)` | рядом с `Telegram/Zulip/SlackSubject` | | `channel/mattermost/locator_codec.go` | `ChannelIDOf(locator)` | достаёт `channel_id` из `AddressJSON` | 📌 `userSubject()` в `actors/command/start/handler.go` **уже** выдавал `mattermost:` через default-ветку — `/start` работает с MM без правок. ### 3. Регистрация в DI (дельта 19) `handlersfx/module.go`: `fx.Annotate(newMattermostInboundHandler, fx.As(new(baldamattermost.InboundProcessor)))`. ### 4. Quality gates и деплой | Гейт | Результат | |---|---| | `go build ./...` | ✅ exit 0 | | `go vet ./...` | ✅ чисто | | `go tool go-arch-lint check` | ✅ **OK - No warnings found** | | Тесты проекта | ⚠️ 11 предсуществующих фейлов в `cmd/balda` / `internal/apps/balda` (workspace/plugin/session-memory) — **проверено `git stash` моей правки, падают и без неё**, к MM не относятся | | Образ | ✅ `balda-mm:0.1.5`, собран на Mac, задеплоен, Up без ошибок старта | ### 5. Доказательство, что хендлер вызывается ```json {"component":"balda.channel.mattermost.ingress","text":"скажи одно слово", "message":"mattermost post accepted for processing"} {"component":"balda.handlersfx.mattermost","session_id":"mm-c-7aafea13", "inbound_id":"mattermost:3b6wg9e1z3rgun3uu7di11w3ja", "settlement_stage":"unauthorized","message":"conversational ingress settled"} ``` Пост дошёл до `ingressapp` и отбился **на авторизации** — это корректное поведение. Раньше на этом месте была тишина. --- ## ✅ Привязка владельца — ВЫПОЛНЕНА, но ПРИВЯЗАН НЕ ТОТ АККАУНТ (2026-09-26) Авторизация Balda — **subject-based**: владелец в `canonical` store привязан строкой `:`. Для MM `channel_type='mattermost'`, `principal=` **без** транспортного префикса. **Итоговое состояние `balda_user_bindings` (актуальное):** ``` binding_id d2006cd1-3337-4159-883b-fcb90c6e9be2 user_id b6517e56-1110-4cb9-a301-302c585fbd0d ← администратор channel_type mattermost principal c6udeze8dfrdxm5jbgq55bd58r ← 🔴 testuser, НЕ аккаунт Alex display_name mattermost:mattermost:c6udeze8… ← 🔴 мусор, двойной префикс остался provenance verified-owner-onboarding ``` 🔴 **Это и есть причина, почему Alex не получает ответов.** Привязка сделана от `testuser` — аккаунта, созданного мной для тестов. Аккаунт Alex (`cbwsorzwoigbtrdnteugnx4aqo`) не привязан. Требуется `UPDATE` (см. §«СТАТУС» вверху). ⚠️ `display_name` с двойным префиксом — след того же дефекта кода (`handler.go:420`). В прошлой сессии я поправил `principal` прямой правкой БД, но **не код**, поэтому мусор в `display_name` и `created_by` channel-токенов остался, а дефект воспроизводится. 🔴 **Правило: чинить в коде, а не в данных.** **Как привязка делалась (порядок, который сработал для первого аккаунта):** 1. `/start owner=` **в DM** — команда `/start` работает только при `Direct=true`, в канале отбивается. Скрипт `tools/mm-owner-bind.sh `: логин → `POST /api/v4/channels/direct` с `[, ]` → `POST /api/v4/posts` с текстом `/start owner=`. 2. Получить токен из `state.db`: ```bash sqlite3 "$DB" "SELECT value_json FROM balda_app_kv WHERE key='owner_auth_token';" # или: sudo -n strings …/state/state.db | grep -o "appowner_auth_token.\{0,45\}" ``` ⚠️ Значение токена в доке **не хранится** — только способ получения. 3. 🔴 Первая попытка дала **«You are not allowed to use Balda in this channel.»** — `HandleCommand` проверял доступ **до** обработки, а владельца ещё нет (тупик онбординга). Фикс: `if cmd.Command != commandStart && !h.canAccess(...)` — по образцу zulip. 4. 🔴 Вторая проблема: `/start` вернул успех, но `principal` записался **с префиксом** (`mattermost:c6udeze…`), а `GetUserByBinding` сравнивает точно → `unauthorized` навсегда. Фикс: `tools/mm-fix-owner-principal.sh` (бэкап → стоп → UPDATE → старт). 📌 Подробный разбор обеих ловушек — [[personal/tech/balda-go-fx-pitfalls]] §«Дефект 6», §«Дефект 7». --- ## Текущее состояние (2026-09-26, 🔴 ФАЗА НЕ ЗАКРЫТА) | Компонент | Статус | |---|---| | Бот `balda` в MM | ✅ создан, токен рабочий, в команде и канале | | Образ | ✅ **`balda-mm:0.1.8`** (актуальный), задеплоен | | Контейнер `balda-mm` | ✅ Up, без ошибок старта | | Провайдер DeepSeek | ✅ работает: `event_author=deepseek`, ответ получен | | **WebSocket к Mattermost** | ✅ 101 Switching Protocols, `pong seq_reply` растёт, `bad_seq` — 0 | | Причина «челлендж» (дельта 14) | ✅ исправлена | | Причина «bad_seq» (дельта 16) | ✅ **исправлена, проверена** | | Причина «Processor==nil» (дельты 17-19) | ✅ **исправлена, доказана логом** | | Причина «`/start` закрыт доступом» | ✅ **исправлена** | | Причина «`principal` с префиксом» | ⚠️ **исправлена в БД, НО НЕ В КОДЕ** — дефект воспроизвёлся (см. причину №7) | | Причина «провайдер `base_url` + CA» (дельта 22) | ✅ **исправлена, ПРОВЕРЕНА** | | **Ответ бота аккаунту Alex** | 🔴 **НЕ РАБОТАЕТ — `unauthorized`. Аккаунт Alex не привязан** | | Ответ бота `testuser` | ✅ работает (это НЕ приёмка — чужой аккаунт) | | Push-уведомления | 🔴 **невозможны на Team Edition** — диагноз снят, см. §Push ниже | | MM-хендлер `handlersfx/mattermost_ingress.go` | ✅ написан, подключён в DI | | Quality gates (build/vet/arch-lint) | ✅ все зелёные | 🔴 **НЕ приёмка.** Требуется: привязать аккаунт Alex (`UPDATE balda_user_bindings`), затем проверить `@balda привет` **от его аккаунта** и увидеть ответ в ленте канала. Правку кода (`handler.go:420`) сделать в том же заходе — иначе двойной префикс вернётся. --- ## 🔴 Push-уведомления — невозможны на Team Edition (диагностика завершена) Задача Alex «уведомления в приложения неактивны» разобрана. **Это ограничение продукта, не настройка.** `Build Enterprise Ready: false` — Team Edition. **Конфиг (факт):** ``` EmailSettings.SendPushNotifications false EmailSettings.PushNotificationServer "" ← пусто EmailSettings.SendEmailNotifications true EmailSettings.SMTPServer localhost / Port 10025 ← SMTP не слушает (exit 7) ``` 🔴 Тумблер лежит в **`EmailSettings`**, не в `ServiceSettings` — запрос `mmctl config get ServiceSettings.EnablePushNotifications` даёт **`Error: invalid key`**. **Три пути, все закрыты:** | Путь | Вердикт | |---|---| | Официальный push-прокси | Прокси жив (HTTP 200), но **отвергает наш инстанс**: `Did not send message because of missing platform property`. APNs/FCM-сертификаты на прокси вешаются **только для лицензированных EE** | | Свой MPNS (`mattermost-push-proxy`) | 🔴 Требует **собственные подписанные сборки приложений** + Apple Developer ($99/год) + Firebase. **Официальные приложения к своему MPNS не подключатся** — прошиты на bundle ID Mattermost Inc. | | SMTP-почта | ⚠️ Работает в TE **без лицензии**, но SMTP не настроен: на хосте нет слушателей на 25/465/587/10025 | **Варианты для Alex:** лицензия Professional (~$10/юзер/мес) · свои сборки + свой MPNS · SMTP-почта вместо push (без затрат, ~15 мин) · оставить как есть. ⚠️ **Живая проба прокси обязательна** — HTTP 200 не означает доставку, читать **тело** ответа (`"status":"FAIL"`). `DiagnosticId` берётся из `SELECT value FROM systems WHERE name ILIKE '%diagnostic%';` (таблица `systems`, не `Systems`). 📌 Команда Alex по этому пункту: решить, делаем ли SMTP. Провайдер push на TE не включается. --- ## Команда Alex (2026-09-26) > «Ок, разворачивай бота на кракене с привязкой к ММ. Токен deepseek возьми скриптом из > hermes whale env — тебе его система не покажет. Сборка на маке.» > Затем: «Делай. До конца. Вопросов не задавать, команды требующие подтверждения не > использовать, при отсутствии подтверждения команды от меня использовать другую команду. > Без результата не останавливаться.» > Затем (после паузы): **«Хули ты встал?! Я кажется сказал не останавливаться пока > прод-левел реализация не будет закончена»** — 🔴 Alex **не приемлет остановок** на > промежуточном результате. Пока сквозной путь не работает — не докладывать «готово», > а продолжать. Статус докладывать только с указанием, что именно осталось и что делаю дальше. --- ## 🔴 Найден и исправлен дефект Фазы 2: объявленная команда без actor-хендлера Первый запуск дал **crash-loop**: ``` Error: starting Balda app: ... failed to build *command.Router: advertised commands have no actor handler: goal ``` **Причина — моя регрессия в MM-адаптере.** В `advertisement.Names` я объявил команду `goal`, но такого actor-хендлера в проекте **нет**. Реальные имена из `internal/apps/balda/actors/command/*/handler.go`: ``` locator · reset · usage · auto · cancel · close · start · user · topic · skill goalkeeper · help · plugin ``` `goal` — это **payload kind** (`goalkeepercmd.PayloadKindGoal`, `actorcmd.KindGoal`, `actors/job_actor.go:100`), а не имя команды. Цикл GoalKeeper снаружи называется **`goalkeeper`**. Zulip-адаптер изначально был прав (`"goalkeeper"` без `"goal"`). **Механизм падения:** `actors/command/router.go:49 ValidateAdvertised()` вызывается из `commandfx/module.go:306` и роняет **весь fx-граф**, а не только команду. Симптом в UI — бесконечный `Restarting (1)`, в логах — тысячи строк `"message":"provided"` и одна `error`. **Фикс** (`tools/deltas-goal-command-fix.json`, дельта-апплаер): | Файл | Что | |---|---| | `channel/mattermost/mattermostfx/module.go` | убран `"goal"` из `Names` | | `channel/mattermost/inbound.go` | убран `"goal"` из inbound allow-list | `go build ./...` = 0, тесты `actors/command/...` — все зелёные. Пересобран образ `0.1.1`. 📌 **Урок (дополнить [[personal/tech/balda-go-fx-pitfalls]]):** `go build`/`go vet`/тесты **не видят** несоответствие advertisement ↔ actor-хендлеры. Ловится **только реальным запуском** с включённым транспортом. Для любого нового транспорта: сверять список `Names` с `Name()`-методами `actors/command/*/handler.go`, а не с таблицей в `docs/commands.md`. ⚠️ `docs/commands.md` **врёт** про имя команды — там таблица доступности, не реестр хендлеров. --- ## Что сделано (по шагам плана) ### Шаг 1 — Сборка образа на Mac ✅ 🔴 **Свой Dockerfile писать не пришлось** — в апстриме уже есть готовый `Dockerfile.release`: multi-stage `golang:1.26.6-bookworm` → `scratch`, `CGO_ENABLED=0`, `GOARCH=$TARGETARCH`, ldflags `-X main.version/commit/date`. Бинарь статический, 20.6 МБ. ```bash cd ~/Developer/balda docker buildx build --builder arm64builder --platform linux/arm64 \ -f Dockerfile.release \ --build-arg VERSION=v0.3.1-137-g004fda2f-dirty --build-arg COMMIT=004fda2f \ --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ --provenance=false --load -t balda-mm:0.1.1 . # ~110 с (go mod download 50 с + go build 14 с) zstd -3 -T0 -o /tmp/balda-mm-011.tar.zst --force <(docker save balda-mm:0.1.1) zstd -dc /tmp/balda-mm-011.tar.zst | ssh kraken 'docker load' ``` ⚠️ `--provenance=false` обязателен, иначе buildx добавляет attestation-манифест и `docker load` на Kraken ругается на неподдерживаемый формат. ⚠️ `arm64builder` (docker-container driver на colima) — на нём есть `linux/arm64` нативно. ⚠️ Распаковывать zstd **на Mac**: zstd 1.5.4 на Kraken не читает фрейм macOS-zstd 1.5.7. ### Шаг 2 — Конфиг и данные на Kraken ✅ **Структура путей** (важно: Balda ищет конфиг как `/.config/balda/config.yaml`, см. `internal/apps/balda/paths/paths.go` — `configDirName = ".config/balda"`): | Что | Путь на хосте | В контейнере | |---|---|---| | compose | `/home/kraken/balda/docker-compose.yml` (0600) | — | | `.env` | `/home/kraken/balda/.env` (0600) | env_file | | `config.yaml` | `/home/kraken/balda/config/config.yaml` | `/workspace/.config/balda/config.yaml` (ro) | | токен MM-бота | `/home/kraken/balda/secrets/mm-bot-token` (0600) | — | | ключ DeepSeek | `/home/kraken/balda/secrets/deepseek-key` (0600) | — | | пароль backoffice | `/home/kraken/balda/secrets/backoffice-adminpw` (0600) | — | | состояние | `/srv/…-49e8f586…/docker/balda/state` | `/state` | | workspace | `/srv/…-49e8f586…/docker/balda/workspace` | `/workspace/work` | ⚠️ Флага `--config` у Балды **нет**. Есть только `--config-dir`, `--profile`, `--debug`, `--trace`. Конфиг монтируется внутрь `/.config/balda/`. ⚠️ Подкоманда обязательна: `command: ["start"]`. Без неё контейнер печатает help и висит. **`docker-compose.yml` — ключевые параметры:** | Ключ | Значение | Почему | |---|---|---| | image | `balda-mm:0.1.1` | arm64, из официального апстрима | | `command` | `["start"]` | 🔴 без подкоманды бинарь печатает help | | `working_dir` | `/workspace` | от него считается путь к конфигу | | `env_file` | `/home/kraken/balda/.env` | секреты, не в compose | | `BALDA_STATE_DIR` | `/state` | | | сеть | `mattermost_mm-net` (external) | 🔴 compose-префикс MM-стека | | volumes | config ro, state, workspace | HDD, не SD | | `restart` | `unless-stopped` | | 🔴 **Сеть MM называется `mattermost_mm-net`**, а не `mm-net` — compose добавляет имя проекта. `server_url` указывает на `http://mattermost:8065` (DNS контейнера внутри сети), а **не** на `https://mm.qentra.top`: внешний хостнейм идёт через туннель и лишний раз рвётся. **`config.yaml` — блок Mattermost + провайдер:** ```yaml balda: provider: deepseek permissions: { mode: "allow_all", timeout: "5m" } mattermost: enabled: true server_url: "http://mattermost:8065" token: "${BALDA_MATTERMOST_TOKEN}" bot_user_id: "${BALDA_MATTERMOST_BOT_USER_ID}" bot_username: "balda" working_dir: "/workspace" state_dir: "/state" workspace: { mode: "off" } runtime: providers: deepseek: type: openai openai: base_url: "https://api.deepseek.com/v1" api_key: "${DEEPSEEK_API_KEY}" model: "deepseek-chat" profiles: deepseek: balda: { provider: deepseek } ``` 📌 **DeepSeek подключается как провайдер `type: openai`** с `base_url`. Реестр типов — `agentconfig/config.go:104`: `generic_acp` · `gemini_acp` · `codex_acp` · `opencode_acp` · `copilot_acp` · `claude_code_acp` · `claude_acp` · `grok_acp` · `registry_acp` · `agy_acp` · `antigravity_acp` · **`openai`** · `aistudio` · `pool`. Поле `base_url` живёт **только** в блоке `openai` (не в общем конфиге). Рабочий образец — старый `~/Docker/balda-agent/.config/balda/config.yaml`. ⚠️ **Env-переменные применяются только к ключам, которые УЖЕ есть в YAML** — блок `balda.mattermost` обязан присутствовать, иначе `BALDA_MATTERMOST_*` молча игнорируются. **`.env` на Kraken (5 строк, 0600):** ``` DEEPSEEK_API_KEY=… BALDA_MATTERMOST_TOKEN=… BALDA_MATTERMOST_BOT_USER_ID=edxrgupoxiyig5gzmr79tjypbme BALDA_MATTERMOST_BOT_USERNAME=balda BALDA_MATTERMOST_SERVER_URL=http://mattermost:8065 ``` **Токен DeepSeek** — читается скриптом из `~/.hermes/hermes-whale/.env` и **стримится на хост по stdin**, значение нигде не печатается: ```bash DS_KEY=$(awk -F= '$1=="DEEPSEEK_API_KEY"{sub(/^[^=]*=/,""); gsub(/["'"'"' \r]/,""); print}' \ ~/.hermes/hermes-whale/.env) ssh kraken "umask 077 && cat > /home/kraken/balda/secrets/deepseek-key && chmod 600 ..." <<<"$DS_KEY" ``` ### Шаг 3 — Backoffice bootstrap (не был в плане, но обязателен) ✅ Без него Балда падала: `Error: backoffice user readiness: administrator bootstrap is required`. ```bash # Пароль генерируется на хосте, читается bootstrap-admin из stdin, пишется в 0600-файл. # 🔴 Останавливать контейнер перед bootstrap: sqlite state.db занят запущенным процессом. head -c 32 /dev/urandom | base64 | tr -d '=+/' | head -c 32 > $SEC/backoffice-adminpw cd /home/kraken/balda && docker compose stop balda cat $SEC/backoffice-adminpw | docker compose run --rm -T --no-deps balda backoffice bootstrap-admin docker compose up -d # → administrator ready: id=b6517e56-… username=admin created=true ``` --- ## 🔴 Питфолл-набор: редакция секретов Hermes и ssh-heredoc Все пункты дают **молчаливо сломанный** скрипт. Полный разбор — [[personal/tech/hermes-secret-redaction-bypass]]. | # | Что режет | Симптом | Обход | |---|---|---|---| | 1 | присваивание переменной с именем **`TOKEN`** | `syntax error near unexpected token 'fi'` | не называть переменную `TOKEN`; `SESS` / `V1` | | 2 | литерал `Authorization: Bearer` (и вообще `Au` + `th`) | `syntax error near unexpected token ')'` | `HD=$(printf '%s%s' "Auth" "orization")` — собрать в рантайме | | 3 | `$o` / `$ord` в jq-выражении | `line N: o: unbound variable` | переименовать: `$ord` → локальная переменная через `as` | | 4 | `printf 'VAR=%s\n' "$VAL"` | в файл попадает мусор, 5-я строка теряется | `echo "VAR=$VAL"` в `{ }`-блок | | 5 | `$PW` внутри вложенного `<<'INNER'` | MM 400 `blank_pwd` | читать из файла на хосте либо двойной heredoc | | 6 | **`read_file` тоже искажает вывод** | диск цел, чтение обрезано | проверять `awk`/`grep -c` на диске, не «чинить» по обрезанному чтению | | 7 | `ssh host 'bash -s' < script.sh` | читает stdin скрипта вместо heredoc: `MM_ADMIN_PW: set MM_ADMIN_PW` | `ssh host "bash -s" < /dev/null <<'EOF'` или `ssh host "cat > /tmp/x.sh" < file` | 📌 **Лучший паттерн для сложных удалённых скриптов:** писать файлом на Mac → `ssh kraken "cat > /tmp/x.sh" < x.sh` → `ssh kraken 'bash -n /tmp/x.sh'` → `ssh kraken 'bash /tmp/x.sh'`. Никаких вложенных heredoc. 📌 **`jq -r '.id // empty'` не работает как «пусто, если ошибка»** — при отсутствующем канале MM возвращает `{"id":"app.channel…missing","message":…}`, т.е. `.id` **есть**, и он уезжает в переменную. Фильтровать по форме: `select(test("^[a-z0-9]{26}$"))`. --- ## 🔵 Mattermost API — что узнали | Факт | Деталь | |---|---| | **Сессионный токен** | 🔴 в MM 11.x возвращается в **заголовке `Token`** ответа `POST /users/login`, а **не** в теле JSON | | **Заголовок `Token:` для API-запросов** | 🔴 **НЕ РАБОТАЕТ** — MM 11.x отвечает 401 `api.context.session_expired.app_error`. Работает **только `Authorization: Bearer *** Сессия при этом живая (в БД `expiresat` = +180 дней, не удалена). Старый заголовок из MM 5.x больше не принимается | | **PAT бота** | значение отдаётся **ровно один раз** — в ответе `POST /users/{id}/tokens`. Повторный `GET /users/{id}/tokens` возвращает `id, description, is_active, expires_at, user_id` — **без `token`** | | **Bot creation** | гейтится `ServiceSettings.EnableBotAccountCreation` (по умолчанию `false`) → HTTP 403 `api.bot.create_disabled`. 🔴 `PUT /config` отвечает 200, но **не меняет** значение (env перебивает). Работает только `mmctl --local config set …` | | **Пост от бота в канал** | бот должен быть **в команде И в канале**, иначе 403 `api.context.permissions.app_error`. Порядок: `mmctl --local team users add qentra ` → `channel users add qentra:balda-test ` | | **`mmctl --local post create`** | 🔴 **не работает**: `Error: creating posts is not supported in local mode`. Для тестовых постов — только REST API | | **Сессия логина** | каждый `POST /users/login` создаёт **новую** сессию; предыдущие не инвалидируются (в БД видно 6 живых сессий `testuser`). Если получаешь 401 — смотри `Authorization: Bearer`, а не лимит сессий | | **Логин в скрипте** | сессия приходит в заголовке ответа. Парсить: `printf "%s" "$RESP" \| tr -d "\015" \| sed -n "s/^[Tt]oken: //p"`. ⚠️ `tr -d "\r"` внутри одинарных кавычек `sh -c` даёт литерал `r`, CR не удаляется — нужен `\015` | | **WS: аутентификация** | 🔴 `Authorization: Bearer *** в dial — **правильный и достаточный** способ. Заголовка хватает, MM отвечает `101 Switching Protocols` и шлёт `hello` | | Команды MM | `docker exec mattermost mmctl --local …` | **Бот `balda`:** `user_id=edxrgupoxiyig5gzmr79tjypbme`, `email=balda@localhost`, owner `admin`. Токен: `/home/kraken/balda/secrets/mm-bot-token` (0600, 26 символов). **Значение токена в доке не хранится** — только путь. **Стенд для проверки:** team `qentra` (`m1iwp7t9w7no3y8gi876iw39he`), channel `balda-test` (`7bjw4g7o1jdsbmjbqd649xxgaw`). Оба созданы в этой сессии — до неё в MM не было ни команды, ни канала. Бот `balda` добавлен в команду и канал (иначе 403). Тестовый пользователь `testuser` (`c6udeze8dfrdxm5jbgq55bd58r`, пароль в `/tmp/mm-post-user.sh` не хранится) — нужен, чтобы событие `posted` приходило с **человеческим** `user_id`, иначе `IsBotEcho` отбрасывает пост. --- ## 🔴 ROOT CAUSE: WS-пробник и три найденные причины Пробник `~/Developer/balda-wsprobe/main.go` — pure Go stdlib (RFC6455 handshake + reader, без зависимостей), собирается нативно на Mac, на Kraken кладётся статический бинарь (3.4 МБ, arm64). Флаги: `-url`, `-token`, `-secs`, `-authheader`. ```bash cd ~/Developer/balda-wsprobe CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o wsprobe-linux-arm64 . scp wsprobe-linux-arm64 kraken:/tmp/wsprobe ssh kraken 'cd /home/kraken/balda/secrets && TOK=$(cat mm-bot-token) && \ docker run --rm --network mattermost_mm-net -v /tmp/wsprobe:/wsprobe:ro \ alpine:3.20 /wsprobe -url ws://mattermost:8065/api/v4/websocket \ -token "$TOK" -secs 20 -authheader' ``` ### Причина 1 — `Origin` в handshake ломает всё (CORS) Первая версия пробника отправляла `Origin: http://mattermost:8065`. MM ответил **`403 Forbidden` / `api.web_socket.connect.upgrade.app_error` — «URL Blocked because of CORS»**, потому что `SiteURL=https://mm.qentra.top`, а `Origin` был `http://mattermost:8065` — не совпадает. Убрал `Origin` → `101 Switching Protocols`. 📌 Без `Origin` MM не проверяет CORS и принимает соединение. Балда `Origin` не шлёт — с ней всё ок. ### Причина 2 — `authentication_challenge` на уже аутентифицированном сокете УБИВАЕТ подписку Это был **реальный баг в коде Балды** (`channel/mattermost/ingress.go`, `streamOnce`). Соединение аутентифицируется заголовком `Authorization: Bearer`, но код **дополнительно** слал `{"seq":1,"action":"authentication_challenge","data":{"token":…}}`. **Проверено эмпирически:** с челленджем — приходит `hello`, затем `status_change`, затем **тишина 40 секунд**, хотя в канал создаётся пост. Без челленджа — `posted` приходит штатно. То есть избыточный челлендж заставляет MM переинициализировать сессию сокета, и `posted` на него больше не пушится. 🔴 **Это объясняет «connected, но тишина»**: Балда логировала `mattermost websocket connected` **до** отправки челленджа, поэтому лог выглядел здоровым, а события не шли. **Фикс** — дельта `tools/deltas-mattermost-14.json` (формат: **плоский массив**, не объект!): - удалён блок `WriteJSON(...authentication_challenge...)` из `streamOnce`; - удалена константа `websocketActionAuthChallenge` (стала unused). Пересобран образ `balda-mm:0.1.2` → задеплоен (`docker compose up -d --force-recreate`). 📌 **Правило:** если сокет аутентифицирован заголовком на dial — **не** слать `authentication_challenge`. Одно из двух, не оба. ### Причина 3 — ❌ ВЕРСИЯ ОПРОВЕРГНУТА (это был не обрыв в ингрессе) Прошлая сессия предполагала, что событие теряется между `readLoop` и `handleFrame`. Наблюдения, которые к этому привели: | Проверка | Результат | |---|---| | Ingress логирует `mattermost websocket connected` | ✅ | | Пробник ловит `posted` с human `user_id` при живом сокете | ✅ событие **есть** на сокете | | Балда логирует `posted` / `dispatchPosted` | 🔴 **ничего**, даже на `logger.level=debug` | | `MentionsBot("@balda …", "balda")` → `contains("@balda")` | ✅ true (проверено по коду) | | `IsBotEcho` / `IsSystemPost` / `IsDeletedPost` | ✅ не срабатывают | | `bot_username: "balda"` в `config.yaml` | ✅ присутствует | | `PostedData.Post` — тип `string`, JSON-строка поста | ✅ парсинг корректен | 🔴 **Версия «обрыв между `readLoop` и `handleFrame`» НЕ подтвердилась.** Диагностический лог каждого кадра (дельта 15) показал: фреймы `posted` **вообще не приходят** — не потому что теряются, а потому что **MM перестал их слать**. Ключевая ошибка рассуждения: пробник подключался **своим отдельным сокетом** и события получал; сокет же Балды к тому моменту был уже «отравлен» `bad_seq` и MM на него не пушил. Отсюда ложный вывод «событие есть, но теряется внутри». **Настоящие причины — см. §«Причина 3 (настоящая) — `bad_seq`» и §«Причина 4 — `Processor == nil`» в начале этого дока.** 📌 **Урок:** «событие видно пробником» ≠ «событие доходит до приложения». Проверять **тот же самый сокет**, а не параллельный. Для этого и понадобился диагностический лог каждого кадра в самом ингрессе — он и вскрыл `bad_seq`. --- ## 🔴 Инструмент постинга в MM (нужен для каждого теста) `mmctl --local post create` не работает, поэтому постинг — через REST в контейнере. | Скрипт (Mac → Kraken `/tmp/`) | Что делает | |---|---| | `~/Developer/balda/tools/mm-post-user.sh ` | пост от **человека** — правильный тест ингресса | | `~/Developer/balda/tools/mm-post-bot.sh ` | пост от **бота** — ⚠️ `IsBotEcho` его отбросит, годится только для проверки WS-доставки | | `~/Developer/balda/tools/mm-debug-session.sh` | логин + `/users/me` тем же токеном — отладка 401 | | `~/Developer/balda-wsprobe/main.go` | WS-пробник | 📌 **Тест ингресса валиден только от НЕ-бота.** Пост от бота даёт событие с `user_id` бота, и `IsBotEcho` его легитимно отбрасывает — на этом я потерял два прогона. --- ## 🔴 Новые питфоллы редакции секретов (дополнение к [[personal/tech/hermes-secret-redaction-bypass]]) Найдены в этой сессии при написании `mm-post-user.sh`: | # | Что режет | Симптом | Обход | |---|---|---|---| | G | `-H "Authorization: Bearer *** $V1"` | `sh: syntax error: unterminated quoted string` — на диске строка обрезана после `***` | собирать схему и имя заголовка из кусков **внутри скрипта**: `SCHEME="Bea""rer"`, `HD="Auth""orization: ${SCHEME} ${V1}"`, затем `-H "$HD"` | | H | `${#V1}` (длина строки) и `if` на одной строке с `echo` | `sh: syntax error: unexpected "then"`; строка слилась с предыдущей | не использовать подстановку длины; `if` — только отдельной строкой или через `[ ] &&` без `echo` рядом | | I | `tr -d "\r"` внутри `sh -c '…'` | CR не удаляется (литерал `r`), токен с `\r` → 401 | писать `tr -d "\015"` (октальный код работаёт в одинарных кавычках) | ⚠️ Подтверждён питфолл из прошлой сессии: **`write_file` пишет байт-в-байт, но редакция срабатывает на выводе** — `read_file`/`terminal` показывают `***`, а на диске строка цела. Проверять `bash -n` **на хосте**, а не глазами. --- ## Что осталось — 🔴 привязка владельца ~~Актуальное состояние обрыва — см. §«Причина 3» выше.~~ **Ингресс больше не блокер:** обе причины тишины устранены, MM-хендлер написан и доказанно вызывается (см. начало дока). Осталось **одно действие**: привязать владельца к MM, иначе авторизация отбивает посты (`settlement_stage="unauthorized"`). Таблица `balda_user_bindings` пуста. ```bash # 1. достать owner_auth_token ssh kraken 'sudo -n strings /srv/…-49e8f586…/docker/balda/state/state.db | grep -A2 appowner_auth_token' ssh kraken '/tmp/mm-owner-bind.sh testuser "T3stPassw0rd!x" ' # 3. проверка сквозного пути ssh kraken '/tmp/mm-post-user.sh 7bjw4g7o1jdsbmjbqd649xxgaw testuser "T3stPassw0rd!x" "@balda скажи одно слово"' ``` ⚠️ **Приёмка — ответ бота виден в UI Mattermost**, не «контейнер Up». Пока НЕ выполнена. ⚠️ Не докладывать «готово», пока сквозной путь не работает (см. реакцию Alex выше). --- ## 🔵 Проверка расходования диска (2026-09-26, по вопросу Alex) Alex спросил «почему ты работаешь на SD карте?» — проверено, ответ: **Docker целиком на HDD**, SD под Docker не используется. ``` Docker Root: /srv/…-6194539b…/docker-data ← USB-HDD (1.8T) /var/lib/docker: No such file or directory ← на SD его НЕТ /etc/docker/daemon.json: data-root = /srv/…-49e8f586…/docker-data ← symlink на 6194539b docker system df → Build Cache: 0B ``` SD (`/dev/mmcblk0p2`, 14 ГБ, 36%): `/usr` 2.9 ГБ · `/var/swap` 513 МБ · `/var/cache` 404 МБ · `/home` 486 МБ. Единственный крупный файл на SD — `/var/swap` (swapfile), это штатно. 📌 **Вывод:** buildkit-кэш, из-за которого правило и появилось, **пуст** — первая сборка шла на `arm64builder` (colima на Mac) с `--load`, кэш на Kraken не писался вообще. Путь `49e8f586…` и `6194539b…` — **один и тот же диск** (первый — симлинк). ⚠️ Неаккуратность этой сессии: `docker run golang:1.26.6-bookworm` тянул ~800 МБ на Kraken ради одноразовой проверки WS. Лёг на HDD, но это лишний мусор — такие пробы гонять на Mac. --- ## Файлы | Что | Путь | |---|---| | Скрипты деплоя и провижининга (Mac) | `/Users/admin/tmp-mm-provision/*.sh` | | `config.yaml.tpl` | `/Users/admin/tmp-mm-provision/config.yaml.tpl` | | `docker-compose.yml` | `/Users/admin/tmp-mm-provision/docker-compose.yml` | | Дельта-фикс `goal`/`goalkeeper` | `~/Developer/balda/tools/deltas-goal-command-fix.json` | | **Дельта-фикс челленджа (14)** | `~/Developer/balda/tools/deltas-mattermost-14.json` | | **WS-пробник (исходник)** | `~/Developer/balda-wsprobe/main.go` | | **Пост от человека** | `~/Developer/balda/tools/mm-post-user.sh` | | **Пост от бота** | `~/Developer/balda/tools/mm-post-bot.sh` | | **Отладка логина/401** | `~/Developer/balda/tools/mm-debug-session.sh` | | Dockerfile | `~/Developer/balda/Dockerfile.release` (апстримный, не менялся) | | Образ | `balda-mm:0.1.2` (актуальный) · `0.1.1`, `0.1.0` (предыдущие) | | Стенд MM | team `qentra` / channel `balda-test` / user `testuser` | | JVM-пробники на Kraken | `/tmp/wsprobe`, `/tmp/mm-post-user.sh`, `/tmp/mm-post-bot.sh` (не переживут reboot) | 📌 **Дельта-апплаер `tools/run-project-deltas.sh` требует ТРИ аргумента:** ` ` (state = `tools/.deltas-state.json`). JSON дельт — **плоский массив** `[{file,note,search,replace}, …]`, НЕ объект с ключом `deltas`. Ошибка `TypeError: string indices must be integers` = передан объект вместо массива. ⚠️ Скрипты в `/tmp` **не переживут reboot Mac** — существенное продублировано в этом доке и живёт в `~/Developer/balda/tools/`. ## Связанные заметки - [[personal/projects/balda/balda-mm-delegation-plan]] — общий план проекта - [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2, MM-адаптер (код) - [[personal/projects/balda/mattermost-kraken-deploy]] — Фаза 1, MM на Kraken - [[personal/tech/balda-go-fx-pitfalls]] — 🔴 fx-питфоллы + дельта-апплаер + advertisement - [[personal/tech/hermes-secret-redaction-bypass]] — редакция секретов Hermes - [[personal/tech/docker-data-root-mount-race]] — почему том на HDD, а не на SD