Перейти до вмісту

Чат-мости

Чат-міст робить зовнішній чат-продукт — сьогодні це Slack — повноцінним шляхом до робочого простору Control Center. Згадайте бота — і прокинеться агент; дайте відповідь у гілці Slack — і розмова продовжиться; введіть slash-команду — і створиться тікет. А під цим — ті самі простори, транскрипти, тікети й атрибуція, якими користується застосунок для комп’ютера. Чат — це поверхня робочого простору, а не його копія.

Єдиний провайдер, який постачається, — Slack. Реалізацій Discord, Telegram, Teams чи Matrix у продукті немає ніде. Цю функцію навмисно написано без прив’язки до провайдера: додати другий продукт — це одне значення enum, один адаптер і один плагін, — але сьогодні «чат» означає Slack.

Цю можливість охоплюють дві поверхні, і належать вони різним людям. Підключення робочого простору до Slack і налаштування бота — це дії адміністратора в розділі Налаштування → Робочий простір → Загальні. Прив’язка власного облікового запису в чаті до вашої особи в Control Center — дія учасника в розділі Налаштування → Робочий простір → Профіль і ідентичність.

Усю цю функцію визначають три рішення: у якому напрямку встановлюється з’єднання, що дає право повідомленню, яке надходить ззовні, і де дозволено зберігати знання, специфічні для продукту.

Міст ніколи не слухає провайдера. Зі Slack міст з’єднується через Socket Mode: сервер запитує у Slack URL сокета й сам встановлює вихідне з’єднання, а потім отримує через нього згадки, особисті повідомлення та slash-команди.

Саме наслідок тут і є суттю. Ноутбук за NAT, домашній сервер без домену, машина, що переміщається між мережами, — усі вони можуть працювати з мостом без вхідного порту, без тунелю, без сертифіката TLS і без публічного URL, який доводилося б оновлювати, коли він змінюється. Немає ендпоінта webhook, який треба захищати, бо ендпоінта webhook немає взагалі. Коли Control Center формує визначення застосунку, він жорстко задає Socket Mode і прибирає будь-який request URL, бо застосунок, налаштований на обидва варіанти, працював би наполовину.

Ціна цього — з’єднання, яке треба підтримувати живим, і це робота сервера: перепідключення виконується з експоненційним збільшенням затримки та джиттером і верхньою межею 30 секунд, ротація сокета за ініціативою провайдера перепідключається, не нарощуючи цю затримку, а відхилені облікові дані — це остаточна відмова: нескінченні спроби з відкликаним токеном дали б лише обмеження частоти, тож міст зупиняється й чекає на нові токени, дослівно показуючи в налаштуваннях власну помилку провайдера.

Робочий простір без чат-застосунку не коштує нічого: ні сокета, ні таймерів, ні трафіку. І все це запускається після банера готовності сервера, бо чат-застосунок, який повільно відповідає, ніколи не повинен затримувати завантаження.

Членство дає право, а не володіння токеном

Розділ із назвою «Членство дає право, а не володіння токеном»

Той, хто встановив застосунок, — не той, хто надсилає повідомлення. Тому кожна вхідна подія, перш ніж щось станеться, зводиться до Principal, і за найменшої невідповідності запит відхиляється з поясненням:

  • Неприв’язаному обліковому запису в чаті пояснюють, як виконати прив’язку, у тій гілці Slack, де він написав.
  • Прив’язаному обліковому запису, користувач якого не є учасником цього робочого простору, радять попросити в адміністратора запрошення.
  • Учаснику, чия роль доступна лише для читання (viewer, guest), повідомляють, що його роль не дозволяє запускати роботу.

Жоден із цих випадків не продовжується. Повідомлення з чату ніколи не приписується тому, хто його не надсилав, — саме це зберігає чесними журнал аудиту, обмеження частоти для кожного Principal і авторів тікетів. Модель членства, на яку все це спирається, описано в розділі Спільна робота — ідентичність, членство та присутність.

Прив’язка — це зіставлення особи, а не надання доступу. Членство перевіряється заново на кожній події, тож прив’язка того, кого згодом вилучили, дає відмову, а не його колишні привілеї. Прив’язку можна встановити двома шляхами: короткоживучий одноразовий код, який учасник вводить у чаті, або автоматичне зіставлення за підтвердженою адресою електронної пошти в провайдера — і шлях через пошту все одно вимагає, щоб зіставлений користувач уже був учасником, тож стороння особа, чия адреса випадково є на сервері, не прив’язується ні до чого.

Коди навмисно тримають у пам’яті з часом життя 15 хвилин. Збереження такого коду створило б довговічний рядок, схожий на облікові дані, для того, що має зникнути, а перезапуск сервера, який знецінює всі невикористані коди, — це правильна поведінка, а не регресія: учасник просто натискає «прив’язати» ще раз.

На відмову відповідають у тій гілці Slack, яку відкрило повідомлення, справжньою відповіддю. Відповідь у гілці позначає повідомлення, на яке відповідає, доходить до єдиної людини, якій це потрібно, і при цьому не виносить «ви не прив’язані» в сам канал. До того ж надсилання повідомлення — це єдина можливість, яка є в кожного провайдера, тож пояснення ніколи не залежить від функції, якої застосунок може не мати.

Міст, який дзеркалить в обидва боки, зациклиться, якщо щось не розрізнятиме ці два напрямки. Кожне повідомлення, яке міст приймає, позначається metadata['chat'] — провайдер, команда, розмова, гілка, повідомлення й відправник. Вихідне дзеркало пропускає будь-яке повідомлення з цією позначкою.

Саме це єдине правило робить двостороннє дзеркалення безпечним: повідомлення людини, набране в Control Center, передається назовні в прив’язану гілку (з іменем автора на початку), а повідомлення, що надійшло з чату, зупиняється там, де увійшло.

Назовні проходить навмисно вузький набір. Відповідь агента передається як звичайний текст чату. Коротка думка може з’явитися в рядку Thinking… картки. Вивід інструментів — це стіна дифів, і він залишається в Control Center, куди ж веде й урізана відповідь.

Картка звітує про роботу; текст залишається повідомленням

Розділ із назвою «Картка звітує про роботу; текст залишається повідомленням»

Там, де провайдер їх відображає, хід несе одну картку завдання, яку редагують на місці, поки триває хід, і яка стоїть над відповіддю, що її пояснює.

Картка — це один згрупований план, а не зліплений абзац. Slack об’єднує details і output в одному завданні, тож деталі рядка надсилають один раз (думку в рядку Thinking…), а output ніколи не передається потоком. Підготовка — це один рядок, заголовок якого замінюється (Working on it…, Cloning …). Міркування додає рядок Thinking…. Кожен інструмент — окремий рядок (Read lib/main.dart). Відповідь — це звичайний текст під карткою: подання плану в Slack читає його з пізнішого долучення markdown, а не з фрагмента, домішаного до завдань. І більше нічого: жодного виводу інструментів і лише аргумент інструмента з короткого списку дозволених, бо аргументи невідомого інструмента можуть бути цілим тілом файлу, а рядок — це один рядок.

Картка охоплює й очікування перед ходом. Перша згадка створює розмову, а її створення клонує кожен репозиторій, у якому працюватиме агент, — і на реальному репозиторії це хвилини, коли агента ще ні про що не спитали, а гілка виглядає так, ніби бот проігнорував запит. Тож картка відкривається тієї ж миті, коли згадку прийнято (Working on it…), потім рядок підготовки оновлюється тими самими словами, які показує застосунок для комп’ютера, — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… — далі рядок Thinking…, щойно агент починає міркувати, а потім по рядку на кожен інструмент. Одна картка, одне повідомлення — від «я вас почув» до відповіді звичайним текстом під нею. Про підготовку повідомляють лише там, де її хтось чекає в чаті: розмова, яку готують у застосунку, або застрягла розмова, відновлена під час запуску сервера, розгортається взагалі без картки.

Вона несе єдине, чого чат не може дати читачеві: посилання View in Control Center на розмову за відповіддю. Чат-продукти приймають у посиланні http(s) і нічого більше, тож кнопка вказує на невелику сторінку, яку віддає цей сервер і яка перенаправляє браузер на посилання control-center:// застосунку для комп’ютера. Ця сторінка нічого не читає й нічого не вирішує — ідентифікатори з її URL передаються в глибоке посилання і більше нікуди, — а сервер без доступного публічного URL просто створює картку без кнопки, а не зламану.

Картки — така сама можливість, як і будь-яка інша. Провайдер без них поводиться точно як раніше, лише текстом, а провайдер, який відмовляє від картки під час виклику, зберігає свій потік: відповідь завжди важливіша за картку, що її описує. Коли потокову передачу вимкнено повністю, готова картка їде разом з єдиним надісланим повідомленням. Створений тікет отримує таке саме ставлення: підтвердження /cc ticket — це завершена картка, єдиний сенс якої — посилання на щойно створений тікет.

Можливості узгоджуються, а потім запам’ятовуються

Розділ із назвою «Можливості узгоджуються, а потім запам’ятовуються»

Чат-продукти різняться тим, що вони вміють відображати, тож міст написано під найбагатшу поверхню — відповідь, що росте наживо в гілці, тимчасовий статус «is thinking…», гілку із заголовком, ефемерну відповідь на slash-команду — і відмовляється від можливостей по одній. Провайдер, у якого немає нічого з цього, все одно працює: відповіді просто приходять одним повідомленням.

Заявлена можливість — це ще не обіцянка. Потокові відповіді Slack потребують функції платного тарифу, і Slack відмовляє лише під час виклику. Відмову, позначену як постійна, запам’ятовують на весь час життя з’єднання: міст перестає питати й відтоді надсилає відповіді цілком, а налаштування прямо про це повідомляють, бо на «мої відповіді перестали йти потоком» має бути відповідь і без читання журналу. Картки завдань відкидаються так само, якщо Slack відмовляє від однієї з них.

Кожна межа належить провайдеру, а не ретранслятору: стеля довжини повідомлення, стеля фрагмента потоку й те, чи потрібен потокові якір гілки, оголошуються адаптером, а довга відповідь розбивається за межею абзацу, рядка чи слова, а не посеред слова.

Усе, що має форму продукту, живе за портом. Загальне ядро знає про markdown, розмови в чаті, гілки чату й учасників; воно нічого не знає про конверти Slack, mrkdwn чи gateway intents. Це забезпечують три частини:

  • Адаптер нормалізує один продукт: його транспорт, його виклики API, його текстовий кодек і його особливості конвертів. Особливості Slack повчальні: згадка в каналі надходить двічі (як app_mention і як message), а більшість підтипів повідомлень — це оформлення, а не нові запити. І те й те вирішується в адаптері, тож ядро ніколи не дізнається, що Slack доставляє двічі.
  • Дескриптор — це публічна форма провайдера: які облікові дані йому потрібні, які в них формати, які URL допомагають користувачеві їх знайти, що він уміє і чи можна створити або змінити його застосунок звідси. Картка налаштувань, діалог підключення й перевірка аргументів — усе це генерується з нього: клієнт надсилає назад провайдера та карту з ключами за ідентифікаторами полів.
  • Плагін — це серверна композиція: як облікові дані стають перевіреним з’єднанням, як збирається адаптер і як керується застосунок на боці провайдера.

Отже, додавання продукту — це одне значення enum, один адаптер і один плагін. Жодної нової операції RPC, жодної зміни схеми, жодної зміни клієнта — і клієнт залишається чесним, бо поле, про яке він ніколи не чув, усе одно відображається з міткою, яку надав сервер.

Дві деталі не дають абстракції протекти нагору. Кроки керування застосунком, для яких у провайдера немає API (створити токен, установити застосунок, перевстановити після зміни дозволів), повертаються як іменовані кроки з власними посиланнями, тож екран завершення налаштування показує їх, не знаючи, про якого провайдера йдеться. А кожна інструкція, яку пише міст, називає провайдера й ту команду, яку застосунок насправді використовує, тож перейменована команда або другий провайдер читаються правильно без нового рядка.

Чат-токени робочого простору лежать у його власному каталозі, поруч із його базою даних — як звичайний JSON за шляхом <dataDir>/<workspaceId>/chat_credentials/<provider>.json, не в базі даних і навмисно не у зв’язці ключів ОС. Два наслідки легко згодом упустити: видалення робочого простору відв’язує каталог, і токени зникають разом із ним, а експорт чи резервне копіювання робочого простору копіює workspace.db, тож робочий простір, переданий комусь іншому, не несе всередині живих токенів бота. Файл успадковує межу довіри файлової системи хост-сервера: усе, що має доступ на читання до каталогу даних, може прочитати токен бота, а це та сама межа, за якою лежать бази даних.

Один файл на провайдера, який записують атомарно й лише для власника: відкликаний застосунок видаляється відв’язуванням одного файлу, а пошкоджений файл коштує одного провайдера, а не всіх. Облікові дані заходять усередину через операцію підключення й ніколи не повертаються назад: читання статусу відповідає лише метаданими з’єднання.

  • Структурні налаштування застосунку. Транспорт, області доступу й підписки на події задаються примусово, а не виставляються назовні. Застосунок, який можна налаштувати до повного мовчання з екрана налаштувань, — це звернення до підтримки, а не функція.
  • Розмова та slash-команди. Поверхня Slack — це повідомлення, відповіді в гілках і slash-команди.
  • Друге джерело істини. Чат — не місце, де зберігається робота. Гілка Slack — це простір, відповідь — транскрипт, тікет — тікет, і все це в робочому просторі, доступне з будь-якого клієнта.