Andrey Epifancev 23192c7a93 OpenSpec: решение R-1 (line_delta) в design.md (#140)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:16:08 +04:00

190 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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 изменит формат заголовков, «+/−»
исказятся на две строки у каждой версии; лечится правкой одной функции и пересчётом.