Andrey Epifancev a4c23f3f0b Архив OpenSpec: wiki-history (#140)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:17:42 +04:00

26 KiB
Raw Blame History

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", "==", "<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 «история: <history_at>» / «история не загружена»; 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 изменит формат заголовков, «+/−» исказятся на две строки у каждой версии; лечится правкой одной функции и пересчётом.
  • 28.10.2026 — R-2: повторное ревью исправления задачи 3 (поля at/author/offset в diff, code/title в versions) сделал контроллер по диффу (10 строк, тесты RED→GREEN), а не свежий ревьюер. Цена ошибки: пропущенная регрессия в двух ответах; финальное ревью её не нашло.
  • 28.10.2026 — I-1 (финальное ревью M-1, поднято до Important): «за период изменений нет» — если все выбранные источники загружены без ошибки, хотя бы у одного нет своего сообщения и групп нет (П-11). Обоснование: в окне выкатки (хаб новый, агент v1) у вики висит «история не загружена», и пустая Jira не сообщала о пустом периоде. Цена ошибки: два сообщения рядом.
  • 28.10.2026 — I-2 (финальное ревью T7.1, поднято до Important): строки диффа в окне правки — без зазора между строками. Обоснование: дифф — главное, ради чего фича; зазор 12 px делал его нечитаемым. Цена ошибки: нет.