Слепки в историю по Save/по простою (вместо эвристики) + матчинг с агентами + share сохранённых версий #247
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Постановка
Как это устроено сейчас
Два независимых слоя: «живой контент» и «слепок»
Ключевой момент, на котором держится безопасность изменения — их нельзя путать:
pages.content(ProseMirror JSON) +pages.ydoc(бинарь Yjs). Пишется автосейвом Hocuspocus: вapps/server/src/collaboration/collaboration.gateway.ts:48-51заданdebounce: 10000/maxDebounce: 45000. Это слой долговечности и real-time-коллаборации — его трогать не нужно и опасно.page_history. Это «именованная точка восстановления». Именно правило её создания мы меняем.Текущая «эвристика» создания слепка
После каждого
onStoreDocumentставится отложенная задача BullMQPAGE_HISTORY, задержка считается чистой функциейcomputeHistoryJobвapps/server/src/collaboration/extensions/persistence.extension.ts:74-88:delay = возраст_страницы < 5 мин ? 1 мин : 5 мин,jobId = pageId. Константы —apps/server/src/collaboration/constants.ts(HISTORY_INTERVAL=5м,HISTORY_FAST_INTERVAL=1м,HISTORY_FAST_THRESHOLD=5м).delay = 0,jobId = "${pageId}-agent".За счёт дедупликации задач BullMQ по
jobIdэто даёт «временны́е корзины»: при непрерывном редактировании — примерно один слепок раз в 1–5 минут. Это и есть «эвристика»: история засоряется промежуточными автосейвами.Финальный гейт —
apps/server/src/collaboration/processors/history.processor.ts:67-70: задача перечитывает страницу и пишет слепок только если!isDeepStrictEqual(lastHistory.content, page.content). Сам insert —saveHistory()вapps/server/src/database/repos/page/page-history.repo.ts:67.Boundary-слепок (инвариант, который надо сохранить)
В
apps/server/src/collaboration/extensions/persistence.extension.ts:227-240: когда агент впервые пишет в страницу авторства человека, состоя��ие человека немедленно пиннится отдельным слепком (lastUpdatedSource='user') до перезаписи. Гарантия «всегда можно откатиться к тому, что написал человек, до первой правки агента». Симметричного перехода агент→человек сейчас нет.Атрибуция агента
Источник правки неподделываемый: подписанный claim
actorв collab-токене + флагusers.is_agent. Маркеры в БД:pages.last_updated_source('user'|'agent'),pages.last_updated_ai_chat_id; копируются вpage_historyприsaveHistory. Агентские правки (MCP) идут не через REST, а через тот же collab-слой (Yjs), см.mutatePageContentвpackages/mcp/src/lib/collaboration.ts:545. Понятия «сессии» агента нет — каждый стор атрибутируется отдельно; приdelay=0это ~слепок на каждое изменение контента → шумно.Клиент
Кнопки «Save» / «сохранить версию» нет — всё на автосейве. История только читается:
POST /pages/historyиPOST /pages/history/info(apps/server/src/core/page/page.controller.ts:501-536); список —apps/client/src/features/page-history/components/history-list.tsx.Share
shares(миграцияapps/server/src/database/migrations/20250408T191830-shares.ts) не ссылается ни на какую версию — толькоpage_id+ флаги (include_sub_pages,search_indexing). Весь публичный показ проходит через единственную точкуresolveReadableSharePage(apps/server/src/core/share/share.service.ts:159-189), которая берёт живой контент:pageRepo.findById(pageId, {includeContent:true})(apps/server/src/core/share/share.service.ts:176). ДальшеupdatePublicAttachments→prepareContentForShareработают надpage.content.page_historyв share-пути не используется нигде.Проектируемое изменение слепков
Принцип: меняем только триггер создания строки
page_history, слой живого контента (Hocuspocus debounce) не трогаем. Риск потери данных не растёт: текущее состояние всегда лежит вpages.Триггер A — явный Save (рекомендуется: через collab-stateless)
Hocuspocus живёт в отдельном процессе (
collab-main.ts) и держит свежий ydoc в памяти. Самый корректный путь (без гонок с 10-секундным debounce):Cmd/Ctrl+S(перехват браузерного Save) или кнопке в тулбаре шлётprovider.sendStateless({type:'save-version'}).onStatelessв расширении collab берёт свежайший ydoc из памяти (TiptapTransformer.fromYdoc(document,'default')), пере-использует существующую логику стора (запись вpages), затем пишет строкуpage_historyсkind='manual', и делаетdocument.broadcastStateless({type:'version.saved'})— у соавторов обновляется панель истории + тост.Прецедент для stateless уже есть (
document.broadcastStatelessвpersistence.extension.ts:264),sendStatelessесть на клиентском провайдере.Триггер B — idle-flush (по простою, напр. 1 час)
Нужен trailing-edge debounce: слепок через
IDLE_INTERVALпосле последней правки. Дедупликация BullMQ поjobIdтак не умеет (держит время первой правки). Решение — на каждомonStoreDocument:delay = IDLE_INTERVAL(напр. 1 ч).При активном редактировании задача всё время отодвигается (никаких слепков в середине сессии — ровно то, что просили), а через час тишины срабатывает один раз и фиксирует финал брошенной сессии. Обрабатывает её тот же
HistoryProcessorс тем же гейтомisDeepStrictEqual.Триггер C — boundary-слепки (сделать симметричными)
persistence.extension.ts:227-240).Новая колонка
page_history.kindДобавить nullable
kind enum('manual'|'idle'|'boundary'|'agent')(старые строки =null/legacy, обратная совместимость). Нужна для: (а) осмысленных меток в UI истории, (б) фичи «share сохранённых версий» — шарить только намеренные точки (manual/agent), а не idle-автосейвы.Дедупликация
Все пути (явный save, idle, boundary) проходят общий гейт
isDeepStrictEqual(lastHistory.content, page.content)— он не даст создать дубль. Уже есть в процессоре и boundary-логике; новые пути обязаны его использовать.Матчинг с агентами
Делаем модель агента симметричной человеческой — это и есть «сведение»:
Cmd/Ctrl+S/ кнопка → statelesssave_page_version(агент сам решает точки сохранения)IDLE_INTERVALтишины«Сессия агента» = непрерывный батч правок одного
aiChatId. Поскольку каждый MCP-инструмент — отдельный connect/mutate/disconnect, естественный терминатор сессии — idle-debounce (накопить правки, снять один слепок при затишье) плюс явныйsave_page_versionдля немедленной фиксации логического результата. Это закрывает «они хотят собственные сохранения» и «правки одной сессии надо сохранить»: вместо ~слепка-на-микроп��авку (текущееdelay=0) — один осмысленный слепок на сессию, тегkind='agent'+aiChatId, плюс boundary-слепок человеческого базиса до начала сессии.Из
computeHistoryJobуходит ветка «agent → delay 0», агент встаёт в общий конвейер триггеров.Доп. вариант: share только сохранённых версий — оценка сложности
Сложность: НИЗКАЯ–СРЕДНЯЯ. В share-пути ровно одна точка чтения контента (
resolveReadableSharePage), поэтому ядро фичи крошечное.Что нужно:
sharesnullableshared_history_id(FK→page_history.id).null= «живой» режим (дефолт, как сейчас), не-null = запиненная версия. Обратная совместимость полная.apps/client/src/features/share/components/share-modal.tsx— выбор «Делиться текущей версией» / «Делиться сохранённой версией» + пикер версий (реюз эндпоинта истории; ограничить выборkind in ('manual','agent')).resolveReadableSharePage— еслиshared_history_idзадан, брать контент/title/iconизpageHistoryRepo.findById(historyId, {includeContent:true})вместоpages. Весь downstream (prepareContentForShare: токены вложений, вырезание комментов/htmlEmbed) работает надpage.contentбез изменений. SEO-контроллер идёт через ту же точку → наследует автоматически.Ограничения MVP (это и есть «вся сложность»):
page_transclusions. В запиненной версии останутся «живыми» (мелкая несогласованность) — для MVP допустимо, задокументировать; «трансклюзии как на момент версии» — отдельная тяжёлая задача.Фича естественно ложится на основную: как только появляются именованные «сохранённые версии», «поделиться версией» = сослаться на одну из них.
Edge cases, инварианты, риски
pages/ydocостаётся → между слепками данные не теряются. Меняется только частота «точек восстановления».isDeepStrictEqualзащитит от дубля.historyQueue; idle-задачу обрабатывает существующийHistoryProcessor. Новых очередей не нужно.page_historyстроки живут как есть;kind=null= legacy.Объём работ (точечно, по файлам)
apps/server/src/collaboration/constants.ts— добавитьIDLE_INTERVAL(+ опц. агентский), вывести из обихода*_FAST_*.apps/server/src/collaboration/extensions/persistence.extension.ts— заменитьcomputeHistoryJobна idle-debounce (remove+re-add), добавитьonStateless('save-version'), добавить agent→user boundary.apps/server/src/collaboration/processors/history.processor.ts— проставлятьkindдля idle/manual/boundary; гейт оставить.apps/server/src/database/repos/page/page-history.repo.ts—saveHistoryпринимаетkind; новая миграция+kind.apps/server/src/core/page/page.controller.ts— (если REST-вариант save)POST /pages/history/save.Cmd/Ctrl+S+sendStateless; меткиkindи обновление панели поversion.savedвapps/client/src/features/page-history/components/history-list.tsx.save_page_versionвpackages/mcp.+shared_history_idвshares, ветка вapps/server/src/core/share/share.service.ts, поле вapps/server/src/core/share/dto/share.dto.ts, пикер вapps/client/src/features/share/components/share-modal.tsx.Открытые продуктовые развилки (с рекомендациями)
save_page_version+ agent→user boundary (группировки по соединению нет).kind in (manual, agent); поддерево и версионные трансклюзии — отдельные тяжёлые итерации.