Ход AI выполнялся прямо в приёме сообщения: цикл опроса мессенджеров и
HTTP-запрос виджета ждали провайдера, держа открытой транзакцию организации.
Один ход — это два обращения к модели (эмбеддинг и чат) по тридцать секунд с
двумя повторами, то есть до трёх минут, и всё это время ни одно входящее по
всей установке не забиралось. Владелец видел это как «бот залипает»: сайт и
MAX на одном агенте отвечали с задержками или молчали.
Приём теперь доводит дело до записи сообщения и ставит событие
`conversation.ai_turn_requested`. Ход считает роль событий воркера
(`run_worker --role=events`) короткими транзакциями, между которыми остаются
походы к провайдеру и в мессенджер. Туда же уехала расшифровка голосовых —
последнее обращение наружу из цикла опроса.
Воркер разделён на роли: `poller` опрашивает подключения и ведёт периодические
работы (один экземпляр — курсоры и паузы после сбоя живут в его памяти),
`events` разбирает outbox и масштабируется репликами (`CHATBALLS_EVENT_WORKERS`,
по умолчанию две). Роль `all` осталась для разработки.
Очередь событий научилась двум вещам: события одного диалога не выдаются
параллельно (иначе два ответа приезжают клиенту вперемешку) и событие,
взятое упавшим процессом, возвращается в очередь по истечении аренды.
Попутно убраны мины, которые тот же залип и продлевали:
- ход клиенту ограничен своим таймаутом (CHATBALLS_AI_TURN_TIMEOUT, 20 с)
и сроком годности (CHATBALLS_AI_TURN_DEADLINE_SECONDS, 120 с) — просроченный
ход не зовёт модель, а передаёт диалог оператору;
- отказ провайдера по существу запроса (4xx, кроме 429) больше не повторяется
трижды по таймауту;
- предохранитель провайдера считает сбои по ключу «организация + интеграция»,
а не один на процесс: отозванный ключ одной организации гасил AI у всех;
- потолок паузы после сбоя опроса — минута вместо четверти часа: он был
компромиссом ради журнала однопоточного воркера.
Виджет узнаёт, что ответ считается, по признаку `thinking` в ленте.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Звонок через симметричный NAT или VPN без relay не соединяется — значит relay должен стоять из коробки, а не быть отдельным профилем с выделенным IP и сертификатом. Coturn поднимается вместе со стеком на том же адресе: порт 3478 не спорит с 80 и 443, TURN-over-TLS выключен, потому что он и требовал второго адреса.
Адреса relay и STUN считаются от адреса установки и появляются в настройках сами; вписанное владельцем по-прежнему побеждает. Deploy и doctor больше не требуют второго публичного адреса, README обоих языков переписан: вместо «опционально, выделенный IP» — какие порты открыть.
Проверено: тесты TURN (включая четыре новых на автонастройку), тесты API звонков и адреса установки.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».
Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.
Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Том chatballs-secrets целиком монтировался во все сервисы: публичный
backend-app читал пароли платформенной роли и роли миграций, которая обходит
RLS. Теперь томов три — общий, платформенной роли и роли миграций вместе с
паролем владельца кластера — и каждый процесс монтирует только свои.
Генератор переносит файлы существующих установок: копия, побайтная сверка,
только потом удаление из общего тома; повторный запуск ничего не трогает,
пароли не меняются. doctor на работающем стеке проверяет, что backend-app
паролей не видит. Воркер получает алиас platform флагом
CHATBALLS_DB_PLATFORM_ALIAS. README перечисляет три тома в бэкапе.
nginx (dev-шлюз и production-фронтенд) резолвил имя сервиса один раз при
старте: после пересоздания контейнеров старый адрес backend-app достался
backend-platform, и туда уходил WebSocket приложения. Upstream задан
переменной с resolver на DNS Docker. ADR-CHATBALLS-0048 §3, §5.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Раньше compose монтировал с хоста Caddyfile, init-скрипты базы и генератор
секретов. Из-за этого установка требовала рядом распакованный репозиторий, а
файл, забытый при сборке релиза, Docker молча подменял пустым каталогом — и
стек падал на первом старте у человека. Плюс production-образ backend вообще
не собирался: COPY content ссылался на каталог, которого в репозитории нет,
так что релиза не существовало ни на GitHub, ни на GitLab.
Теперь весь дистрибутив — один compose.yaml со страницы релиза:
curl -fsSL .../compose.yaml -o compose.yaml
docker compose up -d --wait
- Caddyfile переехал в свой образ шлюза (caddy validate — в сборке),
init-скрипты базы — в свой образ postgres, генератор секретов — в
backend-образ. Bind-mount'ов в production-манифесте не осталось.
- Состояние установки — именованные тома вместо каталогов рабочего каталога.
Заодно чинит загрузку файлов на Linux: том наследует владельца из образа
(hub), тогда как bind-mount доставался контейнеру как root:root.
- scripts/pin-release-compose.py закрепляет ссылки на образы по digest и
падает, если хоть один ключ остался подстановкой.
- GitHub-workflow собирает четыре образа и прикладывает к релизу compose.yaml
(основной путь) и release.env (для `chatballs deploy`).
- tests/cli/test_release_compose.py держит свойство: манифест без bind-mount'ов
и полностью закрепляем по digest.
- Из окружения шлюза убраны CHATBALLS_APP_DOMAIN и CHATBALLS_ACME_EMAIL —
Caddyfile их не читает.
Dev-контур не меняется по смыслу: compose.dev.yaml по-прежнему собирает всё
из исходников и держит состояние в ./data.
AUDIT-TODO.md — временный список остального из аудита; удаляется целиком,
когда закрыт последний пункт.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Документация переработана: действующее отделено от истории, отменённые
контуры (продажи, биллинг, managed AI, отделы, сущность Product) убраны из
действующих документов в архив.
Здесь — только кодовая часть: ссылки на документы в комментариях. Ссылки на
действующие документы переименованы ADR/SPEC/ARCH/BUS-HUB-NNNN →
*-CHATBALLS-NNNN (274 ссылки в 173 файлах). Ссылки на документы, ушедшие в
архив, намеренно сохранили прежний идентификатор: он совпадает с именем
архивного файла.
Логика не менялась — правки только в комментариях, докстрингах и одном
описании теста.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Первичное требование: поднял докер — прошёл мастер — дальше всё в интерфейсе.
Ни одной переменной окружения задавать не нужно и негде.
Запуск и секреты:
- .env.example удалён, мёртвые переменные вычищены; секреты инстанса
генерирует одноразовый сервис secrets в именованный том, пароли БД —
через POSTGRES_*_PASSWORD_FILE;
- Caddy: catch-all :80 и on_demand TLS вместо хостов в конфиге — свежая
коробка отвечает по IP и по любому домену, до мастера дойти можно;
- адрес установки, SMTP и TURN переехали в настройки (InstanceSettings,
миграции 0027–0030), внешние ссылки строятся от него;
- deploy/cli больше не читает .env; сборка образов в ghcr через GitHub
Actions, release.env с digest-пинами;
- куки Secure/__Host- выставляются по факту TLS запроса, а не настройкой.
Порталы (дизайн-базлайн v2, кадры PT1–PT8):
- список, карточка портала, библиотека материалов, редактор статьи и
настройки — ширины и ритм как в остальных разделах;
- файлы статьи: изображение вставляется в текст своим механизмом, файл
прикрепляется вложением и выводится на портале списком с иконкой формата;
- ссылки на файлы приводятся к относительным: абсолютный хост резал CSP
портала и картинка не появлялась;
- «Опубликовать» сверяется с сервером и публикует то, что на экране, а не
ранее выбранную редакцию;
- колонка «Оценки» в списке статей и блок оценок в редакторе.
Первый запуск: полоса «демо-данные устанавливаются» — установка идёт в
worker, и без неё человек видел пустые разделы без объяснения.
Починен фон: вторичное хранилище стало best-effort — при живых остаточных
S3-ключах повторная загрузка файла падала в 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- нет зашитых доменов: help-домен по умолчанию localhost (задаёт установщик через
CHATBALLS_HELP_BASE_DOMAIN), фронт определяет портал помощи пробой /api/v1/help/,
ALLOWED_HOSTS без вендорских хостов, CI environment url — из CHATBALLS_APP_DOMAIN
- DEFAULT_FROM_EMAIL по умолчанию no-reply@localhost; JWT-аудитория support-токена
chatballs.support; загрузчик виджета: window.ChatballsChat, события chatballs-chat-*
- «Контакты»: продукты берутся из данных организации ({code, name}), цвет —
детерминированно по коду; зашитый список продуктов вендора убран
- npm-скоупы @chatballs/*, тема chatballsTheme/buildTheme
- тестовый bootstrap: организация demo, сотрудник staff.member@example.org,
продукты site/app; фикстуры на example.com
- убрана мёртвая привязка Shell к e-mail сотрудника вендора
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- каталог code/custocrm → code/chatballs; CLI custocrm → chatballs; пакеты
hub_platform → chatballs, hub_backend → chatballs_backend (app labels прежние)
- переменные CUS_* и CUSTOCRM_* → CHATBALLS_*; образы chatballs-*; compose-проект
и база chatballs (были edevs_hub); роли Postgres chatballs_* (были custocrm_*);
схема RLS chatballs и GUC chatballs.organization_id; cookie chatballs_*
- CI: APP_DIR code/chatballs, DEPLOY_ROOT /opt/chatballs
- deploy/migrate/rename-to-chatballs.sh — миграция существующей установки без
потери данных: остановка старого проекта, .env (с резервной копией),
переименование суперпользователя initdb через временную роль, остальных ролей,
базы, схемы и функции RLS; проверено на локальном стеке
- dev-стек хранит Postgres в bind-mount data/postgres, как prod (именованный том
compose.dev был устаревшим снимком и вводил в заблуждение)
- снятие демо удаляет объекты, созданные поверх демо-данных тестировавшим
(звонки по демо-диалогу), вместо падения на PROTECT
- реальные домены *.custocrm.ru и идентификаторы документов не тронуты
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Open source: «развернуть за минуту». Никаких параметров в .env и CLI —
при пустом инстансе браузер показывает мастер (/api/v1/setup/): название
организации, имя, e-mail, пароль владельца и флаг «Установить демо-данные».
Запись идёт на соединении platform (роль app не создаёт организации) через
tenancy/routing.use_database; после первого владельца мастер закрыт (409).
bootstrap_owner и захардкоженные Edevs/Котова из установки удалены
(bootstrap_edevs_owner остаётся тестовым helper'ом).
Демо ставится в организацию установщика; реестр DemoRecord (post_save во
время сида) даёт точное удаление в обратном порядке с разрывом PROTECT-циклов
только между демо-объектами. Установка/удаление — outbox → worker; статус и
витринные учётки (админ + сотрудники разных групп, пароль Chatbolls-Demo-2026)
— карточка «Демо-данные» в «Настройках» (/company/demo/).
Сид переписан под дизайн-базлайн v2 («Ателье Норд»): 6 сотрудников (группы,
блокировка, TOTP, приглашения), 4 агента (активные/черновик/выключенный),
подключения TG/MAX/почта/два веб-виджета (анонимный и авторизованный) с
ошибкой у одного, знания с иерархией категорий и вложениями (md/txt/pdf),
30 дней истории LLM, 13 диалогов во всех состояниях с метками, приоритетами,
заметками, ответственными, историей контакта, спамом и архивом, веб-гость
через настоящую сессию виджета, шаблоны «/», портал поддержки со статьями
(опубликованные/черновик/архив, вторая ревизия, оценки), звонки с метриками и
приглашением, уведомления всех типов, привязки уведомителя. Аватары
контактов — портреты владельца, публичный /api/v1/demo-media/. Голосовые —
слоты под клипы владельца (media/voice/README.md).
Тест покрытия: каждая модель hub_platform получает демо-запись; удаление
возвращает счётчики к исходным. compose-профиль demo-seed и seed-demo.ps1
удалены; seed_demo --organization --apply/--remove для разработки; start.sh
для Linux/macOS; README — раздел «Быстрый старт».
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
init-runtime-roles.sh и reassign-schema-ownership.sql монтировались относительным путём ./deploy/postgres/, который резолвится относительно --project-directory (INSTANCE_DIR). Ассеты лежат только в релизе, в instance их нет — Docker создавал пустые директории, psql -f падал с «Is a directory», деплой валился на нормализации владения схемой.
Приведено к единому эталонному паттерну ${CUSTOCRM_RELEASE_DIR} (как Caddyfile). compose.dev.yaml ассеты postgres не монтирует — дефекта там нет.
Production deploy failed at migrate with «must be owner of table
identity_organization»: the table (and others created/imported outside the
migration role) were not owned by custocrm_schema, which migration_user must
belong to to run AddField/AlterField. This is a pre-existing condition that
surfaced on the administration migration and recurred across pipelines
(#922, #937, #938) — not caused by the calls feature.
Fix makes deploy self-healing: before running migrate, reassign ownership of
all public-schema objects (tables, sequences, functions) to custocrm_schema
under the postgres superuser. Idempotent and safe on every deploy.
- deploy/postgres/reassign-schema-ownership.sql: reassign public-schema
ownership to custocrm_schema (verified against PG16)
- compose.yaml: mount the SQL into the postgres container
- deploy/cli/lib/deploy.sh: run the normalization step once postgres is
healthy, before the one-shot migrate
Introduce a call kind (AUDIO default, VIDEO) so dialogs support both audio
and video calls. Video path is unchanged; audio is a separate full-screen
component built 1:1 on the «Аудиозвонок.dc.html» baseline.
Backend
- CallSession.kind (CallKind AUDIO|VIDEO, default AUDIO) + migration 0005
- create_call_request(kind), CallCreateView validates kind from request body
- serializers expose kind in call_payload, public_invite_payload,
public_call_state_payload (drop stale hardcoded capabilities)
- timeline system message reflects kind («аудио-/видеозвонок»)
- tests updated for the kind field and new message wording
@edevs/ui
- useCallRtcSession gains videoEnabled (default true; video unchanged);
audio requests audio-only getUserMedia, no camera fallback
- AudioCallView, AudioCallWaveform (live AnalyserNode oscilloscope),
AudioCallIcons (mic/speaker/phone/status), audioCallStates, audio-call.css
- exported via index.ts
internal-ui
- two call buttons in the conversation header (audio + video)
- CallOverlay becomes a kind dispatcher: VideoCallOverlay (unchanged) /
AudioCallOverlay; kind threaded through model/hook/workspace
web-chat
- CallInfo.kind; invite banner shows audio vs video title and icon
- CallApp dispatches to AudioCallStage for audio; audio resolve/build in model
Infra
- compose.yaml: widen Coturn relay UDP range to 49160-49999 (~420 relay
calls) with explanatory comment
Знания: плоская сущность Knowledge (заголовок, описание, MD) + файловые
вложения с оригинальными именами (retrieval из md/txt/pdf/docx, публичная
ссылка для клиента). Агент: без релизов, инструкции из трёх частей
(персонализация/тон/инструкции) + выбор знаний из библиотеки; лимит
диалогов упразднён (остался dailyCostUsd). channel.system_prompt/model
удалены — AI-поведение только на агенте. Продукт — техническая запись
без summary/sales_description. Data-миграция переносит опубликованные
документы и активные релизы в новую модель. Internal-UI: раздел Знания,
новая карточка агента, упрощённые продукты, release-страницы удалены.
ADR-0005/0007/0017 → superseded; SPEC-HUB-0012 переписан.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Public checkout belongs to product backends, not Hub (ADR-HUB-0014,
ADR-HUB-0018). Delete apps/checkout and its wiring: compose service, nginx
pay.localhost route, npm workspace and dev script, playwright project,
HubApplication type, check.ps1 typecheck and README mentions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ADR-HUB-0016: retrieval on PostgreSQL (FTS) + pgvector (semantic); db image
switched to pgvector/pgvector:pg16, extension enabled via migration
- KnowledgeFragment stores chunks + embeddings (dimension-agnostic vector so
local and OpenRouter embedding sizes coexist)
- publishing a knowledge version re-chunks and embeds it via the provider
- KnowledgeRetriever: semantic (cosine) primary + lexical (FTS) complementary,
scoped to the release's pinned knowledge versions
- LlmInvocation records used_fragment_ids (ADR-HUB-0007)
- tests for chunking, indexing, retrieval scope and lexical match
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>