## Context Модуль `wiki` держит Markdown-копию дерева страниц проекта: каждая `wiki_sync` перекачивает всё дерево, `sync.commit` подменяет копию целиком. Истории нет: ни времени правки, ни автора, ни прошлых текстов (ARCHITECTURE.md, «Открытые вопросы»). Владелец 28.10.2026 хотел из чата узнать, что за день сделали аналитики, и упёрся в это (sdlc-hub#140). ### Что известно об API Eva Проверено 28.10.2026 на живой Eva (`https://skap.space.rshb.pro`) токеном eva-agent, скрипты `eva_probe.py` и `eva_probe2.py` (только чтение; отчёты — в общей папке `probe/`). Публичная спека EvaTeam API v1.9.22 (69 методов) полей не описывает, версий документов в ней нет. - `CmfDocument` с `fields: ["*"]` отдаёт ~120 полей, среди них `cmf_modified_at`, `cmf_modified_by_id`, `cmf_author_id`, `cmf_version`, `doc_version`, `text`, `text_draft`, `cur_workflow_version_id`, `cur_published_version_id`; поле `history` — список ссылок на версии `CmfDocumentHistory`. Ссылочные поля по имени (`cmf_modified_by`, `cmf_author`) или через `fields: ["**"]` раскрываются в объект с `name` и `login`. - `CmfDocumentHistory.list` с фильтром `[["parent_id", "==", ""]]` работает; фильтр `parent.code` падает (`-32001`, вложенный фильтр — как у `CmfDocument.parent`). Поля версии: `code` (`DOCH-…`), `cmf_created_at`, `cmf_modified_at`, `cmf_author_id`, `text` (полный HTML страницы на момент версии, у проверенных — 43–56 тыс. символов), `has_published`, `cur_workflow`, `doc_version`, `approved*`, `status_id`. - Пример DOC-101252: шесть версий; четыре — Дмитрий Соснов 27.10 (две из них опубликованы), две — Илья Молчанов в августе. Текущий черновик (`cur_workflow = true`, `has_published = false`) Eva дописывает на месте; создание новой версии меняет `cmf_modified_at` предыдущей. Отдельного поля «время публикации» в проверенных полях нет. - `CmfAudit` есть (`audit_data`, `html_diff_data`, `operate`…), но все поля токену агента закрыты (`deny`). - `CmfAttachment`: запись файла заменяется на месте (новая `cmf_version`, `cmf_modified_at`), истории версий файла нет; классов `CmfAttachmentHistory`, `CmfFileVersion` и подобных нет (`Method not found`). ### Решения владельца (brainstorming 28.10.2026) 1. Нужна лента правок за период и полный дифф каждой правки; сценарий — «что за сегодня сделали аналитики». 2. Только зона видимости проекта — страницы дерева корня, не вся вики. 3. Только опубликованные правки; черновики не берём. 4. Версии файлов не нужны: свежий файл по запросу, как сейчас. 5. Обновление отдельно от чтения: `sync` отвечает «ок» или ошибкой; `changes`, `diff`, `page` читают локальную копию. 6. Веб: общая «Лента» в боковой панели с переключателями источников (Jira, Вики); нажатие на правку — окно с диффом поверх ленты. ## Goals / Non-Goals **Goals:** - Опубликованные версии страниц дерева проекта — в хабе, с автором, временем и Markdown. - Лента правок за период и дифф правки — в HTTP API, MCP и вебе; без обращения к Eva при чтении. - Инкрементальная догрузка: Eva читается только по новым версиям. - Агент v1 и хаб нового кода работают вместе (история просто не пополняется). **Non-Goals:** см. proposal.md. ## Decisions 1. **Источник — `CmfDocumentHistory`, только `has_published = true`.** Черновик меняется на месте и не нужен по решению владельца 3. Опубликованная версия считается неизменной: агент не перечитывает известные коды. 2. **Постраничная выборка по дереву, а не запрос по проекту Eva.** Агент после обхода дерева для каждой страницы дерева вызывает `CmfDocumentHistory.list` с фильтром `parent_id` (id страницы из обхода) и полями без текста. Так Eva не отдаёт версии чужих страниц вообще (решение владельца 2), а страница, перенесённая в дерево позже, получает всю свою историю. Цена: один вызов Eva на страницу за синхронизацию (у AWPCA 28 страниц). 3. **Инкремент — по известным кодам.** Хаб при выдаче `wiki_sync` (`CommandHooks.deliver_params`) дописывает в параметры `known_versions: {<код страницы>: [<код версии>…]}` — коды версий, которые уже лежат в хабе у страниц текущего дерева проекта; в `command.params` в базе этого поля нет. Агент запрашивает текст (`CmfDocumentHistory.get` по коду) только для опубликованных версий, которых нет в `known_versions` его страницы. Первый прогон — `known_versions = {}`, то есть вся история. 4. **Отдельный маршрут для версий.** `POST /agent/wiki/versions {command_id, versions}` под токеном агента `wiki`; версия — `{page_code, code, created_at, author: {login, name}, html, attachments, links}` (`attachments`/`links` — как у страниц, по `html` этой версии); в пачке не больше 50 версий, срез по объёму (4 МБ) делает агент. Версии не вкладываются в страницы: у страницы с долгой историей тело пачки упёрлось бы в 16 МБ. Хаб переводит HTML в Markdown вне транзакции и кладёт в стадию версий команды (`wiki_version_stage`), как страницы. 5. **Отчёт.** `done` получает `result.versions` — число присланных версий. Поле есть — хаб применяет стадию версий; поля нет (агент v1) — история проекта не меняется, копия страниц обновляется как прежде. Присланных кодов меньше, чем `result.versions`, — команда `failed` «нет версий: пришло N из M». 6. **Хранение.** Таблица `wiki_version`: `label`, `page_code`, `code` (PK — `label, code`), `created_at` (UTC), `author_login`, `author_name`, `markdown`, `added`, `removed`. Markdown версии — тем же `convert`, но ссылки на вложения дают текст `📎 <имя>` без ссылки (у прошлых версий нет строк `wiki_attachment`, а новые заводить ради истории незачем); ссылки на страницы — `page:<код>`, как в копии. У проекта хранится `history_at` — время последнего применения истории (`null` — история не загружалась, агент v1); где именно — на исполнителя: колонка `wiki_project` через шаг миграции схемы или своя таблица модуля без миграции. 7. **Время и автор правки.** Время — `cmf_created_at` версии, автор — `cmf_author` версии. Отдельного времени публикации в API не нашлось; создание версии — начало правки. См. раздел «Decisions made without discussion». 8. **«+строк / −строк» — при записи.** `sync.commit` для каждой новой версии считает строки Markdown, добавленные и удалённые против предыдущей версии той же страницы (по `created_at`, затем `code`); у первой версии — против пустого текста. Число пишется в `added`/`removed`. Если новая версия встала между известными (позднее дошедшая история), пересчитывается и следующая за ней. 9. **Запись атомарно с копией.** Версии пишет только `sync.commit` в той же транзакции, что и копию: новые из стадии добавляются; версии страниц, которых нет в новом дереве, удаляются. Сброс корня (`projects._drop_copy`) удаляет все версии проекта. Отброшенный отчёт по сменённому корню версии не трогает. 10. **Сбой чтения версий — сбой команды.** Ошибка Eva на шаге версий даёт `failed`, копия и история прежние. Шаг версий проверен на живой Eva; отдельный режим «страницы без истории» усложнил бы отчёт ради редкого случая. 11. **Дифф считается по запросу.** `difflib.unified_diff` по строкам Markdown версии и предыдущей, контекст 3 строки, заголовки `--- <код пред.>` / `+++ <код>`; у первой версии — против пустого. Отдаётся частями по 20 000 символов с `offset`, как текст страницы. Готовый дифф не хранится: Markdown версий уже в базе. 12. **Чтение — только копия.** `GET /wiki/changes`, `…/versions`, `…/diff`, MCP `changes`/`diff`/`page` не ставят команд и не ходят к агенту; свежесть — `synced_at` в ответе. Новых команд и частоты синхронизации change не вводит. 13. **MCP `sync` — короткий ответ.** Без `wait` — «поставлено: пакет N» (или «уже идёт: пакет N»); с `wait` — «ок: синхронизировано ДД.ММ ЧЧ:ММ» или «ошибка: <текст>», или «не дождался за 300 с: пакет N». Сводки страниц и данных в ответе нет (решение владельца 5). 14. **Веб: лента — страница ядра с источниками от модулей.** Страница «Лента» (`/app/feed`) переезжает в оболочку; модули отдают ей источники через новое необязательное поле контракта `HubModule.feed`. Ядро ведёт период, переключатели источников и общий порядок групп; модуль — свои запросы, группы, параметры (виды событий Jira) и окно по нажатию. Ядро не знает терминов модулей (требование «Граница модулей в вебе»). 15. **Окно диффа — оверлей модуля wiki.** Как карточка задачи: адрес окна поверх фоновой страницы (`location.state.background`), закрытие возвращает ленту с тем же периодом. Внутри — переход к предыдущей и следующей правке страницы и ссылка «Открыть страницу». ## Risks / Trade-offs - **Время правки — начало, не публикация.** Правку, начатую вчера и опубликованную сегодня, лента покажет вчерашней. Смысл времени записан в спеке маршрута; если на живых данных лента разойдётся с тем, что видят в Eva, — отдельная доработка (поиск времени публикации в Eva). - **Опубликованная версия может меняться в Eva** (согласование, `approved_*`) — агент её не перечитает. Текст версии при согласовании, по проверенным данным, не меняется. - **Вызов Eva на страницу** — при сотнях страниц синхронизация удлиняется на сотни вызовов с паузой `wiki_pacing`. Сейчас деревья — десятки страниц. - **Объём базы.** Версия — до ~60 тыс. символов Markdown; 28 страниц × десятки версий — единицы-десятки МБ. Уборки старых версий нет. - **Агент v1 после выкатки хаба** — история пустая, лента вики пустая; MCP и веб показывают «история ещё не загружена», пока не придёт отчёт агента v2. ## Migration Plan 1. Хаб: `git pull` → kickstart (владелец) → `make web` → `/mcp` reconnect. Новые таблицы создаются при подключении (`CREATE TABLE IF NOT EXISTS` схемы модуля); прежний код с такой базой работает — таблицы он не читает. 2. eva-agent v2: `uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py`; агент обновится сам. 3. После `status` с v2 — владелец поднимает `wiki_min_agent_version` до 2; первая синхронизация v2 загружает всю историю. 4. Откат: прежний код хаба; таблицы версий остаются и не мешают. ## Open Questions - Время публикации версии: есть ли поле, отличное от `cmf_created_at` (решение 7). Проверить на живых данных после выкатки, если лента разойдётся с тем, что видят аналитики в Eva. ## Decisions made without discussion - 28.10.2026 — **постраничная выборка версий (решение 2)** вместо одного запроса по проекту Eva, о котором говорилось в brainstorming. Обоснование: строже держит зону видимости (решение владельца 2) и подхватывает историю перенесённых страниц. Цена ошибки: лишние вызовы Eva на больших деревьях; переход на запрос по проекту меняет только агента. - 28.10.2026 — **время правки = `cmf_created_at` версии (решение 7).** Обоснование: другого времени в API не найдено. Цена ошибки: правка, опубликованная позже начала, попадает в ленту не в тот день. - 28.10.2026 — **ссылки на вложения в версиях — текстом без ссылки (решение 6).** Обоснование: у прошлых версий нет строк вложений, дифф по тексту от этого не теряет. Цена ошибки: из диффа нельзя открыть файл; в копии страницы ссылки прежние. - 28.10.2026 — **сбой шага версий валит всю синхронизацию (решение 10).** Цена ошибки: сломанный в Eva метод истории остановит и обновление страниц до исправления агента. - 28.10.2026 — **дифф по запросу, не хранится (решение 11)**; «+/−» хранится. Цена ошибки: время ответа `diff` на больших страницах (difflib на ~1000 строк — миллисекунды). - 28.10.2026 — **короткий ответ MCP `sync` (решение 13)** — формулировки мои; смысл — решение владельца 5. - 28.10.2026 — **адрес общей ленты `/app/feed`**, `/app/jira/feed` перенаправляет на него; пункт «Лента» — сразу под «Обзором». Цена ошибки: закладки на `/app/jira/feed` работают через перенаправление. - 28.10.2026 — **ссылки «Лента» на «Обзоре»** (по вопросу ревьюера спеки): «Лента» уходит из группы «Jira» боковой панели (требование «Спринты и бэклог»); в сводке Jira на «Обзоре» ссылка «Лента» ведёт на `/app/feed?sources=jira`, в сводке вики добавлена ссылка «Правки» на `/app/feed?sources=wiki`. Цена ошибки: две ссылки на «Обзоре». - 28.10.2026 — **адрес окна правки** `/app/wiki/pages/{code}/versions/{version}` (по вопросу ревьюера спеки); без фоновой страницы в `location.state` — поверх «Вики» с этой страницей; «Предыдущая/Следующая правка» заменяют адрес (`replace`), чтобы «назад» закрывал окно. Цена ошибки: ссылка на правку открывается не поверх ленты. - 28.10.2026 — **фильтр по автору** в `GET /wiki/changes` и MCP `changes` — подстрока имени или логина без учёта регистра. Цена ошибки: «Иван» находит и «Иванову». - 28.10.2026 — **П-1: `history_at` — своя таблица `wiki_history(label, history_at)` без миграции.** Обоснование: схема `wiki_project` не меняется, миграция не нужна. Цена ошибки: ещё одна таблица на проект. - 28.10.2026 — **П-2: повтор версии.** В хабе версия с уже известным кодом не перезаписывается (`INSERT OR IGNORE`, решение 1 — опубликованная версия неизменна); в стадии повтор кода заменяет строку (`INSERT OR REPLACE`), N в «пришло N из M» — число уникальных кодов в стадии. Цена ошибки: исправленная Eva версия не попадёт в историю. - 28.10.2026 — **П-3: `result.versions` некорректен.** Не целое ≥ 0 (строка, `true`, −1) — команда `failed` «versions — неотрицательное целое»; присланных больше `M` — применяются все. Цена ошибки: агент с багом в счётчике уронит команду, а не только историю. - 28.10.2026 — **П-4: «+/−» считаются в `sync.prepare`** (вне транзакции), пишутся в `sync._commit`; число — строки `+`/`-` из `difflib.unified_diff` (те же, что в диффе правки). Цена ошибки: при смене алгоритма диффа старые «+/−» расходятся с новым диффом. - 28.10.2026 — **П-5: время версии.** Дробные секунды отбрасываются; равное время — порядок по коду строкой (решение спеки «затем по коду»). Цена ошибки: две правки в одну секунду идут в ленте по коду, а не по порядку правки. - 28.10.2026 — **П-6: фильтр автора в Python (`casefold`)** до среза `limit`. Цена ошибки: на больших лентах фильтр медленнее, чем в SQL. - 28.10.2026 — **П-7: `GET …/diff`.** `offset` не целое ≥ 0 — 422 `bad_request` «offset: целое ≥ 0»; `offset` за концом — `diff = ""`, `next_offset = null`. Цена ошибки: клиент, ждавший 404, получит пустой дифф. - 28.10.2026 — **П-8: MCP.** Шапка `diff`; строка `status` «история: » / «история не загружена»; `sync` при отменённом пакете — «ошибка: пакет N отменён»; время в «ок: синхронизировано …» — `synced_at` из `GET /wiki/status`; автор правки — имя, иначе логин, иначе «—» (и в вебе). Цена ошибки: формулировки MCP могут не совпасть с ожиданием владельца; смысл не меняется. - 28.10.2026 — **П-9: помощники времени MCP (`local_if_naive`, `since_of`) — в `core/mcp.py`**, сервер jira берёт их оттуда. Цена ошибки: ядро получает функции форматирования времени, общие для двух модулей. - 28.10.2026 — **П-10: веб — в шапке «Ленты» вторичные кнопки обновления модулей выбранных источников** («Обновить из Jira», «Обновить вики»). Цена ошибки: лишняя кнопка на одном из источников, если модуль не подключён. - 28.10.2026 — **П-11: веб — тексты сообщений.** `truncated` и ошибки источника — с префиксом «<Источник>: »; «выберите хотя бы один вид» и «история вики ещё не загружена…» — без префикса; «за период изменений нет» — когда ни один выбранный источник не грузится и не в ошибке, хотя бы один загружен без своего сообщения и групп нет; `sources=none` — явный пустой набор (как `kinds=none`). Цена ошибки: формулировки сообщений, не смысл. - 28.10.2026 — **П-12: веб — `eventTime` переезжает в `ui/format.ts`, период (`PERIODS`, `parsePeriod`, `periodRange`) — в `shell/feed/period.ts`;** прежнее перенаправление `/app/feed` → `/app/jira/feed` снимается (адрес теперь страница ядра). Цена ошибки: закладки на прежний адрес ленты перестают перенаправлять. - 28.10.2026 — **R-1: `history.line_delta` берёт `difflib.unified_diff(...)[2:]` вместо отбора строк по префиксам `+++`/`---` (решение контроллера при исполнении, Task 2).** Обоснование: заголовков всегда ровно две, они идут первыми; отбор по префиксам терял бы строки содержимого вида `++x` и `--x`. Цена ошибки: если difflib изменит формат заголовков, «+/−» исказятся на две строки у каждой версии; лечится правкой одной функции и пересчётом.