Reference in New Issue
Block a user
Delete Branch "fix/issues-190-159"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Батч из двух независимых правок на одной ветке (
fix/issues-190-159), по одному коммиту на находку.fix(ai-chat): дружелюбные ошибки валидации tool-input — #190
При параллельной партии tool-call'ов модель иногда роняет обязательный параметр (обычно повторяющийся
pageId). zod отклонял ввод, а AI SDK отдавал модели сырой zod-текст (Invalid input: expected string, received undefined) — по нему модель не могла осознанно повторить вызов.Добавлен централизованный враппер входных схем
modelFriendlyInput(shape):z.toJSONSchema(z.object(shape), { target: 'draft-7' })—required/description/ограничения не меняются (контракт сохранён);validateвозвращает человекочитаемое сообщение с именем проблемного параметра и подсказкой повторить со всеми обязательными полями;deletePageне пробрасываетpermanentlyDelete/forceDelete;transformPage—deleteComments).Применён в
ai-chat-tools.service.ts(билдерsharedTool+ все инлайн-тулы) иpublic-share-chat-tools.service.ts. Значения не угадываются, контракт required/optional не тронут.fix(page): защита от цикла при встречных перемещениях — #159 (находка #9)
Гонка двух встречных перемещений (A тянет X под Y, B одновременно Y под X) проходила проверку цикла против устаревшего снимка, и обе записи коммитились →
X.parent=YИY.parent=X: цикл без пути к корню, рекурсивные ancestor-CTE ломаются, оба поддерева пропадают из сайдбара. Guard и запись не были в транзакции.Для подлинного переноса под конкретного родителя guard+запись обёрнуты в одну
executeTx-транзакцию: строки перемещаемой страницы и целевого родителя блокируютсяFOR UPDATEв каноническом (по id) порядке (сериализация без дедлока), проверка цикла перевыполняется внутри транзакции (проигравший видит закоммиченный репарент и отклоняется существующей ошибкой «Cannot move a page into its own subtree»). Реордер в том же родителе и перенос в корень идут прежним путём (цикл невозможен). Phantom-broadcast gate иPAGE_MOVEDбез изменений.Границы: закрыта двухсторонняя гонка (находка #9). Трёхсторонняя цепочка (A→B, B→C, C→A) — вне рамок, отмечена как известное ограничение. Находка #5 (токенный бюджет) намеренно не делается (депириоритезирована в самом issue).
Тесты
model-friendly-input.spec.ts(сообщение именует параметр + подсказка; лишние ключи срезаются; JSON-схема сохраняет required/description); два.parse()-теста гайдрейлов переведены на новыйvalidate-путь.findByIdс{ withLock: true, trx }для обоих id в отсортированном порядке; trx прокинут вgetPageBreadCrumbs/updatePage) + сохранены прежние гарантии; интеграционныйpage-move-cycle-concurrency.int-spec.ts(реальный Postgres в CI): два встречных перемещения дают ровно один успех + один отказ и никогда не сохраняют цикл.tsc --noEmitчистый; затронутые юнит-наборы зелёные.fix/ai-chat-new-chat-during-stream).When the in-app AI chat issued a parallel batch of tool calls, the model sometimes dropped a required parameter (typically a repeated `pageId`). zod rejected it and the AI SDK surfaced the raw zod text ("Invalid input: expected string, received undefined") to the model, which is not actionable — so it could not reliably retry. Add a centralized `modelFriendlyInput(shape)` wrapper for in-app tool input schemas: - the model-facing JSON schema is derived from the SAME zod shape via `z.toJSONSchema(z.object(shape), { target: 'draft-7' })`, so `required`, `description` and field constraints are unchanged (contract preserved); - on a validation failure `validate` returns a human-readable Error naming the offending parameter(s) plus a fixed retry hint ("Include every REQUIRED parameter ... do not drop ids like pageId"), which the SDK relays to the model via InvalidToolInputError; - on success it returns the parsed (unknown-key-stripped) data, preserving the existing strip guardrails (deletePage never forwards permanentlyDelete/ forceDelete; transformPage never forwards deleteComments). Applied in ai-chat-tools.service.ts (the sharedTool builder + every inline tool inputSchema) and in public-share-chat-tools.service.ts. Values are never guessed and the required/optional contract is untouched. Tests: new model-friendly-input.spec.ts (friendly message names the param + hint; unknown keys stripped; JSON schema keeps required/description); the two .parse()-based guardrail tests reworked to assert via the new validate path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Code review — PR #200: дружелюбные ошибки tool-input в AI-чате (#190) + guard от циклов при конкурентных перемещениях страниц (#159)
Вердикт: Approve with comments. Логика корректна и безопасна (security/regressions чисты, цикл при гонке re-parent действительно сериализуется через
FOR UPDATE+ re-check внутри транзакции). Блокирует только одно: по конвенции репозитория обе пользовательские правки должны попасть вCHANGELOG.md [Unreleased], а он не тронут. Остальное — non-blocking.Объём: дифф
develop…fix/issues-190-159(merge-base3ddc329b), 8 файлов, +467/−79. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.Must fix before merge
CHANGELOG.md [Unreleased]для #190 и #159 —CHANGELOG.md:11Репозиторий ведёт Keep-a-Changelog с секцией
[Unreleased], и недавние PR (#163, #146/#147, #183) последовательно добавляли пункты по каждой пользовательской правке; ревью-коммиты прошлых PR трактовали отсутствие записи как дефект. PR везёт два user-facing изменения (исправление потери поддеревьев из-за цикла при конкурентном перемещении — #159; дружелюбные ошибки валидации tool-input в AI-чате с указанием параметра и retry-подсказкой вместо сырого zod-текста — #190), ноCHANGELOG.mdв диффе пустой. Fix: под## [Unreleased]добавить### Fixed-пункт для #159 (опаздывающие re-parent сериализуютсяFOR UPDATEс re-check цикла внутри транзакции) и### Added/### Changed-пункт для #190, в стиле существующих записей с ссылками на (#159) и (#190).Non-blocking
[stability] Брать lock по каноническим UUID (
movedPage.id,parentPageId), а не по сыромуdto.pageId—apps/server/src/core/page/services/page.service.ts:943-944Защита от дедлока держится на том, что оба встречных перемещения сортируют ОДНИ И ТЕ ЖЕ две строки-идентификатора и берут
FOR UPDATEв одинаковом порядке. Сейчасconst lockIds = [dto.pageId, parentPageId].sort():parentPageId— уже разрешённый UUID (parentPage.id), аdto.pageId— сырое клиентское значение, иfindByIdпринимает также slugId (MovePageDto.pageId— неограниченный@IsString). Sidebar всегда шлёт UUID, так что на практике безопасно, но REST/agent-клиент, передавший slugId в двух одновременных встречных перемещениях (A: X под Y, B: Y под X), даст противоположный физический порядок блокировок и воспроизведёт AB-BA дедлок, который Postgres разрешит, отдав 500 вместо дружелюбногоBadRequestException. Повреждения данных нет (цикл всё равно предотвращается), но контракт «не может задедлочиться» из комментария нарушается для slug-входа. Fix:const lockIds = [movedPage.id, parentPageId].sort();—movedPage.idэто UUID, разрешённый контроллером,parentPageIdуже UUID;dto.pageIdдля breadcrumb-check /updatePageоставить как есть (та же строка в транзакции).[simplification] Вынести дублированный stub
makeChain(Proxy для chainable-trx) в один модульный хелпер —apps/server/src/core/page/services/page.service.spec.ts:46-56,327-337PR добавляет две байт-в-байт копии Proxy-стаба
makeChainповерх уже существующей в блокеmovePageToSpace()на develop — три идентичные копии ~10-строчного хелпера в одном файле, каждую правку trx-стаба придётся делать в трёх местах (сами комментарии диффа отмечают, что «mirror the pattern»). Fix: завести одинmakeChain()/makeTrxStub()на уровне describePageService(или модуля) и переиспользовать во всех трёх местах, удалив новые дубли.Test coverage
Новая логика покрыта неравномерно: есть unit-спеки cycle-guard и интеграционный тест конкурентных перемещений, а
model-friendly-input.spec.tsпокрывает основной путь форматтера. Без покрытия остаются:[test-coverage] Покрыть unlocked else-ветку
movePage(move-to-root / reorder под тем же родителем) —apps/server/src/core/page/services/page.service.ts:939-977Дифф разветвляет
movePageна locked-путь (typeof parentPageId === 'string') и новую else-ветку, выполняющуюthis.pageRepo.updatePage(updateValues, dto.pageId)БЕЗ транзакции для move-to-root и same-parent reorder. Все movePage-спеки и интеграционный тест используют толькоparentPageId:'dest-parent'(locked-ветку); self-move отвергается до развилки. То есть самая частая бытовая операция — перемещение в корень/реордер — не прогоняется ни одним тестом; регрессия в условии развилки или в аргументах unlocked-update пройдёт весь suite. Fix: в describe «movePage cycle guard» добавить тест сdto.parentPageId === null(move-to-root), проверяющий, чтоupdatePageвызван БЕЗ trx (mock.calls[0][2]undefined) иthis.db.transactionне вызывался; опционально кейс same-parent reorder с теми же проверками.[test-coverage] Покрыть ветку де-дупликации повторяющегося параметра в
formatIssues—apps/server/src/core/ai-chat/tools/model-friendly-input.ts:456-468formatIssuesреализует де-дуп повторяющихся имён параметров (if (seen.has(name)) continue;), но ни один тест не порождает два zod-issue на один path, так что ветка не исполняется. Косметика, низкий риск, но это заявленное новое поведение, ничем не зафиксированное. Fix: вmodel-friendly-input.spec.tsдобавить форму, дающую два issue на один параметр (например refine + type error), и проверить, что имя параметра в сообщении не повторяется.Architecture & design (forward-looking, non-blocking)
apps/server/src/core/ai-chat/tools/model-friendly-input.tsvspackages/mcp/src/tool-specs.ts(SharedToolSpec.buildShape),packages/mcp/src/index.tsНовый
modelFriendlyInput()чисто консолидирует in-app путь (заменяет ~25 inlinez.object(...)одним хелпером, который выводит JSON-схему draft-07 И прикрепляет дружелюбную ошибку:formatIssues+RETRY_HINT). Но источник схемы общий: и in-app сервисы, и standalone MCP-сервер строят input-схемы из однойSharedToolSpec.buildShape. PR добавляет дружелюбное поведение только на in-app ветке; MCP-сервер по-прежнему регистрирует сырой zod-shape, так что внешние MCP-клиенты на том же сценарии «потерял pageId в параллельном батче» (ради которого затевался #190) получат сырой zod-текст. Не дефект данного изменения — #190 был ограничен in-app чатом и там решён полностью; важно лишь если тот же сбой заденет внешнего MCP-клиента.formatIssues/RETRY_HINTили потребует рефактора.packages/mcp) с opt-in для обоих потребителей (effort: M). Pros:formatIssues+RETRY_HINTстановятся единым источником формулировок для in-app и standalone MCP; хелпер живёт там же, где схема; новые tool-ы наследуют единый UX. Cons: общая spec намеренно zod-/SDK-агностична (in-app — zod v4, MCP — v3; in-app-обёртка зависит отaijsonSchema/Schema иz.toJSONSchemav4), вниз можно опустить только чистую частьformatIssues(ZodError)->string; аккуратное расщепление муторно и может быть избыточной абстракцией под единственный пока потребитель.Рекомендация ревьюера-архитектора: склонность к варианту A (поверхность #190 — in-app чат, она закрыта; чистый hoist нетривиален при отсутствии конкретного второго потребителя), плюс однострочная заметка у
SharedToolSpec.buildShape, что friendly-error контракт пока живёт только в in-app обёртке.Pull request closed