Reference in New Issue
Block a user
Delete Branch "fix/mainline-bugs-2026-06-26"
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?
Багфикс-батч (одна ветка, коммит-на-баг). Все проверки зелёные вместе: server+client tsc, editor-ext, server jest 589, + интеграционные тесты на реальном PG.
Закрывает
Частично (#206 — ред-тим отчёт)
Исправлено 4 подтверждённых бага: общие аттачи при дублировании страницы, ретрай транзиентного сбоя автосейва, cycle-guard на out-of-order move (drop поддерева), dedup коллизий unique-id. Остальные findings: footnote — не баг; markdown-export кастомных узлов / empty-guard / history-snapshot — фича/продуктовое/рефактор (отложены, см. отчёт в треде).
Коммиты (9, по багу)
161, 190, 207×2, 206×4, 159×1.
🤖 Generated with Claude Code
The server-side move cycle guard (getPageBreadCrumbs) and the move UPDATE ran as two separate, unlocked statements. Two concurrent moves ("A under B" and "B under A") could each read the same pre-write acyclic snapshot, both pass the guard, then persist A.parentPageId=B AND B.parentPageId=A — a parent/child cycle (TOCTOU, #207 #7). Run the cycle check and the UPDATE inside one transaction (executeTx) guarded by a per-space advisory lock (pg_advisory_xact_lock, held until COMMIT) so all moves within a space serialize: the second mover blocks until the first commits and then sees the freshly written parent, so its guard rejects the cycle. getPageBreadCrumbs gains an optional trx so the check runs on the locked snapshot. Adds an integration test driving two opposing concurrent movePage calls and asserting no cycle ever persists and exactly one move is rejected. Updates the movePage unit-test stubs for the new transactional path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ghost referenced this pull request2026-06-26 07:33:24 +03:00
Ghost referenced this pull request2026-06-26 15:55:38 +03:00
Code review — PR #212: батч багфиксов (#161 new-chat reset, #190 tool-error messages, #207 move-cycle TOCTOU/CTE, #159 share-AI token budget, #206 attachment-dup / id-dedupe / persistence / tree-race)
Вердикт: Request changes. Логика корректна и без security/regression-замечаний, но PR вводит новый operator-facing env-seam и закрывает пять багов без единой правки
CHANGELOG.md/.env.example(нарушение задокументированной конвенции проекта), а самая рискованная ветка фикса дедупликации id (NO_REASSIGNдляtransclusionSource) не покрыта ни одним тестом. Это must-fix перед merge.Объём: дифф
develop…fix/mainline-bugs-2026-06-26(merge-base3ddc329b), 27 файлов, +1918/−206. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.Must fix before merge
[documentation] Задокументировать новый env-var
SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAYв.env.example—apps/server/src/core/ai-chat/public-share-workspace-limiter.ts:343,.env.example:185-189Подтверждено: лимитер читает
process.env.SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY(default1_000_000, docstring называет это «overridable seam»), но PR не трогает.env.example, где соседние knob'ыSHARE_AI_WORKSPACE_MAX_PER_HOUR(стр. 185) иSHARE_AI_MAX_OUTPUT_TOKENS(стр. 189) задокументированы. Оператор не может найти центральную ручку фикса #159. Fix: добавить в блок anonymous-share-assistant рядом соSHARE_AI_WORKSPACE_MAX_PER_HOURзакомментированную запись# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000с комментарием — cluster-wide per-workspace rolling-day бюджет токенов (input+output), fail-closed при ошибке Redis, default 1 000 000.[conventions] Добавить записи
[Unreleased]вCHANGELOG.mdсо ссылками на #161 #190 #207 #159 #206 —CHANGELOG.md:11-12Подтверждено: PR не трогает
CHANGELOG.md, при том что проект ведёт секцию## [Unreleased]по Keep-a-Changelog с цитированием issue по мере мержа (уже перечислены #163, #146/#147; недавние PR #185/#186 правили этот файл). Пять пользовательских багфиксов и новый operator-facing default остаются недокументированными, и release-cut (AGENTS.md шаг 4) теряет per-issue контекст. Fix: добавить под## [Unreleased]### Fixed-буллеты для #161/#190/#207/#206 и отдельный### Security-буллет для per-workspace rolling-day token budget #159 (default 1M/day, env-overridable), в стиле окружающих записей.[test-coverage] Добавить тест на новую ветку
NO_REASSIGN(transclusionSource) в дедупликации id —packages/editor-ext/src/lib/unique-id/unique-id.util.ts:73-90Подтверждено: дублирующийся id на
transclusionSourceнамеренно НЕ переназначается (!NO_REASSIGN.has(node.type.name)), а отсутствующий — заполняется. Это самый рискованный путь фикса:transclusionSource— реальный сконфигурированный UniqueID-тип в серверном пути (collaboration.util.ts:72), его id — cross-reference key, и переназначение осиротит ссылки на transclusion (тихая потеря целостности данных). Новыйunique-id.util.test.tsконфигурирует только["heading","paragraph"]и не содержит ни одного упоминанияtransclusionSource(grep: 0) — регрессия, выкинувшаяNO_REASSIGN, прошла бы сьют. Fix: добавитьtransclusionSourceв конфигурируемые типы и кейсы: (a) два узла с общимtransclusionSourceid ОБА сохраняют его, (b) узелtransclusionSourceбез id получает новый.Non-blocking
[simplification] Упростить двойную защиту
totalUsageвonFinish—apps/server/src/core/ai-chat/public-share-chat.service.ts:273-275Подтверждено по коду: в AI SDK
onFinishвсегда получает объектtotalUsage(нулевыми могут быть только его поля), поэтомуconst u = totalUsage ?? ({} as typeof totalUsage)и последующийu?.…-optional-chaining мертвы. Не баг, соответствует defensive-стилю файла. Fix (опц.): убрать промежуточныйuи?-цепочку, оставив field-level?? 0(которые реальны):const tokens = totalUsage.totalTokens ?? (totalUsage.inputTokens ?? 0) + (totalUsage.outputTokens ?? 0);.[stability] Учесть, что
PAGE_UPDATEDвmovePageтеперь срабатывает до COMMIT —apps/server/src/core/page/services/page.service.ts:959-1001movePageтеперь оборачиваетupdatePageвexecuteTx, аupdatePages()эмититEventName.PAGE_UPDATEDсинхронно внутри транзакции (page.repo.ts:172); раньше шло на auto-committhis.db. При откате послеupdatePage, но до commit, слушатель уже поставит job на reindex несостоявшегося move. Влияние мало: cycle-rejection бросает доupdatePage(событие не эмитится), reindex-listener перечитывает БД (индексирует корректное un-moved состояние), аPAGE_MOVED-broadcast корректно гейтится на committednumUpdatedRows. Итог — редкий spurious reindex, не неверный broadcast. Fix (опц.): отложить эмитPAGE_UPDATEDдо резолваexecuteTxлибо эмитить только приnumUpdatedRows > 0.[test-coverage] Покрыть fallback извлечения токенов в
onFinish(provider безtotalTokens) —apps/server/src/core/ai-chat/public-share-chat.service.ts:266-276Замыкание считает spend как
u?.totalTokens ?? (u?.inputTokens ?? 0) + (u?.outputTokens ?? 0)и шлёт вrecordShareTokens, но спека не мокаетstreamText/ не запускает stream(), так что ветка суммы input+output не проверяется. Математика бюджета и проводкаrecordShareTokensпокрыты хорошо — тонкий пробел, не блокер. Fix: юнит-тест, вызывающий формуonFinishнапрямую сtotalUsageбезtotalTokens(ассерт суммы) и второй — сtotalTokens(ассерт приоритета).Architecture & design (forward-looking, non-blocking)
1. Дублирование двух per-workspace Redis sliding-window лимитеров (
public-share-workspace-limiter.ts:PublicShareWorkspaceLimiterrequest-count + новыйPublicShareWorkspaceTokenBudget). Структуры почти идентичны (sliding-window LOG над per-workspace sorted set, ZREMRANGEBYSCORE-trim, PEXPIRE, уникальный member<…>:<counter>:<random>, fail-closed log-and-deny, parallelresolveShareAi…env-reader +createPublicShare…factory + test-fake). Различия: request-лимитер делает check-AND-ZADD атомарно, бюджет делит read-only CHECK и отдельный RECORD ZADD и кодирует токены в leading integer member'а. Корректно и независимо протестировано сегодня; цена — drift-risk и двойная поддержка (есть реальный второй use-case).RedisSlidingWindowLogпримитив (trim + member-uniqueness + PEXPIRE + fail-closed) (effort: m). Pros: один владелец оконной семантики/TTL/fail-closed, будущие фиксы — в одном месте, общий fake-redis harness. Cons: Lua-скрипты различаются (atomic consume vs read-only check + record, count vs token-sum), общий core — в основном JS-обвязка, абстракция рискует выйти тонкой; обобщение Lua под оба режима усложнит более простой request-лимитер.uniqueMember(),resolveEnvPositiveInt(), fail-closed eval-wrapper, test-fake) (effort: s). Pros: убирает явный boilerplate почти без риска абстракции; каждый лимитер хранит свою целевую Lua и читается ясно. Cons: два Lua-скрипта и две оболочки классов остаются параллельными — концептуальное дублирование частично сохраняется.2.
placeByPosition's=== prevкак перегруженный sentinel + дублированный cycle-guard вtree-socket-reducers.applyMoveTreeNode(apps/client/src/features/page/tree). Cycle-guard написан дважды: вtreeModel.placeByPosition(возвратtree, когда destination parent — потомок source) и вapplyMoveTreeNode(if (newParentId && isDescendant(prev, id, newParentId)) return prev;). Причина —placeByPositionсигналит «отказ» возвратом той же ссылки (=== prev), но reducer УЖЕ трактуетplaced === prevкак «destination parent не загружен на этом клиенте» и делает fallbackremove(source)— именно та потеря поддерева, которую гард предотвращает.=== prevтеперь означает два разных исхода, неразличимых для caller'а. Корректно и протестировано; риск — будущий caller повторно введёт subtree-drop баг.{ tree, status: 'placed' | 'noop-parent-missing' | 'noop-cycle' }) (effort: m). Pros: убирает дублированныйisDescendantиз reducer'а, модель — единый источник «почему move отклонён», remove-fallback срабатывает только для реально незагруженного родителя, снимает comment-coupling. Cons: меняет сигнатуру и все call-site/тесты на=== prev; больший blast radius, чем сам баг; чуть больше церемонии на happy-path.treeModel.canReparent(tree, sourceId, parentId), который зовут и гард, и reducer (effort: s). Pros: один именованный предикат владеет cycle-правилом, минимум churn сигнатуры. Cons: reducer всё ещё принимает собственное pre-call решение — двусмысленность=== prev(parent-missing vs cycle) технически остаётся, дедуплицируется только правило, не sentinel.=== prevостаётся перегруженным; будущий caller может вернуть subtree-drop; правило живёт в двух местах, синхронизируемых вручную.onFinish always receives a totalUsage object, so the `?? {}` guard and optional chaining were dead. Extract the field-level extraction into a recordTurnUsage method (totalTokens, else input+output) and unit-test that recordShareTokens receives the summed value when totalTokens is absent and the authoritative total when present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ghost referenced this pull request2026-06-26 17:10:30 +03:00
Ghost referenced this pull request2026-06-26 17:10:30 +03:00
Ghost referenced this pull request2026-06-26 17:10:31 +03:00
Code review (re-review) — PR #212: батч багфиксов после правок
Вердикт: Approve. Все три прошлых блокера закрыты по существу (не косметически), а новый код дельты — рефактор учёта токенов в share-AI и тесты — корректен и покрыт; регрессий и новых блокеров нет.
Ре-ревью дельты
1d610b3a..df50f23d(12 файлов, +697/−42); в дельту влит и #182 (ревьюился отдельно, чисто). Аспекты: security, stability, conventions, documentation, regressions, test-coverage, simplification (параллельные ревьюеры + judge).Статус прошлых блокеров
.env.example— закрыт.SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000задокументирован с описанием rolling-day семантики, FAILS-CLOSED при недоступном Redis и дефолта (.env.example, блок на строках ~190–197).[Unreleased](#161 #190 #207 #159 #206) — закрыт. Все пять записей присутствуют и разнесены по верным секциям: #161/#190/#207/#206 вFixed, #159 вSecurity; формулировки содержательные, не заглушки.NO_REASSIGN(transclusionSource) — закрыт.unique-id.util.test.tsрегистрирует реальныйTransclusionSourceтип и прогоняет настоящийaddUniqueIdsToDoc: один тест проверяет, что коллизия id НЕ переназначается (оба узла сохраняют"src"), второй — что отсутствующий id всё равно заполняется. Совпадает с реализацией (NO_REASSIGN = new Set(["transclusionSource"]),needsNewId = currentId == null || (isDuplicate && !NO_REASSIGN.has(...))). Не поверхностно.Must fix before merge
Нет.
Non-blocking
Нет.
Test coverage
Покрыто. Единственная новая логика дельты — извлечение тела
onFinishв методrecordTurnUsage(public-share-chat.service.ts) — покрыта тремя кейсами вpublic-share-chat.spec.ts: fallback-сумма input+output при отсутствииtotalTokens, трактовка отсутствующих компонент как 0, приоритет авторитетногоtotalTokens. Удаление прежнего guard'аtotalUsage ?? {}безопасно: AI SDK типизируетtotalUsageвonFinishкак необязуемыйLanguageModelUsage, а внутренние поля метод гасит через?? 0. Слитый #182 (throttle/memo) интегрирован без следов конфликтов:messageSignatureимпортируется и используется вmemo(MessageItem, arePropsEqual),experimental_throttle: STREAM_THROTTLE_MSподключён с очисткой таймера — внутренности не переразбирались.Ghost referenced this pull request2026-06-28 03:43:29 +03:00