[feature][history][share] Версии страниц: ручной Save + idle-flush вместо эвристики, версии агента через save_page_version, share «только сохранённое», контракт с git-sync #370

Open
opened 2026-07-05 04:07:52 +03:00 by agent_vscode · 0 comments
Collaborator

Постановка

Сейчас история страницы (page_history) пишется шумной эвристикой: человек — слепок раз в 1–5 минут непрерывного редактирования, агент — слепок на каждую правку (delay=0). История захламлена, намеренные точки неотличимы от автосейвов, share всегда отдаёт живой черновик.

Цель — модель трёх уровней намеренности:

  • Уровень 0 — черновик/страховка: живой контент (pages+ydoc, hocuspocus 10s/45s — НЕ трогаем) + автослепки kind in ('idle','boundary'). Назначение: «вернуть случайно удалённую табличку».
  • Уровень 1 — версии: kind in ('manual','agent') — намеренные точки. Ручной Save (Cmd+S/кнопка) человека / явный инструмент save_page_version агента.
  • Уровень 2 — публикация: share в режиме approved отдаёт последний kind='manual', а не живой черновик: правишь сколько хочешь — снаружи видна только сохранённая версия.

Принятые продуктовые решения

Развилка Решение
Публикация в share «Save = публикация»: approved-share всегда отдаёт последний ручной Save человека. Cmd+S = зафиксировать и опубликовать одним действием. Агентские версии публикацию НЕ двигают.
Версия агента Только явный инструмент save_page_version (kind='agent'). Всё остальное от агента — автосейвы (idle/boundary). Ветка «ран = версия» отклонена: ран = один ход, длинный диалог вернул бы шум; к тому же ai_chat_runs не отслеживает правленные страницы.
UI истории Один список: версии крупно с бейджем, автосейвы приглушены, фильтр «только версии».
Git-sync (PR #359) Минимальная связка: git-sync остаётся зеркалом живого контента; правки ИЗ git получают source='git' + boundary-слепок перед перезаписью. Версии ≠ коммиты (иначе редизайн двунаправленного reconcile-цикла).

Дизайн (сверен с develop@e89ac627)

Данные

  • Миграция: page_history.kind nullable varchar — 'manual' | 'agent' | 'idle' | 'boundary'; legacy-строки null = автосейвы. saveHistory (page-history.repo.ts:86) принимает kind; kind в baseFields списка (page-history.repo.ts:39-53).
  • Миграция: shares.published_mode varchar ('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-слой)

  1. Ручной Save — единый stateless-путь для человека И агента. Прецедент: 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с).
  2. Idle-flush (trailing debounce): дедуп BullMQ по 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_* уходят.
  3. Boundary — обобщить до «любая смена источника»: вместо спецветки user→agent (persistence.extension.ts:329-362) — пин предыдущего состояния при любом переходе lastUpdatedSource (user↔agent↔git), kind='boundary', тот же гейт. Автоматически покрывает git-sync.
  4. Из computeHistoryJob уходит ветка агента delay=0 (persistence.extension.ts:105-119; тест compute-history-job.spec.ts:22 «agent delay MUST be 0» переписывается). Агент встаёт в общий idle-конвейер.
  5. Manual Save: снимает contributors из Redis (как HistoryProcessor.popContributors, SPOP) и отменяет pending idle-задачу страницы.
  6. Промоушен вместо дубля: если при Save контент идентичен последней строке истории и та — автосейв → апгрейд её kind→'manual' (контент-строки тяжёлые); если уже 'manual' → no-op + тост «уже сохранено».

Share approved (уровень 2)

  • ⚠️ Плумбинг: getShareForPage — ручной рекурсивный CTE (share.service.ts:289-370), перечисляющий колонки трижды (anchor, recursive, объект возврата) + allowlist share.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 наследуется автоматически.
  • Включение режима без ручных версий → автосоздание первой версии из текущего контента (empty-state).
  • MVP-ограничения: approved взаимоисключим с includeSubPages (гейт в updateShare/createShare + disable в share-modal.tsx); трансклюзии остаются живыми (by design); share-AI-ассистент (public-share-chat-tools.service.ts:109-122) читает живой контент — известная несогласованность MVP, задокументировать.
  • Алиасы /l/:alias наследуют режим автоматически (ре-резолвят share по странице) — ноль изменений.
  • UI 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 + catalogLineCORE_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 (каждый самостоятельно ценен)

  1. PR-1 «ядро»: миграция page_history.kind + триггеры (stateless save + hotkey/кнопка, idle trailing-debounce, обобщённый boundary, снятие agent delay=0) + метки/фильтр в UI истории.
  2. PR-2 «публикация»: shares.published_mode + ветка в резолве + share-модалка.
  3. PR-3 «агент»: save_page_version (реестр, оба транспорта, ack по version.saved) + промпты ролей.
  4. PR-4 «git»: actor='git' — контракт вносится в PR #359 (или follow-up); boundary-обобщение из PR-1 подхватит без доработок.

Edge cases / инварианты

  • Долговечность не меняется: hocuspocus-автосейв pages/ydoc остаётся, между слепками данные не теряются. Меняется только частота «точек восстановления».
  • Окно отката шире, чем при 1–5-минутной эвристике — осознанный trade-off, компенсируется idle-flush и boundary.
  • Manual Save при неизменном контенте → промоушен kind / no-op.
  • Гонка manual save ↔ активная idle-задача безвредна (общий гейт isDeepStrictEqual).
  • Retention-политики истории сейчас нет; если появится — kind in ('manual','agent') не подлежат чистке, автосейвы можно ротировать.
  • Approved-share без ручных версий невозможен (автосоздание при включении).
  • Legacy-строки (kind=null) в UI = автосейвы.
  • Contributors при редких слепках агрегируют всех правивших с прошлой версии — корректнее текущего поведения.
  • Права: явный save требует edit-права (readOnly-соединения отклоняются в onStateless, как в intentional-clear); у агента право несёт его токен.
# Постановка Сейчас история страницы (`page_history`) пишется шумной эвристикой: человек — слепок раз в 1–5 минут непрерывного редактирования, агент — **слепок на каждую правку** (`delay=0`). История захламлена, намеренные точки неотличимы от автосейвов, share всегда отдаёт живой черновик. Цель — модель **трёх уровней намеренности**: - **Уровень 0 — черновик/страховка**: живой контент (pages+ydoc, hocuspocus 10s/45s — НЕ трогаем) + автослепки `kind in ('idle','boundary')`. Назначение: «вернуть случайно удалённую табличку». - **Уровень 1 — версии**: `kind in ('manual','agent')` — намеренные точки. Ручной Save (Cmd+S/кнопка) человека / явный инструмент `save_page_version` агента. - **Уровень 2 — публикация**: share в режиме approved отдаёт последний `kind='manual'`, а не живой черновик: правишь сколько хочешь — снаружи видна только сохранённая версия. # Принятые продуктовые решения | Развилка | Решение | |---|---| | Публикация в share | **«Save = публикация»**: approved-share всегда отдаёт последний ручной Save человека. Cmd+S = зафиксировать и опубликовать одним действием. Агентские версии публикацию НЕ двигают. | | Версия агента | **Только явный инструмент** `save_page_version` (kind='agent'). Всё остальное от агента — автосейвы (idle/boundary). Ветка «ран = версия» отклонена: ран = один ход, длинный диалог вернул бы шум; к тому же `ai_chat_runs` не отслеживает правленные страницы. | | UI истории | **Один список**: версии крупно с бейджем, автосейвы приглушены, фильтр «только версии». | | Git-sync (PR #359) | **Минимальная связка**: git-sync остаётся зеркалом живого контента; правки ИЗ git получают `source='git'` + boundary-слепок перед перезаписью. Версии ≠ коммиты (иначе редизайн двунаправленного reconcile-цикла). | # Дизайн (сверен с `develop@e89ac627`) ## Данные - Миграция: `page_history.kind` nullable varchar — `'manual' | 'agent' | 'idle' | 'boundary'`; legacy-строки `null` = автосейвы. `saveHistory` (`page-history.repo.ts:86`) принимает `kind`; `kind` в `baseFields` списка (`page-history.repo.ts:39-53`). - Миграция: `shares.published_mode` varchar (`'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-слой) 1. **Ручной Save — единый stateless-путь для человека И агента.** Прецедент: `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с). 2. **Idle-flush (trailing debounce)**: дедуп BullMQ по `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_*` уходят. 3. **Boundary — обобщить до «любая смена источника»**: вместо спецветки user→agent (`persistence.extension.ts:329-362`) — пин предыдущего состояния при **любом переходе `lastUpdatedSource`** (user↔agent↔git), `kind='boundary'`, тот же гейт. Автоматически покрывает git-sync. 4. **Из `computeHistoryJob` уходит ветка агента `delay=0`** (`persistence.extension.ts:105-119`; тест `compute-history-job.spec.ts:22` «agent delay MUST be 0» переписывается). Агент встаёт в общий idle-конвейер. 5. Manual Save: снимает contributors из Redis (как `HistoryProcessor.popContributors`, SPOP) и отменяет pending idle-задачу страницы. 6. **Промоушен вместо дубля**: если при Save контент идентичен последней строке истории и та — автосейв → апгрейд её `kind`→'manual' (контент-строки тяжёлые); если уже 'manual' → no-op + тост «уже сохранено». ## Share approved (уровень 2) - ⚠️ Плумбинг: `getShareForPage` — ручной рекурсивный CTE (`share.service.ts:289-370`), перечисляющий колонки **трижды** (anchor, recursive, объект возврата) + allowlist `share.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 наследуется автоматически. - Включение режима без ручных версий → **автосоздание первой версии из текущего контента** (empty-state). - MVP-ограничения: `approved` взаимоисключим с `includeSubPages` (гейт в `updateShare`/`createShare` + disable в `share-modal.tsx`); трансклюзии остаются живыми (by design); share-AI-ассистент (`public-share-chat-tools.service.ts:109-122`) читает живой контент — известная несогласованность MVP, задокументировать. - Алиасы `/l/:alias` наследуют режим автоматически (ре-резолвят share по странице) — ноль изменений. - UI 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 (каждый самостоятельно ценен) 1. **PR-1 «ядро»**: миграция `page_history.kind` + триггеры (stateless save + hotkey/кнопка, idle trailing-debounce, обобщённый boundary, снятие agent delay=0) + метки/фильтр в UI истории. 2. **PR-2 «публикация»**: `shares.published_mode` + ветка в резолве + share-модалка. 3. **PR-3 «агент»**: `save_page_version` (реестр, оба транспорта, ack по `version.saved`) + промпты ролей. 4. **PR-4 «git»**: `actor='git'` — контракт вносится в PR #359 (или follow-up); boundary-обобщение из PR-1 подхватит без доработок. # Edge cases / инварианты - Долговечность не меняется: hocuspocus-автосейв `pages`/`ydoc` остаётся, между слепками данные не теряются. Меняется только частота «точек восстановления». - Окно отката шире, чем при 1–5-минутной эвристике — осознанный trade-off, компенсируется idle-flush и boundary. - Manual Save при неизменном контенте → промоушен kind / no-op. - Гонка manual save ↔ активная idle-задача безвредна (общий гейт `isDeepStrictEqual`). - Retention-политики истории сейчас нет; если появится — `kind in ('manual','agent')` не подлежат чистке, автосейвы можно ротировать. - Approved-share без ручных версий невозможен (автосоздание при включении). - Legacy-строки (`kind=null`) в UI = автосейвы. - Contributors при редких слепках агрегируют всех правивших с прошлой версии — корректнее текущего поведения. - Права: явный save требует edit-права (readOnly-соединения отклоняются в onStateless, как в intentional-clear); у агента право несёт его токен.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#370