Skip to content

Баги генераторов, рантайма и доков: union без дискриминатора, ретраи записей, потеря контента - #270

Open
lookinway wants to merge 16 commits into
mainfrom
fix/generator-runtime-bugs-2026-07-25
Open

Баги генераторов, рантайма и доков: union без дискриминатора, ретраи записей, потеря контента#270
lookinway wants to merge 16 commits into
mainfrom
fix/generator-runtime-bugs-2026-07-25

Conversation

@lookinway

@lookinway lookinway commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Четыре прохода по репозиторию: проверка накопившихся замечаний по генераторам и рантайму, поиск семантически неверных примеров, проход по ссылкам и контенту доков и сверка спеки с живым API.

Из проверенных находок три оказались ложными и не правились — они разобраны ниже.

Критичное

Журнал аудита не читался ни одним SDK. AuditEventDetailsUnion не имеет общего поля-дискриминатора: селектор лежит на родительском объекте (event_key). Определение дискриминатора сканировало только первого члена union и всегда возвращало имя поля, поэтому эмиттеры подставляли несуществующее поле type. Go возвращал ошибку и ронял разбор всего ответа GET /audit_events, C# и Kotlin падали на дублирующихся дискриминаторах, Swift — на отсутствующем ключе, Python молча отдавал пустые детали.

Теперь поле принимается, только если литерал есть у каждого члена; иначе дискриминатора нет и вариант выбирается по составу полей payload. В Go у такого union появилось поле Raw с исходным JSON — неизвестная форма больше не ошибка разбора. Заодно Python перестал брать «первого члена списка» для union с дискриминатором, что чинит и блоки представлений.

Загрузка файлов возвращала 403. Генератор CLI клал файл в multipart-форму первым, а S3 игнорирует поля после файла — подпись и ключ не доезжали. Бинарное поле теперь эмитится последним, как в рукописном upload.ts.

Записи повторялись при ошибке сервера. В CLI гейт «только идемпотентные методы» стоял лишь на сетевых ошибках, но не на 503; в n8n 5xx ретраился для любого метода. Закоммиченный POST /messages мог отправить сообщение до четырёх раз. 429 по-прежнему повторяется для любого метода — запрос отклонён до обработки.

Узел n8n открывался на необратимой операции. Ресурс Bot по умолчанию вставал на Recreate own bot token — ротацию собственного токена, срабатывавшую от простого Execute. Теперь Get Many.

Спека против живого API

Последний проход — проверка запросами к работающему API, а не чтением кода.

Ответы статуса описаны неверно. GET /profile/status и GET /users/{user_id}/status при отсутствующем статусе возвращают 200 и ровно четыре байта null — без объекта-обёртки. Спека объявляла обёртку с обязательным data, то есть ошибалась и про обёртку, и про обязательность. Добавлено и то, что видно только на живом API: статус с истёкшим expires_at какое-то время продолжает возвращаться целиком.

display_avatar_url и display_name объявлены required, но их нет в ответе. Для сообщений от сотрудников ключи отсутствуют полностью — проверено и на создании, и на чтении. Сделаны опциональными.

Reaction.name не бывает null — снят nullable.

Два обязательных поля приходят null. first_name пуст, пока приглашённый сотрудник не завершил регистрацию, а value дополнительного поля — пока поле не заполнено. Оба объявлены обязательными, поэтому разбор ответа GET /users падал целиком в языках со строгой типизацией: Swift не проходил дальше первого же пользователя с незаполненным дополнительным полем. Оба поля сделаны nullable.

Проверка предотвратила регрессию. Готовилась типизация scopes как OAuthScope[] вместо string[]. Оказалось, что набор по умолчанию у свежесозданного бота содержит скоуп, которого нет в enum спеки, — закрытый тип уронил бы разбор GET /bots в SDK со строгой проверкой enum на любом дефолтном боте. Оставлен string[], ограничение описано словами.

Примеры, которые врали

POST /threads показывал выдуманные message_id и message_chat_id, хотя спека рядом объявляет для них null, а описание метода это прямо обещает. Причина: оба генератора примеров шли сразу в схему и никогда не смотрели на example уровня media type — все девять рукописных примеров молча выбрасывались. Теперь пример из спеки имеет приоритет, как и требует семантика OpenAPI.

Опечатки в примерах формы ломали round-trip: страница открытия формы предлагала отправить timeoff_reguest_form, а вебхук возвращал timeoff_request_form — проверка на равенство никогда не срабатывала. В private_metadata вебхука лежал {'timeoff_id':4378} с одинарными кавычками: JSON.parse на задокументированном значении бросает исключение. Опечатка успела разойтись по спеке, гайду, postman-коллекции и сниппетам SDK — вычищена везде.

Тихая потеря контента в доках

<CardGroup> вырезался целиком из всех сгенерированных .md: обработчик карточек отрабатывает раньше обработчика группы, тот не находит ни одного тега и возвращал пустоту вместе с уже развёрнутыми пунктами. В агентской доке под «Готовые SDK» было пусто — шесть ссылок на SDK и три ссылки установки n8n отсутствовали. Похожим образом терялся lead={<ApiIntroNotes />}.

[Текст](POST /messages) — авторская сокращённая запись, которую резолвит React-рендерер. В генерации доки её не резолвил никто, а по CommonMark адрес с пробелом ссылкой не является: 991 вхождение в 88 артефактах было голым текстом ровно для тех агентов, ради которых эти файлы и делаются.

robots.txt закрывал /internal/og — это og:image каждой страницы, и краулеры соцсетей его уважают, поэтому любая расшаренная ссылка шла без превью.

Плюс 13 битых якорей (транслитерация расходилась с toSlug), 187 двойных точек перед «Пример:», 12 страниц обновлений мимо sitemap, дубль api-catalog, перекрывавший более полный роут-хендлер.

Описания полей

У 32 полей, которые API возвращает как null, не было сказано, когда это происходит. Попутно выяснилось, что Message.thread и MessageWebhookPayload.thread называются одинаково, но значат противоположное (тред, созданный к сообщению, против треда, в котором сообщение отправлено), а описание changed_at было неверным: поле пишется и при создании. У last_name, department и title «не заполнено» приходит и как null, и как пустая строка — проверено по списку сотрудников.

end_time у GET /audit_events документировался как исключающий верхнюю границу, а реализация включает её — выборка за сутки молча захватывала лишнюю запись.

Английская спека чуть не разошлась с русской молча: овёрлей ключуется по JSONPath, поэтому правка русского его не ломает, но и не обновляет, а overlay:validate проверяет только отсутствие кириллицы. Обновлено 55 записей; попутно найдены дубли целей, где запись-дубль перетирала основную.

Ложные находки

  • Тест креда n8n на /oauth/token/info — не баг: у /profile есть обязательный scope, то есть предложенная «безопасная» замена строже и отвергала бы узкоскоупные токены ботов. Зафиксировано комментарием в генераторе
  • isArrayParam не видит $ref-массивы — неверно, парсер резолвит $ref до генератора. Реальный пробел был в allOf и ['array','null']
  • Fallback-ключ примера из описания — ветка недостижима, удалена вместо починки

Примеры SDK не собирались ничем

Прогон всех шести SDK против живого API потребовал сначала собрать их примеры — и выяснилось, что сборка их никогда не касалась. Каждый SDK строится из своего каталога, а примеры лежат в соседнем, поэтому ломались молча, хотя именно на них ссылаются README.

  • Событие видеозвонка не разбиралось нигде. Payload появился в спеке ещё в июльском аудите, а разбор в примерах не обновили. В Kotlin и Swift это ошибка компиляции: when и switch по закрытому типу обязаны быть исчерпывающими. В остальных четырёх языках событие просто молча проваливалось в unknown.
  • Swift и Go: пример загрузки файла обращался к полю Content_Disposition, которого в SDK нет со времён перехода на текущий генератор.
  • TypeScript: PachcaClient.stub вызывался семью позиционными аргументами, хотя принимает один объект-оверрайд. Плюс fileType: "file" вместо значения перечисления FileType.
  • C#: Examples.csproj лежал в .gitignore. Program.cs описывает запуск dotnet run -- <example>, но в чистом клоне собрать примеры было нечем. Проект возвращён в репозиторий.
  • Python: все шесть примеров читали переменные окружения на уровне модуля и не имели if __name__ == "__main__". Чтение перенесено внутрь main().

Гейт закрыт: scripts/build-sdks.sh и sdk.yml собирают примеры всех шести языков. Go-примеры собираются по одному — каждый из них package main в общем каталоге, поэтому go build ./... сообщал только «main redeclared» и ни одной настоящей ошибки. Проверка Python в CI усилена импортом вместо py_compile: py_compile разбирает синтаксис и пропускает импорт типа, которого больше нет в спеке.

Защита от повтора

Добавлены гейты в turbo check:

  • check-examples — каждый рукописный пример спеки обязан дословно присутствовать в сгенерированной доке
  • check-anchors — якоря разрешаются в существующие заголовки, METHOD /path существует в спеке
  • фикстура union-no-discriminator и guard на пустое значение дискриминатора в генераторе
  • тесты на не-ретрай записей, path traversal, formula injection в CSV, контракт op-значений n8n
  • check-release больше не выдаёт ошибку npm за «первый релиз»; check-changelog-sync при недоступном base падает, а не пропускает; check-generated-sync покрывает корневые артефакты

Каждый гейт проверен на реальном баге: на состоянии до фикса падает.

Что осталось за рамками

Требует решения на стороне API или продукта, поэтому отложено, а не сделано наугад:

  • в enum OAuthScope нет agent_search:messages — пока его нет, scopes нельзя типизировать
  • набор скоупов по умолчанию шире назначаемого: group_tags:read выдаётся боту по умолчанию, но при явной установке возвращает 400
  • ключи x-enum-descriptions записаны через _, а значения enum — через :
  • Forwarding.original_thread_id ведёт себя не так, как описан; какое поведение задумано — вопрос к бэкенду

Релиз

Ветка тянет публикацию четырёх пакетов — CLI 2026.7.2, n8n 2.0.16, generator 1.1.10, SDK 1.0.28. Записи в changelog'ах и releases.json на месте, check-release версии принимает.

Проверки

turbo check — 25/25 (2179 тестов CLI, 430 n8n, 28 генератора, линт, типы, knip, формат). check-generated-sync, check-examples, check-anchors чистые. Все шесть SDK и их примеры компилируются — TypeScript, Go, Python, Kotlin, C#, Swift.

Компиляции недостаточно, поэтому каждый SDK прогнан против живого API: профиль, пагинация, типизированная ошибка с вложенным payload и разбор union без дискриминатора. Для Kotlin, Swift и C# этот код исполнялся впервые — до сих пор он существовал только в виде успешной компиляции.

…, порядок multipart

Проверка накопившихся замечаний по генераторам, рантайму CLI/n8n и гейтам.
Три из них оказались ложными, остальные подтвердились по коду и исправлены.

SDK/генератор (1.1.10 / SDK 1.0.28)

`detectDiscriminatorField` сканировал только первого члена union и всегда
возвращал имя поля. Для `AuditEventDetailsUnion` (селектор — родительское
`event_key`, общего литерала у членов нет) это давало несуществующее поле
`type`: Go возвращал `unknown ... type` и ронял разбор всего ответа
`GET /audit_events`, C# и Kotlin падали на дублирующихся дискриминаторах,
Swift — на отсутствующем ключе, Python молча отдавал пустые детали.

Теперь поле принимается, только если литерал есть у каждого члена, иначе
дискриминатора нет и эмиттеры разбирают union по составу полей payload.
В Go у такого union добавлено поле `Raw` — неизвестная форма больше не
ошибка разбора. Python заодно перестал брать «первого члена списка»: для
union с дискриминатором появился реестр `_UNION_DISCRIMINATORS`, что чинит
и `ViewBlockUnion`. Инлайновый enum в Go-параметре генерируется как
`string`, а не `any` (не компилировалось).

Добавлена фикстура `union-no-discriminator` — её отсутствие и позволило
багу уехать в релиз — и guard: пустое значение дискриминатора
(`@SerialName("")`, `JsonDerivedType(..., "")`) валит тесты.

CLI (2026.7.2)

- `files direct-url` клал файл в форму первым; S3 игнорирует поля после
  файла, поэтому подпись и ключ не доезжали и загрузка возвращала 403.
  Генератор теперь эмитит бинарное поле последним, как рукописный upload.ts
- 503 больше не ретраится для не-идемпотентных методов: гейт `safeMethod`
  был только у сетевых ошибок, из-за чего закоммиченный POST мог задвоиться
- явный `--profile` больше не проигрывает ambient `PACHCA_TOKEN`
- `Retry-After` в формате HTTP-date больше не превращается в NaN, значение
  ограничено 60 секундами и не может быть отрицательным
- `downloadFile` берёт basename из `Content-Disposition` — имя из ответа
  редиректа не может увести запись за пределы папки
- CSV: экранирование formula-injection, объекты и массивы как JSON
- `clearTimeout` в finally, `--timeout` меньше 1 отклоняется
- ярлык примера в `--help` = шаг сценария, а не заголовок воркфлоу
  («Добавить реакцию» на `reactions remove`)
- в workflows.ts исправлена команда `pachca thread add` → `threads add`

n8n (2.0.16)

- 5xx не ретраится для POST/PATCH: закоммиченное сообщение могло уйти
  до четырёх раз
- ресурс Bot больше не открывается на необратимой `recreateTokenSelf`
- `user_id` попал в `PATH_PARAM_SEARCH` — у операций со статусом и
  аватаркой вернулся пикер сотрудника
- имя sub-collection считается одним хелпером: UI и роутер расходились
  (`${x}Values` против `singularize(x)`)
- ярлыки group_tags говорят «to chat», порядок в `getParamName` исправлен,
  `tagToResource` больше не даёт «custom propertie»
- searchChats: v2 `limit` вместо v1 `per`, курсор кодируется; поиск
  чатов и сотрудников пагинируется; `sanitizeBaseUrl` во всех хелперах
- пагинация: guard на застрявший курсор в ветке `has_next`
- триггер: вебхук без `webhook_timestamp` отклоняется, если задан секрет

Доки и skills

- `COMMON_ENDPOINT_MAP` применяется по пути, а не по несуществующему тегу
  `Common`: `/custom_properties`, `/uploads` и `/direct_url` вернулись в
  pachca-profile и pachca-messages, orphan-скиллы вне индекса удалены
- `nearestAlternatives` наконец попадают в описание скилла
- снят устаревший guard `/api/search` в proxy.ts — роут-хендлеры давно
  переехали в `/internal`, а `.md`-твины страниц существуют
- body-массивы в CLI-примерах печатаются как JSON (CLI парсит их через
  JSON.parse), POSIX-квотирование в curl и CLI, `${filename}` в ключе S3
- `isNewUpdate` сравнивает даты в одном часовом поясе; дата без кавычек
  во frontmatter больше не ломает загрузку обновлений

Гейты

- `check-release`: ошибка npm больше не выдаётся за «первый релиз»,
  CalVer на границе года даёт 2027.1.0, месяц 13 невалиден
- `check-changelog-sync`: недоступный base — падение, а не тихий пропуск
- `check-generated-sync`: `--force` (корневые артефакты вне turbo outputs)
  и покрытие `skills/**`, `SKILL.md`, `skill.json`, agent-card
- n8n-workflow проверяет, что закоммиченный узел совпадает со спекой

Отдельно проверено и признано корректным: тест учётных данных n8n обращается
к `/oauth/token/info`, а не к `/profile`, потому что у `/profile` есть
обязательный scope и узкоскоупные токены ботов он отверг бы. Зафиксировано
комментарием в генераторе, чтобы не «исправили» обратно.
…round-trip формы починен

Класс проблем, который проходит все проверки: примеры синтаксически валидны
и соответствуют схеме, но показывают не то, что возвращает API.

Рукописные примеры терялись

`generateResponseExample` и `generateRequestExample` шли сразу в схему и
никогда не смотрели на `example` уровня media type. Все 9 примеров,
написанных в спеке руками, молча выбрасывались.

Заметнее всего на `POST /threads`: спека объявляет `message_id: null` и
`message_chat_id: null` (у самостоятельного треда нет сообщения, и описание
метода прямо это обещает), а страница показывала выдуманные из схемы
`154332686` и `2637266154` — то есть ровно противоположное тексту рядом.

Теперь пример из спеки имеет приоритет, как и требует семантика OpenAPI.
У `POST /bots` и `PUT /bot/webhook` пример запроса стал у́же синтетического
(`template`, `challenge_key`, `can_edit` в нём не перечислены) — это
осознанно: поля остаются задокументированы в таблице схемы выше, а пример
теперь показывает реальный минимальный вызов.

Добавлен гейт `check-examples`: каждый рукописный пример спеки обязан
дословно присутствовать в `public/api/**`. Проверен на реальном баге — на
состоянии до фикса падает.

nullable не отображался

`markdown-generator` не выводил `nullable`, поэтому поле, которое API
возвращает как `null`, читалось как обычное непустое. Из 53 nullable-полей
только 15 сообщали об этом текстом описания. Теперь рендерится
`(required, nullable)` — 575 мест.

Двойные точки

Точка перед «Пример:» ставилась безусловно, а конвенция репо требует точку
в конце многопредложного `@doc`. Итог — 187 «..» в 39 файлах и 205 в
llms-full.txt. Строковые примеры печатались через сырое `"${...}"`, из-за
чего значение-JSON получало неэкранированные вложенные кавычки.

Опечатки в примерах формы (typespec.tsp)

Описания webhook-полей говорят «указанный при открытии представления» —
значит значения на двух концах обязаны совпадать. Они расходились:

- `callback_id`: отправь `timeoff_reguest_form`, получи `timeoff_request_form`.
  Проверка `payload.callback_id === "timeoff_request_form"`, написанная по
  странице открытия формы, никогда не срабатывала
- `private_metadata`: в вебхуке `{'timeoff_id':4378}` — одинарные кавычки,
  не JSON. `JSON.parse` на задокументированном значении бросает SyntaxError

Правка затрагивает только строки `@example`, контракт не меняется. Опечатка
успела разойтись по openapi.yaml, гайду, postman-коллекции, llms-full и
сниппетам SDK — вычищена везде.

Отложено как требующее отдельного решения: 13 нарушений конвенции точек в @doc, 38 nullable-полей без описания
случая `null`, `nullable` рядом с `allOf` в ответах статуса (игнорируется
большинством кодогенераторов), `scopes` как `array of string` вместо enum,
ключи `x-enum-descriptions` через `_` против значений enum через `:`,
примеры дат без миллисекунд в `/audit_events`.

Проверено сплошным проходом и чисто: 693 примера против enum, ограничений
длины и диапазона, типов и форматов; 9 примеров операций против их схем;
22 JSON-примера запросов, извлечённых из сгенерированных страниц.
…time

Поведение сверено с реализацией API. Правки затрагивают только тексты
описаний — форма контракта не меняется.

Когда поле бывает null — 38 полей

Из 53 nullable-полей только 15 говорили в описании, когда значение пустое.
Остальные читались как обычные непустые. Условие для каждого выяснено по
реализации, а не угадано. Примеры находок:

- `last_activity_at` — `null`, пока сотрудник ни разу не заходил; это ровно
  то же состояние, что `invite_status: sent`
- `Message.thread` и `MessageWebhookPayload.thread` называются одинаково, но
  значат противоположное: в REST это тред, созданный К сообщению, в вебхуке —
  тред, В КОТОРОМ сообщение отправлено. Теперь это написано в обоих
- `changed_at` — описание было неверным: поле пишется и при создании, так что
  у неотредактированного сообщения оно равно дате создания, а `null` бывает
  только у сообщений старше самого поля
- `revoked_at` и `expires_in` — всегда `null`: по отозванному токену метод
  отдаёт 401, а токены выдаются бессрочно
- `last_used_at` обновляется не чаще раза в час и может отставать

end_time работал не так, как описан

`GET /audit_events` документировал верхнюю границу как исключительную, а бэк
строит `created_at <= end_time` — граница включительная. Выборка за сутки
молча захватывала лишнюю запись ровно на границе.

Пунктуация и примеры

11 нарушений конвенции точек в `@doc` (одно предложение — без точки,
несколько — точка в каждом). Примеры `start_time`/`end_time` приведены к
формату с миллисекундами, который заявляют 32 описания рядом.

Английская спека

Овёрлей ключуется по JSONPath, поэтому правка русского текста его не ломает —
но и не обновляет: `overlay:validate` проверяет только отсутствие кириллицы и
показывал 100%, пока английская дока говорила обратное русской. Обновлены 43
записи. Попутно выяснилось, что у части полей в овёрлее есть дубли целей
(`ApiError.errors.items.*`, `Message.files.items.*`), и запись-дубль
перетирала основную — они выровнены.

Проверено структурным сравнением: полей, где русское описание упоминает
`null`, а английское нет, не осталось.

Отложено как меняющее форму контракта и требующее проверки на живом API: ответы `/profile/status` и `/users/{id}/status` при
отсутствующем статусе отдают голый `null`, а не обёртку `{data: null}`;
`display_avatar_url` и `display_name` объявлены required, но в ответе для
сообщений от людей отсутствуют; `Reaction.name` помечено nullable, хотя
`null` быть не может; `scopes` бота — подмножество 41 из 54 значений enum;
в enum нет `agent_search:messages`; ключи `x-enum-descriptions` через `_`
против значений через `:`; расхождение в `Forwarding.original_thread_id`.

Все шесть SDK компилируются, turbo check зелёный.
Второй проход по сайту доков и артефактам. Правки только в apps/docs —
пакеты не затронуты, поэтому релизных записей не требуется.

Тихая потеря контента

`<CardGroup>` вырезался целиком из всех сгенерированных .md. Обработчик
`Card compact` отрабатывает раньше обработчика группы, тот не находит ни
одного тега и возвращал пустую строку — вместе с уже развёрнутыми пунктами.
Под заголовком «Готовые SDK» в агентской доке было пусто: все шесть ссылок
на SDK и три ссылки установки n8n отсутствовали. Теперь уже развёрнутый
текст сохраняется, а голые nav-карточки по-прежнему отбрасываются.

`lead={<ApiIntroNotes />}` терялся так же беззвучно: обработчик читал только
title и description. Матчить пришлось по всему блоку, а не по атрибутам —
`>` внутри `/>` обрывает захват атрибутов раньше самого свойства.

robots.txt закрывал превью ссылок

`/internal/og` стоял в disallow, а это `og:image` и `twitter:image` каждой
страницы. Twitterbot, Slackbot, LinkedInBot и facebookexternalhit уважают
robots.txt, поэтому любая расшаренная ссылка на dev.pachca.com показывалась
без картинки. Закрытым остался только `/internal/search`.

METHOD-ссылки не были ссылками

`[Текст](POST /messages)` — авторская сокращённая запись, которую резолвит
React-рендерер. В генерации доки её не резолвил никто, а по CommonMark
адрес с пробелом ссылкой не является: 991 вхождение в 88 артефактах было
голым текстом ровно для тех агентов, ради которых эти файлы и делаются.
Резолвится в единственной точке записи, покрывая и llms-full, и skills.

Битые якоря

13 ссылок вели на несуществующие заголовки: транслитерация расходилась с
нашей toSlug (`й→j` вместо `y`, `х→kh` вместо `h`, `щ→shh` вместо `sch`),
плюс два якоря ссылались на переименованные заголовки. Клик по такой ссылке
просто ничего не делает — ни ошибки, ни предупреждения при сборке.

Добавлен гейт `check-anchors`: проверяет и якоря, и что `METHOD /path`
существует в спеке. Проверен на реальном баге — на состоянии до фикса падает.
Им же пойман мёртвый `GET /chats/{id}/messages`, которого в API никогда не
было (правильный метод — `GET /messages`).

Мелкое

- 12 страниц обновлений с релизами, но без файла в content, не попадали в
  sitemap: он обходил loadUpdates, а страницы рендерятся по объединению с
  releases. Теперь оба используют groupTimelineByDate
- `/.well-known/api-catalog` существовал дважды: статический файл в public
  перекрывал роут-хендлер, был беднее на 7 записей и отдавался без
  `application/linkset+json`. Статическая копия удалена, версия OpenAPI
  исправлена с 3.1 на 3.0
- `icon="ExternalLink"` отсутствовал в iconMap — две карточки были без иконок
- захардкоженная версия n8n `v2.0.6` не совпадала ни с чем; убрана, а не
  обновлена — иначе протухнет после следующего релиза
- `/guides/albato.md` возвращал 404: ветка в proxy недостижима, matcher
  исключает `.md`. Добавлен редирект, раз доки обещают агентам, что `.md`
  можно дописать к любому адресу
- llms.txt ссылался на главную как `/.md`, что работало лишь благодаря
  rewrite; теперь `/index.md` напрямую

Проверено чистым: postman, arazzo, дайджесты skills, связность llms.
@cursor

cursor Bot commented Jul 26, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Шесть из 38 полей отложены, а не исправлены: два ответа статуса,
display_avatar_url, display_name, Reaction.name и Forwarding.original_thread_id.
В записи релиза стояло общее число находок вместо числа внесённых правок.
Поведение проверено запросами к работающему API, а не только чтением кода.

Тело ответов статуса

`GET /profile/status` и `GET /users/{user_id}/status` при отсутствующем статусе
возвращают 200 и ровно четыре байта `null` — без объекта-обёртки. Спека
описывала обёртку с обязательным `data`, то есть врала дважды: и про обёртку,
и про обязательность. Теперь тело описано как обёртка либо `null`.

Добавлено и то, что видно только на живом API: статус с истёкшим `expires_at`
какое-то время продолжает возвращаться целиком, пока не будет очищен. Клиент
не должен считать возвращённый статус актуальным по факту наличия.

display_avatar_url и display_name

Объявлены required, но в ответе для сообщений от сотрудников их нет вообще —
проверено и на создании, и на чтении. Сделаны опциональными и описано, что
поля появляются только у сообщений от ботов.

Reaction.name

Снят `nullable`: реакция возвращает `":+1::skin-tone-1:"`, а неизвестный код
emoji отклоняется валидацией — `null` в этом поле не приходит.

Пустая строка наравне с null

У `last_name`, `department` и `title` «не заполнено» приходит и как `null`,
и как пустая строка. Прошлая формулировка «`null`, если не указано» вводила бы
в заблуждение клиента, проверяющего только на `null`.

Скоупы бота: проверка предотвратила регрессию

Собирался типизировать `scopes` как `OAuthScope[]` вместо `string[]`. Проверка
показала, что делать этого нельзя: у свежесозданного бота набор по умолчанию
содержит 37 скоупов, и один из них — `agent_search:messages` — отсутствует в
enum спеки. Закрытый тип уронил бы разбор ответа `GET /bots` в SDK со строгой
проверкой enum на любом дефолтном боте. Оставлен `string[]`, ограничение
описано словами.

Заодно подтверждено: `group_tags:read` входит в набор по умолчанию, но при
явной установке возвращает 400 «Scopes restricted by role cannot be assigned».
Это расхождение самого API, в спеке оно не чинится — описано обтекаемо.

Английская спека обновлена под все изменённые описания, расхождений
«русское упоминает `null`, английское нет» не осталось.

Все шесть SDK компилируются, turbo check зелёный.
@lookinway
lookinway force-pushed the fix/generator-runtime-bugs-2026-07-25 branch from 571433e to dfc41a5 Compare July 26, 2026 17:53
lookinway added 10 commits July 27, 2026 21:25
Функция выглядит как лишний слой: если ключи расширения должны совпадать со
значениями enum, логично поправить источник и убрать нормализацию. Проверено —
поправить нельзя.

TypeSpec не принимает квотированные ключи в объектных литералах: и
`#{ "chats:read": ... }`, и `#{ "simple": ... }` падают с `Property expected`,
то есть дело в самом квотировании, а не в двоеточии. Имя члена enum двоеточие
содержать тоже не может, поэтому эмитируемые ключи никогда не совпадут со
значениями.

Касается единственного enum — `OAuthScope`, у остальных значения являются
корректными идентификаторами и имя совпадает со значением.

Комментарий, чтобы следующий не проходил этот путь заново.
Дата 25 июля не соответствовала действительности: сверка с работающим API и
проход по докам шли по 27-е. Проставлена дата завершения.

Записи пересобраны по принципу «кому это адресовано».

В блоке SDK лежали вперемешку изменения кода и исправления описаний. Читателю,
который открывает «SDK v1.0.28», нужны первые: разбор union без общего
дискриминатора, опциональность display_avatar_url/display_name, снятый nullable
у Reaction.name. Они и остались, шесть строк вместо девяти.

Исправления описаний API вынесены в запись обновлений — они одинаково важны и
тем, кто SDK не использует вовсе. Там же прямо сказано, что сам API не менялся,
менялось описание, и перечислено, что стоит перепроверить в своём коде: граница
end_time включительная, ответ методов статуса при отсутствующем статусе, поля
сообщения от сотрудников, незаполненные строковые поля.

Из блока generator убраны две строки. Инлайновый enum в query-параметре и
неоднозначные ключи расширения — правки на случай, который в текущей спеке не
возникает: ни один пользователь этого не видел и увидеть не мог. По правилу
о dev-only изменениях такие в changelog не попадают.
Запись читалась как признание недосмотра — «сверили с реальным поведением»,
«описание расходилось», «если вы писали код по прежнему описанию». Читателю
нужно, как метод работает, а не как мы это выясняли.

Осталась одна вводная строка о том, что работа API не менялась: без неё
формулировка «end_time включает границу» читается как изменение поведения.
Обе находки — от прогона против работающего API, а не от чтения кода.

Python SDK падал на импорте

Реестр дискриминаторов union ссылается на все union'ы, а список импортов
в utils.py собирался только по тем, у которых есть свой десериализатор.
Итог — NameError при `import pachca.utils`, то есть библиотека не работала
целиком.

Проверка сборки этого не ловила: `py_compile` только разбирает синтаксис,
а обращение к неопределённому имени на уровне модуля компилируется нормально
и падает при импорте. Добавлен реальный импорт пакета с заглушкой httpx —
проверка не требует зависимостей. Проверена на этой же регрессии: без фикса
генератора падает, с фиксом проходит.

Лимит длины сообщения

Гайд про входящие вебхуки обещал 40 000 символов, гайд для агентов — 40 КБ.
Разные единицы, и для кириллицы это отличается вдвое.

Замерено двоичным поиском по границе: 40 000 байт. Латиница 40 000 принимается,
кириллица 20 000 принимается, 20 001 отклоняется. То есть верна была вторая
формулировка. Первая исправлена, добавлено пояснение про кириллицу — иначе
русскоязычный интегратор рассчитывает на вдвое больший объём и получает 422.

Из проверенного по гайдам остальное сошлось: форма meta у поисковых методов
(без has_next), отсутствие meta у /profile, дефолт сортировки сообщений,
401 на неверный токен.
…переписаны

Второй заход по утверждениям в гайдах — проверка запросами к работающему API.

Лимиты кнопок

Заявлено «максимум 8 кнопок в строке» и «максимум 100 кнопок у сообщения».
Ни то, ни другое не подтвердилось: 9 кнопок в строке принимаются, а сообщение
со 100 кнопками отклоняется.

Замерено двоичным поиском: единственное ограничение — 32 строки. Внутри строки
API не ограничивает количество (200 кнопок в одной строке проходят), и 32
строки по 200 кнопок — 6400 штук — тоже принимаются.

Описано как есть, с оговоркой про читаемость: интерфейс строку не переносит,
поэтому больше пяти-шести кнопок в ряд плохо смотрятся на узких экранах. Это
рекомендация, а не ограничение API, и теперь так и написано.

Удалённое сообщение

Гайд утверждал, что после удаления «добрать его по id не получится». На деле
метод отвечает 200 и возвращает очищенную запись: пустой content, снятые
вложения и реакции, заполненный deleted_at. В списке сообщений её нет.

Разница существенная для обработчика вебхука: по событию delete можно дочитать
запись и опереться на deleted_at, а не считать сообщение недоступным.

Что сошлось

Треды: идемпотентность повторного создания, объект thread ровно из id и
chat_id, отправка по entity_id без entity_type, root_chat_id, рост updated_at,
самостоятельный тред с одним участником и null в message_id, 400 на тред
внутри треда, 404 на тред у удалённого сообщения.

Боты: требование окончания _bot, access_token только в ответе создания, все
четыре значения who_can_add, пустой can_edit, неизменность single_chat при
редактировании, отключение вебхука пустой строкой.

Очистка кнопок пустым массивом, атомарность превью ссылок, форма meta у
поисковых методов, дефолт сортировки сообщений, 401 на неверный токен.

Экспорт и журнал аудита проверить не удалось: их скоупы выдаются только
владельцу пространства.
Ни одна из этих находок не всплыла на компиляции и юнит-тестах — только на
реальных запросах.

Загрузка файла падала при успехе

S3 отвечает 204 с пустым телом, клиент кладёт в data null, а сгенерированная
команда читала из него поле — TypeError на каждой удачной загрузке. Генератор
теперь подставляет пустой объект.

Заодно замерен настоящий код отказа при неверном порядке полей формы: S3
отвечает 400, а не 403, как было записано. Сама правка порядка подтверждена
на живом S3 — файл последним даёт 204, файл первым 400.

Ошибки API не разбирались в типизированный ApiError

Поле payload описано в спеке как объект с произвольными значениями, но
генератор превращал пустую схему additionalProperties в строку. В Go, Kotlin
и C# получался словарь строк, а реальный payload ошибки доступа содержит
вложенный объект — разбор падал, и клиент получал безымянную обёртку вместо
ApiError. Проверено: errors.As в Go теперь находит тип.

В Kotlin для произвольного значения используется JsonElement: Any там не
сериализуется, kotlinx требует явный сериализатор.

Проверка сборки SDK пропускала падения

`run` вызывался внутри подоболочки, поэтому fail=1 оставался в ней, а родитель
завершался нулём. Скрипт печатал FAIL и тут же «all toolchains OK» — так и
случилось, когда сломался Kotlin. Незамеченными проходили падения Go, Python,
Kotlin, C# и Swift: пять языков из шести. Добавлен run_in, который изолирует
каталог, но проверяет код возврата в родителе. Проверено на реальной поломке —
скрипт возвращает 1.

Что подтвердилось живыми вызовами

CLI: отказ при --timeout меньше 1, объекты в CSV как JSON, пагинация --all,
полный цикл загрузки файла. SDK на TypeScript и Go: профиль, списки,
завершение пагинации, разбор ошибок.
Прогон всех шести SDK против живого API. Swift и Kotlin проверены
впервые: разбор union без дискриминатора и типизированный ApiError с
вложенным payload теперь подтверждены исполнением, а не компиляцией.

Спека:
- User.first_name объявлено nullable — API отдаёт null, пока приглашённый
  сотрудник не завершил регистрацию (invite_status = sent).
- CustomProperty.value объявлено nullable — null, если поле не заполнено.

Оба поля ломали разбор ответа GET /users в языках со строгой типизацией:
Swift падал на первом же пользователе с незаполненным дополнительным полем.

Примеры SDK:
- Во всех шести языках добавлен разбор события видеозвонка в истории
  вебхуков. В Kotlin и Swift это была ошибка компиляции (when/switch
  обязаны быть исчерпывающими), в остальных — молчаливый пропуск события.
- Swift и Go: поле загрузки файла переименовано в ContentDisposition.
- TypeScript: PachcaClient.stub принимает объект-оверрайд, а не позиционные
  аргументы; fileType — значение перечисления FileType.
- Python: чтение переменных окружения перенесено внутрь main(), добавлен
  guard `if __name__ == "__main__"`.
- C#: возвращён в репозиторий Examples.csproj, без которого описанный в
  Program.cs запуск `dotnet run -- <example>` не работал.

Гейт:
- scripts/build-sdks.sh и .github/workflows/sdk.yml собирают примеры всех
  шести языков. Раньше собирался только исходник SDK, а примеры лежат в
  соседнем каталоге — они ломались молча, хотя на них ссылаются README.
- Go-примеры собираются по одному: каждый из них package main в общем
  каталоге, поэтому `go build ./...` сообщал только «main redeclared».
- Проверка Python в CI усилена импортом вместо py_compile.
Полная проверка v2: все 17 ресурсов и 76 операций прогнаны через
установленную ноду в локальном n8n. Каждая операция бьёт по пути, который
есть в спеке, с нужным методом и авторизацией.

Починено:

- User → Update Avatar и Chat → Download Export собирали адрес запроса из
  объекта выбора сущности, а не из идентификатора: в URL уходило
  "[object Object]". Обе ветки обходили общий сборщик URL и читали параметр
  напрямую, минуя разбор локатора. Выгрузка чата ломалась и в опубликованной
  версии.
- Выбор сотрудника открывался пустым: список подтягивался только после ввода
  текста. Теперь без фильтра показывается список сотрудников, как это уже
  работает у чатов.

Правки внесены в генератор: SharedRouter.ts собирается им, ручное изменение
стёрлось бы следующей сборкой.

Совместимость проверена настоящим обновлением: workflow, сохранённый на
опубликованной версии с числовым userId, после установки этой сборки
выполняется с тем же результатом, а в редакторе значение сохраняется и
видно в поле.

Тесты: прогон локаторной формы по всем операциям с выбором сущности — на
состоянии до правок падает на обеих сломанных. Плюс проверка, что старое
числовое значение остаётся рабочим.

Проверено и расхождений не найдено: операции UI против таблицы маршрутов,
маршруты против openapi.yaml, значения выпадающих списков против enum спеки,
19 сопоставлений событий триггера против схем вебхуков, порядок полей
multipart при загрузке файла, набор параметров против опубликованной версии.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant