[feature][history][share] Версии страниц: ручной Save + idle-flush вместо эвристики, версии агента через save_page_version, share «только сохранённое», контракт с git-sync #370
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?
Постановка
Сейчас история страницы (
page_history) пишется шумной эвристикой: человек — слепок раз в 1–5 минут непрерывного редактирования, агент — слепок на каждую правку (delay=0). История захламлена, намеренные точки неотличимы от автосейвов, share всегда отдаёт живой черновик.Цель — модель трёх уровней намеренности:
kind in ('idle','boundary'). Назначение: «вернуть случайно удалённую табличку».kind in ('manual','agent')— намеренные точки. Ручной Save (Cmd+S/кнопка) человека / явный инструментsave_page_versionагента.kind='manual', а не живой черновик: правишь сколько хочешь — снаружи видна только сохранённая версия.Принятые продуктовые решения
save_page_version(kind='agent'). Всё остальное от агента — автосейвы (idle/boundary). Ветка «ран = версия» отклонена: ран = один ход, длинный диалог вернул бы шум; к тому жеai_chat_runsне отслеживает правленные страницы.source='git'+ boundary-слепок перед перезаписью. Версии ≠ коммиты (иначе редизайн двунаправленного reconcile-цикла).Дизайн (сверен с
develop@e89ac627)Данные
page_history.kindnullable varchar —'manual' | 'agent' | 'idle' | 'boundary'; legacy-строкиnull= автосейвы.saveHistory(page-history.repo.ts:86) принимаетkind;kindвbaseFieldsсписка (page-history.repo.ts:39-53).shares.published_modevarchar ('live'default |'approved'). Без FK на конкретную версию — approved резолвит «последний kind='manual'» на чтении (findLatestByPageIdAndKind), никакой инвалидации.ProvenanceSource→'user'|'agent'|'git'(apps/server/src/core/auth/dto/jwt-payload.ts:7,resolveSourceвpersistence.extension.ts:82-87). Колонки varchar(20) — миграция значений не нужна.Триггеры слепков (collab-слой)
onStatelessуже обрабатываетintentional-clear(#251,persistence.extension.ts:471-489). Новая веткаsave-version: свежий ydoc из памяти collab-процесса → существующий путь стора →saveHistory(kind=...)→broadcastStateless({type:'version.saved', historyId, kind}). kind выводится сервером из подписанногоcontext.actor('user'→'manual', 'agent'→'agent') — тип версии неподделываем. REST-эндпоинт не нужен (был бы гоночным:pages.contentотстаёт до 10с).jobId— окно от ПЕРВОЙ правки, повторные правки задержку НЕ отодвигают → на каждом store: remove отложенной задачи + re-add сdelay=IDLE_INTERVAL. Прецедент remove-then-add:workspace.service.ts:613-615. Если job активен и remove не удался — просто добавить новую, гейтisDeepStrictEqual(history.processor.ts:67-70) защитит.kind='idle'. Константы:IDLE_INTERVAL_USER=60м,IDLE_INTERVAL_AGENT=15м;HISTORY_FAST_*уходят.persistence.extension.ts:329-362) — пин предыдущего состояния при любом переходеlastUpdatedSource(user↔agent↔git),kind='boundary', тот же гейт. Автоматически покрывает git-sync.computeHistoryJobуходит ветка агентаdelay=0(persistence.extension.ts:105-119; тестcompute-history-job.spec.ts:22«agent delay MUST be 0» переписывается). Агент встаёт в общий idle-конвейер.HistoryProcessor.popContributors, SPOP) и отменяет pending idle-задачу страницы.kind→'manual' (контент-строки тяжёлые); если уже 'manual' → no-op + тост «уже сохранено».Share approved (уровень 2)
getShareForPage— ручной рекурсивный CTE (share.service.ts:289-370), перечисляющий колонки трижды (anchor, recursive, объект возврата) + allowlistshare.repo.ts:25+ DTO.published_modeобязан пройти все четыре точки, иначе молча потеряется.resolveReadableSharePage(share.service.ts:159-189) подменять только контент/title:{...livePage, content: hist.content, title: hist.title}— id/workspaceId остаются живыми, иначеupdatePublicAttachmentsначеканит токены вложений не на того владельца. SEO-контроллер читаетresolved.page.title→ замороженный title наследуется автоматически.approvedвзаимоисключим сincludeSubPages(гейт вupdateShare/createShare+ disable вshare-modal.tsx); трансклюзии остаются живыми (by design); share-AI-ассистент (public-share-chat-tools.service.ts:109-122) читает живой контент — известная несогласованность MVP, задокументировать./l/:aliasнаследуют режим автоматически (ре-резолвят share по странице) — ноль изменений.Инструмент агента
save_page_versionРеализация тем же stateless-каналом: MCP-клиент уже умеет поднимать HocuspocusProvider (
packages/mcp/src/lib/collaboration.ts,withPageLock) — connect →sendStateless({type:'save-version'})→ дождатьсяversion.saved(ack, с таймаутом) → disconnect.Тройная проводка по реестру: спека в
SHARED_TOOL_SPECS(packages/mcp/src/tool-specs.ts, шаблон —restorePageVersion:377);registerShared+ строка вSERVER_INSTRUCTIONSвpackages/mcp/src/index.ts(тестserver-instructions.test.mjsзаставит);sharedTool(...)вai-chat-tools.service.ts(контрактshared-tool-specs.contract.spec.tsзаставит); метод вDocmostClientLike+client.ts(тестclient-host-contract.test.mjs). Тир: deferred +catalogLine(вCORE_TOOL_KEYSне добавлять). Промпты редакторских ролей: «закончил работу над страницей — вызови save_page_version», с бампом версий ролей. Пересборкаpackages/mcp/build/*(артефакты в git).UI истории
history-item.tsxуже различает агента (AgentAvatarStack, #300). Добавить: бейдж типа («Сохранено» / «Версия агента» / «Граница» / «Автосейв»), приглушение автосейвов, фильтр «только версии» вhistory-list.tsx, live-обновление панели поversion.saved. Рестор не трогаем (клиентский,use-history-restore.tsx).Хоткей и кнопка
mod+S(прецедент хоткеев:page-header-menu.tsx:78-97,mod+F) + кнопка в меню страницы.provider.sendStatelessна клиенте — прецедентintentional-clear.ts:90-92. Скрывать/дизейблить при readOnly.Нарезка на PR (каждый самостоятельно ценен)
page_history.kind+ триггеры (stateless save + hotkey/кнопка, idle trailing-debounce, обобщённый boundary, снятие agent delay=0) + метки/фильтр в UI истории.shares.published_mode+ ветка в резолве + share-модалка.save_page_version(реестр, оба транспорта, ack поversion.saved) + промпты ролей.actor='git'— контракт вносится в PR #359 (или follow-up); boundary-обобщение из PR-1 подхватит без доработок.Edge cases / инварианты
pages/ydocостаётся, между слепками данные не теряются. Меняется только частота «точек восстановления».isDeepStrictEqual).kind in ('manual','agent')не подлежат чистке, автосейвы можно ротировать.kind=null) в UI = автосейвы.