From 4848dff2062506b4c18164a4ae19c5370c753c58 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:31:55 +0400 Subject: [PATCH] =?UTF-8?q?OpenSpec:=20change=20wiki-package-d=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=20D=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=20wiki=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/wiki-package-d/.openspec.yaml | 2 + openspec/changes/wiki-package-d/design.md | 126 ++++++++++++ openspec/changes/wiki-package-d/proposal.md | 47 +++++ .../changes/wiki-package-d/specs/mcp/spec.md | 48 +++++ .../wiki-package-d/specs/web-app/spec.md | 57 ++++++ .../changes/wiki-package-d/specs/wiki/spec.md | 183 ++++++++++++++++++ 6 files changed, 463 insertions(+) create mode 100644 openspec/changes/wiki-package-d/.openspec.yaml create mode 100644 openspec/changes/wiki-package-d/design.md create mode 100644 openspec/changes/wiki-package-d/proposal.md create mode 100644 openspec/changes/wiki-package-d/specs/mcp/spec.md create mode 100644 openspec/changes/wiki-package-d/specs/web-app/spec.md create mode 100644 openspec/changes/wiki-package-d/specs/wiki/spec.md diff --git a/openspec/changes/wiki-package-d/.openspec.yaml b/openspec/changes/wiki-package-d/.openspec.yaml new file mode 100644 index 0000000..97e3642 --- /dev/null +++ b/openspec/changes/wiki-package-d/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-27 diff --git a/openspec/changes/wiki-package-d/design.md b/openspec/changes/wiki-package-d/design.md new file mode 100644 index 0000000..172308b --- /dev/null +++ b/openspec/changes/wiki-package-d/design.md @@ -0,0 +1,126 @@ +## Context + +Пакет D сверки follow-ups 25.10.2026 (sdlc-hub#135). Маркеры #94 T6.1–T6.5, T8.2, T8.3, T9.3 сверены с кодом +27.10.2026 и воспроизведены на `convert.to_markdown`: + +| Маркер | Вход | Сейчас | +|---|---|---| +| T6.1 | ` текст` | `текст` — ссылки на вложение нет, строка вложения есть | +| T6.2 | `` | `[\[📎 x.png](attachment:@0)](page:DOC-1)` — ссылка в ссылке | +| T6.3 | два `attachments` с одним `ref` | побеждает последний (`dict` по `ref`) | +| T6.4 | `док` | `[док](http://ex.com/a b(c).html)` — не ссылка | +| T6.5 | `путь C:\` | `[путь C:\](page:DOC-1)` — `\]` экранирует скобку | + +T8.2: `request_fetch` отвечает `ready` по полю `state`, а `GET …/content` при пропавшем файле — 409 «запросите +скачивание»: тупик. T8.3: `POST /wiki/sync` проверяет `active_sync`, а `POST /batches` и `POST /commands/{id}/retry` +проходят только `commands.check_batch`, где этой проверки нет. T9.3: `mcp.py` склеивает шапку `page`, строки `tree`, +`search` и кандидатов из значений Eva без нормализации переводов строк. + +Владелец 27.10.2026 согласился с предложенным поведением по всем восьми маркерам и добавил картинки в тексте страницы +«Вики»: показ только после нажатия, уже скачанные — сразу. + +## Decisions + +1. **T6.1 — предок, а не родитель.** `convert_img` ищет ближайшего предка `` (`el.find_parent("a")`), а не только + `el.parent`. Если `href` этого `` — вложение и картинка — то же вложение (совпадает `url` записей) или вообще не + вложение, картинка не выводится: ссылку выведет ``. Картинка — другое вложение (миниатюра файла Y внутри ссылки + на файл X) выводит свою ссылку, а `convert_a` для вложения X сохраняет маркеры вложений из `text` перед своей + ссылкой (то же правило, что в решении 2): Y не теряется. +2. **T6.2 — вложение перед ссылкой на страницу.** В `convert_a` для ссылки на документ (`code` найден): если в `text` + (уже сконвертированное содержимое) есть маркеры вложений (`_MARK`), они выносятся перед ссылкой: результат — + `<ссылки вложений> [<остаток текста или код>](page:<код>)`. Остаток — `text` без фрагментов `[📎 …<маркер>`; + пустой после `strip()` — код. Через `_label` как сейчас. Внешняя ссылка (`super().convert_a`) с картинкой-вложением + внутри — то же правило: вложение перед ссылкой, текст ссылки — остаток, пустой — адрес. Ссылка на вложение внутри + ссылки на вложение уже покрыта решением 1. +3. **T6.3 — первая запись.** `_by_ref` и `_links` собираются с `setdefault`: первая запись по `ref` побеждает (порядок + агента — порядок документа). +4. **T6.4 — кодирование адреса.** Для внешних ссылок и картинок (ветка `super().convert_a`/`convert_img`) адрес + проходит `_href(url)`: пробел → `%20`, `(` → `%28`, `)` → `%29`; прочие символы не трогаются (уже закодированное не + кодируется повторно). Ссылки `page:`/`attachment:` не затрагиваются — в них таких символов нет. +5. **T6.5 — хвостовая черта.** `_label` после текущих замен удваивает нечётную серию `\` в конце строки (одна `\` + в конце → `\\`). То же в тексте внешней ссылки, если markdownify не экранировал её сам; проверять тестом обе ветки. +6. **T8.2 — проверка файла в `request_fetch`.** `request_fetch(conn, attachment_id, author, files_dir)`: при `state = + ready` и `files.size(files_dir, row["file"]) is None` вложение идёт по ветке «поставить скачивание» (state + `fetching`, новый пакет). Маршрут `h_attachment_fetch` передаёт `hub.files_dir`; MCP `attachment` ходит через этот + же маршрут и поведение получает без правок. `GET /wiki/pages/{code}` и `GET /wiki/attachments/{id}` по-прежнему + отдают `state` из базы (проверка файла на каждом чтении — лишние обращения к диску). +7. **T8.3 — проверка в `check_batch`.** Для каждой `wiki_sync` в пакете: если у проекта есть команда `wiki_sync` в + `queued`/`claimed` с тем же `params.root`, — `CommandError` «у проекта <метка> уже идёт синхронизация корня + (пакет #N)» → 422 `bad_batch`. Тот же `root`, а не любой: `projects.set_root` ставит синхронизацию нового корня, + пока команда старого может быть `claimed` (её отчёт будет отброшен по «корень сменился»). `POST /wiki/sync` + по-прежнему проверяет `active_sync` раньше и отвечает 409; фоновая работа — тоже раньше (`tick.py`). + Возврат корня (A → B → A, пока `wiki_sync {root: A}` ещё `claimed`): `projects.on_updated` отменяет только + `queued`, и новая синхронизация A упёрлась бы в проверку — PATCH получил бы 422. Поэтому `commands.enqueue_sync` + перед постановкой ищет активную `wiki_sync` проекта с тем же `root` и, если она есть, возвращает её пакет без + нового: её отчёт не будет отброшен (корень снова совпадает). Ответ — в форме `create_batch`: `{id, + estimate_seconds, count}` из строки пакета и числа его команд; для вызывающих (`on_created`, `on_updated` → ответ + PATCH, `POST /wiki/sync`, `tick`) это «пакет синхронизации проекта», новый он или идущий — не различается. Проверка + в `check_batch` остаётся для `POST /batches` и повтора команды; `enqueue_sync` с поиском и `check_batch` + реализуются одной задачей, иначе `enqueue_sync` между шагами упирается в новую проверку. +8. **T9.3 — одна функция строки.** В `modules/wiki/mcp.py` — `_line(v)`: `core.value(v)`, затем `re.sub(r"\s*[\r\n]+\s*", + " ", …)`. Применяется ко всем значениям из Eva внутри строк ответа: `tree` (название), `page` (название, путь, имена + вложений, состояние), кандидаты `_candidates` (название и сам `title` запроса), `search` (название, путь, фрагмент), + `attachment` (имя, код страницы), `_summary`/`status` (название корня, ошибка синхронизации). Тело страницы — как + есть. +9. **Признак картинки — от хаба по адресу.** Имя вложения для этого не годится: проверка живого AWPCA 27.10.2026 + показала имена без расширения («Схема расчёта суммы элиминации по тройке» — файл `vgo-matching-1.png`; 14 вложений + с именем `attachment`) — eva-agent берёт `alt`/`title`/текст ссылки раньше имени файла. Хаб добавляет в + `attachments` ответа `GET /wiki/pages/{code}` поле `image`: последний сегмент пути `url` (без запроса, + `unquote`) оканчивается на `.png .jpg .jpeg .gif .webp .bmp` без учёта регистра. Функция — в + `modules/wiki/attachments.py`, схема базы не меняется. SVG исключён: файл из Eva недоверенный, а пользы в вики от + него мало. +10. **Картинки — устройство во фронте.** + - `useWikiDownload.start(id, mode)` получает режим: `"save"` (как сейчас) или `"show"`. В режиме `show` по `ready` + содержимое (`api.blob`) не сохраняется, а передаётся в хранилище картинок страницы. + - Хранилище картинок страницы (новый хук рядом, например `useWikiImages(project, code, attachments)`): `id → + objectURL`. При загрузке страницы для каждого вложения-картинки в `ready` один раз запрашивает `…/content` и + кладёт objectURL; ошибка — молча, остаётся ссылка. При смене страницы/проекта и размонтировании — `revokeObjectURL` + всех адресов и отмена поздних ответов (поколение, как в `useWikiDownload`). + - Blob заворачивается с типом по расширению (`image/png` и т.д.): хаб отдаёт содержимое как + `application/octet-stream`. + - Один хук на страницу: хранилище картинок живёт внутри `useWikiDownload` (или в хуке, который его оборачивает), + режим `show` кладёт objectURL туда же. Ссылка на объект, от которого зависит `components` в `PageView`, остаётся + стабильной (как сейчас — см. комментарий в `download.ts`): карту картинок `AttachmentLink` читает через + `status(id)`-подобный геттер, меняющийся вместе с картой, как сейчас `status`, а не через пересоздание + `components`. Перемонтирование вложений при появлении картинки не должно сбрасывать `busy`/`error` других. + - Источник картинки — либо автозапрос `content` при загрузке страницы для `ready`-картинок, либо результат + `start(id, "show")`. Автозапрос — один раз на вложение за жизнь страницы; его неудача (409, сеть) оставляет + ссылку в режиме `show` без текста ошибки. Нажатие на неё — `start(id, "show")`: после T8.2 хаб при пропавшем файле + скачивает заново, по `ready` картинка кладётся в хранилище из ответа `start`. Вложение в `failed` — та же ссылка + с текстом ошибки, нажатие — повтор `start(id, "show")`. + - `AttachmentLink`: картинка с objectURL — `{name}/` и под ней + прежняя кнопка-ссылка в режиме `save`; картинка без objectURL — кнопка в режиме `show`; не картинка — как сейчас. + - CSS: `.wiki-att-img img { max-width: 100%; height: auto; display: block }`, токены AURA. + - Правило «внешние картинки не загружаются» (`img` в `components`) не меняется: картинки — только вложения хаба. + +## Risks / Trade-offs + +- **Картинки в памяти.** objectURL держат blob до смены страницы; предел файла — 10 МБ, на странице AWPCA — единицы + картинок. Приемлемо. +- **Состояние вложения после скачивания.** Ответ `GET /wiki/pages/{code}` не перечитывается после скачивания: + картинка показывается из хранилища страницы, при повторном открытии — по `ready` из нового ответа. +- **Корень сменился во время повтора.** Повтор команды старого корня при идущей синхронизации нового проходит (корни + разные); его `done` будет отброшен по «корень сменился». Лишний запрос в Eva, данных не портит. + +## Decisions made without discussion + +- 2026-10-27 — T6.2: вложение выводится перед ссылкой на страницу, пустой текст ссылки — код страницы. Обоснование: + Markdown не допускает ссылку в ссылке; порядок совпадает с порядком в HTML. Цена ошибки — вид одной строки текста. +- 2026-10-27 — T6.3: при повторе `ref` побеждает первая запись. Обоснование: порядок агента — порядок документа, + первое появление определяет имя. Цена ошибки — другое имя вложения в редком случае. +- 2026-10-27 — T6.4: кодируются только пробел и круглые скобки. Обоснование: только они ломают разбор + `[текст](адрес)`; прочее браузер разбирает сам. Цена ошибки — редкая внешняя ссылка не кликается. +- 2026-10-27 — T8.2: проверка файла — только в `request_fetch`, не при чтении состояния. Обоснование: тупик был в + повторном запросе, не в показе. Цена ошибки — до нажатия вложение выглядит скачанным. +- 2026-10-27 — T8.3: отказ только при активной синхронизации того же корня. Обоснование: смена корня ставит + синхронизацию нового корня при выданной старой (спека «Синхронизация вики»). Цена ошибки — один лишний запрос + дерева в Eva. +- 2026-10-27 — T9.3: все значения Eva в строках ответа MCP — через одну функцию `_line`, тело страницы как есть. + Цена ошибки — длинное значение в одну строку. +- 2026-10-27 — картинки: признак `image` считает хаб по имени файла в адресе Eva (не по имени вложения), SVG не + показывается; под картинкой остаётся ссылка сохранения файла. Обоснование: на живом AWPCA имена картинок бывают + без расширения; SVG — недоверенный активный формат. Цена ошибки — картинка с необычным расширением остаётся ссылкой. +- 2026-10-27 — T8.3: при возврате корня `enqueue_sync` отдаёт идущий пакет того же корня в форме `create_batch`, не + различая «новый/идущий». Цена ошибки — ответ PATCH называет уже идущий пакет. +- 2026-10-27 — T6.1: картинка-другое-вложение внутри ссылки на вложение выводится своей ссылкой перед ссылкой + внешнего вложения. Цена ошибки — порядок двух ссылок в строке. diff --git a/openspec/changes/wiki-package-d/proposal.md b/openspec/changes/wiki-package-d/proposal.md new file mode 100644 index 0000000..20ccb9b --- /dev/null +++ b/openspec/changes/wiki-package-d/proposal.md @@ -0,0 +1,47 @@ +## Why + +Пакет D сверки follow-ups 25.10.2026 (sdlc-hub#135, маркеры из #94). Сверка с кодом 27.10.2026 подтвердила дефекты модуля +`wiki`. Самый заметный — T6.1: если в HTML Eva между ссылкой на файл и его миниатюрой стоит обёртка (``), +ссылка на вложение пропадает из текста страницы целиком, хотя вложение в базе есть. Остальные дефекты: сломанный +Markdown на нестандартных ссылках, вложение «скачано», которое нельзя ни открыть, ни скачать заново, вторая живая +синхронизация через повтор команды и подделка строк ответа MCP переводами строк из данных Eva. + +Владелец 27.10.2026 добавил в пакет показ скачанных картинок в тексте страницы «Вики»: сейчас картинку можно только +сохранить файлом. + +## What Changes + +- **Перевод HTML** (T6.1–T6.5): миниатюра внутри ссылки на вложение распознаётся на любой глубине вложенности. Вложение + внутри ссылки на страницу выводится отдельной ссылкой рядом со ссылкой на страницу, без вложенных ссылок. При + повторе `ref` в списке агента побеждает первая запись. Пробелы и круглые скобки во внешних адресах кодируются. + Хвостовая `\` в тексте ссылки на страницу экранируется. +- **Вложение без файла** (T8.2): `POST /wiki/attachments/{id}/fetch` для вложения в `ready`, чей файл пропал из + `files/`, ставит скачивание заново, а не отвечает `ready`. +- **Одна синхронизация на корень** (T8.3): пакет с `wiki_sync` (в том числе повтор команды) отклоняется, если у проекта + уже идёт `wiki_sync` с тем же корнем. +- **MCP вики** (T9.3): переводы строк в названии, пути, имени вложения и фрагменте поиска заменяются пробелом. +- **Картинки в тексте** (решение владельца): на странице «Вики» вложение-картинка после скачивания показывается в + тексте на месте ссылки; уже скачанные картинки показываются при открытии страницы. Без клика хаб ничего не скачивает. + Картинку определяет хаб по имени файла в адресе Eva: поле `image` у вложений в `GET /wiki/pages/{code}`. + +## Non-Goals + +- Остальные маркеры #94 (M-7, T7.x, T8.4–T8.9, T9.1–T9.7 кроме T9.3 и др.) — остаются в #94. +- eva-agent#4 Н-1 (битый `agent.json`) — отдельный bounded PR в eva-agent. +- Автоматическое скачивание картинок при открытии страницы — владелец выбрал «только после нажатия». +- Картинки в Markdown-копии на диске и в MCP: копия и ответы MCP не меняются, картинки — только в веб-приложении. +- Показ SVG в тексте. + +## Capabilities + +### Modified Capabilities + +- `wiki` — «Синхронизация вики», «Markdown-копия вики», «Вложения вики по запросу», «Маршруты вики». +- `mcp` — «MCP вики». +- `web-app` — «Вики проекта». + +## Impact + +- Хаб: `modules/wiki/convert.py`, `attachments.py`, `api.py`, `commands.py`, `mcp.py`; тесты `tests/test_wiki_*`. +- Веб: `web/src/modules/wiki/` (`PageView.tsx`, `download.ts`, `wiki.css`) и тесты рядом. +- Схема базы и контракт с агентом `wiki` не меняются; в ответе `GET /wiki/pages/{code}` у вложений новое поле `image`. Выкатка: `make web` + перезапуск хаба. diff --git a/openspec/changes/wiki-package-d/specs/mcp/spec.md b/openspec/changes/wiki-package-d/specs/mcp/spec.md new file mode 100644 index 0000000..7bea69e --- /dev/null +++ b/openspec/changes/wiki-package-d/specs/mcp/spec.md @@ -0,0 +1,48 @@ +## MODIFIED Requirements + +### Requirement: MCP вики +`sdlc-hub mcp wiki [--project <метка>]` SHALL запускать по stdio MCP-сервер модуля `wiki` с именем `sdlc-wiki`, +ходящий в HTTP API хаба клиентским токеном; хаб недоступен — каждый инструмент отвечает «хаб не запущен: sdlc-hub serve». +Привязка к проекту проверяется так же, как у сервера jira («Привязка сессии к проекту»). Инструменты: + +| Инструмент | Параметры | Ответ | +|---|---|---| +| `projects` | — | таблица: метка, название, корень вики, страниц, последняя синхронизация; текущий проект отмечен | +| `project_create` | `label`, `name`, `wiki_root` (необязательно) | `POST /projects` с автором `claude`; проект и фрагмент `.mcp.json` с записью `sdlc-wiki` | +| `root_set` | `root` | `PATCH /projects/<текущий> {wiki_root}`; новый корень и id пакета синхронизации | +| `tree` | `code` (необязательно), `depth` (по умолчанию 2) | дерево от корня или от `code`: отступ, код, название, размер | +| `page` | `code` или `title`, `offset` (по умолчанию 0) | шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с `id`) и Markdown с `offset` до 20 000 символов, в конце — «продолжение: offset=», если текст длиннее; `title` ищется точно, иначе — до 10 кандидатов с кодами | +| `search` | `query`, `limit` (по умолчанию 10) | страницы: код, название, путь, фрагмент | +| `attachment` | `id` | ставит скачивание, ждёт до 60 с; готово — путь к копии `downloads/wiki-/<имя>`; иначе состояние и подсказка повторить | +| `sync` | `wait` (по умолчанию `false`) | ставит синхронизацию (или сообщает о текущей) и id пакета; с `wait` — ждёт до 300 с и отдаёт итог | +| `status` | — | корень, последняя синхронизация, ошибка, пакет в работе, агент `wiki`: онлайн/офлайн, версия, публикация, обновление, запускатель (как `agent_status` сервера jira) | + +Инструменты, кроме `projects` и `project_create`, SHALL требовать привязки к проекту. В привязанной сессии без корня +(кроме `root_set`, `status`) ответ SHALL быть «у проекта <метка> нет корня вики: root_set DOC-…»; с корнем, но без +синхронизаций — «вики проекта ещё не синхронизирована: sync». Набор инструментов и их описания у сервера `sdlc-jira` +(модуль jira) SHALL остаться прежним. + +Значения из Eva, которые инструменты выводят внутри строки ответа (названия страниц и корня, путь, имя вложения, +фрагмент поиска, кандидаты `page` по названию), SHALL выводиться одной строкой: переводы строк (`\r`, `\n`) и пробелы +вокруг них заменяются одним пробелом. Тело страницы (Markdown после шапки `page`) выводится как есть. + +#### Scenario: Поиск из сессии awp-ca +- **WHEN** сервер запущен с `--project AWPCA`, копия синхронизирована, вызван `search {query: "статус согласован"}` +- **THEN** ответ — страницы с кодами и фрагментами; запрос в хаб — `GET /wiki/search?project=AWPCA&q=…` + +#### Scenario: Длинная страница +- **WHEN** `page {code: DOC-3}`, Markdown 45 000 символов +- **THEN** первые 20 000 символов и «продолжение: offset=20000»; `page {code: DOC-3, offset: 40000}` — остаток без строки + продолжения + +#### Scenario: Корня нет +- **WHEN** сессия `sfm_kedr` без корня, вызван `tree` +- **THEN** ответ «у проекта sfm_kedr нет корня вики: root_set DOC-…», запроса `GET /wiki/tree` нет + +#### Scenario: Сервер jira не изменился +- **WHEN** запущен `sdlc-hub mcp jira --project AWPCA` +- **THEN** имя сервера `sdlc-jira`, список инструментов и описания совпадают с эталоном до change + +#### Scenario: Перевод строки в названии страницы +- **WHEN** у DOC-3 название «Статусы\nпуть: поддельный», вызван `page {code: DOC-3}` +- **THEN** первая строка ответа — «DOC-3 — Статусы путь: поддельный», строка «путь:» в шапке одна — настоящая diff --git a/openspec/changes/wiki-package-d/specs/web-app/spec.md b/openspec/changes/wiki-package-d/specs/web-app/spec.md new file mode 100644 index 0000000..26160c1 --- /dev/null +++ b/openspec/changes/wiki-package-d/specs/web-app/spec.md @@ -0,0 +1,57 @@ +## MODIFIED Requirements + +### Requirement: Вики проекта +Приложение SHALL иметь страницу «Вики» (`/app/wiki?project=<метка>`, раздел группы «Вики» боковой панели; текущий +проект — как у остальных страниц, «Проект в приложении»). Шапка страницы: заголовок «Вики», подзаголовок «<название +корня> · страниц · обновлено ДД.ММ ЧЧ:ММ» и действие «Обновить вики» (вторичная кнопка; требование «Состояние и +обновление модулей»). Страница SHALL показывать: +- дерево страниц проекта (`GET /wiki/tree`) слева, свёрнутое до первого уровня; узел раскрывается по клику; узел + выбранной страницы и его предки раскрыты; +- выбранную страницу справа (`GET /wiki/pages/{code}`): заголовок, дата синхронизации, ссылка «открыть в Eva», + Markdown с таблицами, без сырого HTML; без `page` в адресе — корневая страница; ссылки `page:<код>` открывают страницу + вики (код в адресе `&page=<код>`), ссылки `attachment:` скачивают вложение (запрос, опрос состояния, сохранение + файла браузером), состояние `failed` — текст ошибки рядом со ссылкой; +- вложение-картинку (`image = true` в `attachments` ответа страницы) — в тексте + страницы на месте ссылки: вложение в `ready` при открытии страницы показывается картинкой сразу (содержимое `GET + /wiki/attachments/{id}/content`, без нового скачивания агентом); нажатие на ссылку нескачанной картинки ставит + скачивание (запрос, опрос состояния) и по готовности показывает картинку вместо сохранения файла. Под картинкой + остаётся ссылка «📎 <имя>», сохраняющая файл. Картинка не шире колонки страницы. Содержимое не получено (409 + `not_ready`, ошибка сети) — вместо картинки прежняя ссылка, нажатие на неё ставит скачивание и по готовности + показывает картинку; ошибка скачивания — текст рядом со ссылкой. Без нажатия приложение не ставит скачивание; +- на экранах уже 640 px выбранная страница — над деревом; +- поиск над деревом (`GET /wiki/search`): список результатов с фрагментами вместо дерева, пока строка поиска не пуста; + после очистки поиска раскрытие дерева прежнее; +- проекта без корня — форму «Корень вики (DOC-… или ссылка)» с кнопкой «Привязать» (`PATCH /projects/{label}`); 422 — + текст ошибки у поля; +- корень есть, синхронизаций нет — «вики ещё не синхронизирована» и действие «Обновить вики»; +- последняя синхронизация с ошибкой — плашку с текстом ошибки над страницей. + +#### Scenario: Открыть страницу по ссылке +- **WHEN** на странице DOC-3 нажата ссылка `page:DOC-2` +- **THEN** адрес `/app/wiki?project=AWPCA&page=DOC-2`, справа DOC-2, в дереве DOC-2 выделен + +#### Scenario: Проект без корня +- **WHEN** открыта `/app/wiki?project=sfm_kedr`, корня нет; введено `DOC-001234`, нажато «Привязать» +- **THEN** `PATCH /projects/sfm_kedr {wiki_root: "DOC-001234"}`; строка «Вики» блока состояния показывает ход пакета + синхронизации + +#### Scenario: Поиск +- **WHEN** в строке поиска «согласов» +- **THEN** вместо дерева — результаты `GET /wiki/search?project=AWPCA&q=согласов`; очистка строки возвращает дерево + +#### Scenario: Без выбранной страницы +- **WHEN** открыта `/app/wiki?project=AWPCA` без `page` +- **THEN** справа корневая страница проекта, в дереве она выделена + +#### Scenario: Скачать картинку +- **WHEN** на странице DOC-3 вложение 7 `scheme.png` (`image = true`) в `none`; нажата ссылка «📎 scheme.png»; агент скачал файл +- **THEN** `POST /wiki/attachments/7/fetch`, опрос до `ready`, `GET /wiki/attachments/7/content`; на месте ссылки — + картинка, под ней ссылка «📎 scheme.png»; файл браузером не сохраняется + +#### Scenario: Скачанная картинка видна сразу +- **WHEN** вложение 7 `scheme.png` в `ready`, открыта страница DOC-3 +- **THEN** картинка показана без нажатия; `POST /wiki/attachments/7/fetch` не вызывался + +#### Scenario: Не картинка +- **WHEN** вложение 8 `model.xlsx` (`image = false`) в `ready`, нажата ссылка «📎 model.xlsx» +- **THEN** файл сохраняется браузером, картинки нет diff --git a/openspec/changes/wiki-package-d/specs/wiki/spec.md b/openspec/changes/wiki-package-d/specs/wiki/spec.md new file mode 100644 index 0000000..c883f72 --- /dev/null +++ b/openspec/changes/wiki-package-d/specs/wiki/spec.md @@ -0,0 +1,183 @@ +## MODIFIED Requirements + +### Requirement: Синхронизация вики +Команда `wiki_sync {label, root}` SHALL быть командой чтения модуля `wiki` (без копии базы) в пакете с `project = +label`. Её SHALL ставить: `POST /wiki/sync {project}`, задание или смена корня, фоновая работа хаба — по каждому +проекту с корнем, если с последней успешной или неудачной синхронизации прошло больше `wiki_auto_sync_hours` +(настройка модуля, по умолчанию 24; `0` — выключено). Пока у проекта есть пакет `wiki_sync` не в терминальном статусе, +новый не ставится: `POST /wiki/sync` отвечает 409 `sync_in_progress` с `batch_id` текущего. Любая другая постановка +пакета с `wiki_sync` (`POST /batches`, повтор команды `POST /commands/{id}/retry`) SHALL отклоняться с 422, если у +проекта уже есть команда `wiki_sync` с тем же `root` в `queued` или `claimed`; текст ошибки называет пакет идущей +синхронизации. Команда с другим `root` не мешает: смена корня ставит синхронизацию нового корня, пока старая ещё +выдана агенту. Смена или сброс корня SHALL отменять невыданные (`queued`) пакеты `wiki_sync` проекта и при новом корне ставить новый; отчёт `done` по +команде, чей `root` не равен текущему корню проекта, SHALL отбрасываться без изменения копии и данных +(`result.discarded = "корень сменился"`). + +Агент `wiki` SHALL прислать все страницы поддерева корня (включая корень) пачками `POST /agent/wiki/pages {command_id, +pages}` — каждая страница `{code, eva_id, title, parent_code, orderno, url, html, attachments: [{ref, url, name}], +links: [{ref, code}]}`, где `attachments` — ссылки и картинки на файлы Eva, `links` — ссылки на документы Eva, `ref` — +значение атрибута `src`/`href` ровно как в `html`, `url` — абсолютный адрес, `name` — `alt`/`title`, текст ссылки или +имя файла; в пачке не больше 50 страниц, — и отчитаться `done` с `result = {root_title, codes, eva_calls}`, где +`codes` — коды всех страниц дерева в порядке обхода. Хаб SHALL при приёме пачки переводить HTML в Markdown вне +транзакции записи и складывать результат во временную стадию команды, не трогая копию. Пачка по +команде не в `claimed` или не `wiki_sync` — 409; неверная форма страницы — 422 `bad_pages` с кодом страницы и полем. + +По отчёту `done` хаб SHALL одной операцией заменить копию проекта (см. «Markdown-копия вики»), страницы, индекс поиска и +вложения и записать `synced_at`, `pages_count`, `root_title`, очистив `sync_error`. Код из `codes` без присланной +страницы — команда `failed` «нет страницы <код>». Отчёт `failed` или перевод в `failed` по пределу выдач SHALL записать +`sync_error`; копия, страницы и вложения остаются прежними. Повторная выдача команды SHALL очищать её стадию. Стадия +очищается при любом терминальном статусе команды. + +#### Scenario: Первая синхронизация +- **WHEN** у `AWPCA` корень DOC-1 с детьми DOC-2, DOC-3; агент прислал три страницы одной пачкой и `done {root_title: + "АРМ КА", codes: [DOC-1, DOC-2, DOC-3]}` +- **THEN** в `wiki/AWPCA/` три `index.md`; `GET /projects` — `wiki_pages = 3`, `wiki_root_title = АРМ КА`, + `wiki_synced_at` заполнен + +#### Scenario: Неудачная синхронизация не портит копию +- **WHEN** копия `AWPCA` из 3 страниц, следующая синхронизация завершилась `failed` «Eva: HTTP 503» +- **THEN** копия и поиск прежние; `wiki_sync_error = "Eva: HTTP 503"` + +#### Scenario: Потерянная пачка +- **WHEN** отчёт `done` с `codes: [DOC-1, DOC-2]`, пачки пришли только для DOC-1 +- **THEN** команда `failed` «нет страницы DOC-2», копия прежняя + +#### Scenario: Страница удалена в Eva +- **WHEN** в прошлой копии были DOC-1, DOC-2, DOC-3, новая синхронизация вернула DOC-1, DOC-3 +- **THEN** каталога DOC-2 нет, поиск его не находит, его вложения удалены + +#### Scenario: Корень исправлен, пока агент не установлен +- **WHEN** у `AWPCA` корень DOC-12 и пакет `wiki_sync` в `queued`; `PATCH /projects/AWPCA {wiki_root: "DOC-21"}` +- **THEN** прежний пакет `cancelled`, поставлен `wiki_sync {root: DOC-21}` + +#### Scenario: Поздний отчёт по старому корню +- **WHEN** `wiki_sync {root: DOC-12}` выдан агенту, корень сменён на DOC-21, агент отчитался `done` +- **THEN** копия и страницы `AWPCA` не изменились, команда `done` с `result.discarded` + +#### Scenario: Повторный запрос синхронизации +- **WHEN** пакет `wiki_sync` проекта `AWPCA` выполняется, вызван `POST /wiki/sync {project: AWPCA}` +- **THEN** 409 `sync_in_progress` с `batch_id` текущего пакета, нового пакета нет + +#### Scenario: Повтор упавшей синхронизации во время идущей +- **WHEN** команда `wiki_sync {label: AWPCA, root: DOC-1}` 40 в `failed`, фоновая работа поставила пакет 41 с + `wiki_sync {root: DOC-1}` (`queued`); `POST /commands/40/retry` +- **THEN** 422, в тексте — пакет 41; нового пакета нет + +#### Scenario: Смена корня при выданной синхронизации +- **WHEN** `wiki_sync {root: DOC-12}` выдан агенту (`claimed`); `PATCH /projects/AWPCA {wiki_root: "DOC-21"}` +- **THEN** 200, поставлен `wiki_sync {root: DOC-21}` + +### Requirement: Markdown-копия вики +Копия проекта SHALL лежать в `/wiki/<метка>/` (каталоги 0700): у каждой страницы — каталог +`NN_<название>` внутри каталога родителя (`NN` — двузначная позиция среди соседей по `orderno`, затем по коду; у корня +номера нет; название без символов `<>:"/\|?*` и управляющих, обрезано до 80 символов; совпадение у соседей — +суффикс `_2`, `_3`), в нём `index.md`: frontmatter `code`, `title`, `url`, `synced_at`, строка `# ` и текст. В +корне копии — `_tree.json`, список `{code, title, depth, path}` в порядке обхода. Копия SHALL собираться рядом +(`wiki/.<метка>.new/`) и подменять прежнюю переименованием; незавершённую подмену хаб SHALL доводить при старте. + +HTML страницы SHALL переводиться в Markdown (заголовки `#`); ссылка или картинка на файл Eva (по списку `attachments` агента: адрес на хосте Eva +с путём `/files/…`, относительный адрес — тоже Eva; миниатюра `<адрес>.meta/thumbnail.jpg` означает файл `<адрес>`, а +ссылки на `static/…` — значки интерфейса — из текста удаляются) SHALL заменяться на +`[📎 <имя>](attachment:<id>)`, где `id` — строка вложения; ссылка на документ Eva с кодом `DOC-<цифры>` (в Eva — относительный +`project/Document/DOC-…`) — на `[<текст>](page:<код>)`; прочие ссылки остаются. Сырой HTML в копию не попадает. + +Перевод SHALL давать корректный Markdown на любом HTML Eva: +- картинка внутри ссылки на то же вложение — на любой глубине вложенности (`<a><span><img></span></a>`) — даёт одну + ссылку на вложение; +- вложение внутри ссылки на документ Eva выводится отдельной ссылкой на вложение перед ссылкой на страницу; текст + ссылки на страницу — её текст без вложения, пустой — код страницы; ссылка внутри ссылки не появляется; +- при нескольких записях `attachments` или `links` с одним `ref` действует первая; +- пробел и круглые скобки в адресе внешней ссылки или картинки кодируются (`%20`, `%28`, `%29`); +- текст ссылки не может экранировать её закрывающую скобку: хвостовая `\` экранируется. + +#### Scenario: Раскладка +- **WHEN** корень DOC-1 «АРМ КА», дети DOC-3 «Статусы» (`orderno` 1) и DOC-2 «Роли/права» (`orderno` 2) +- **THEN** `wiki/AWPCA/АРМ КА/index.md`, `…/01_Статусы/index.md`, `…/02_Роли_права/index.md`; `_tree.json` в порядке + DOC-1, DOC-3, DOC-2 + +#### Scenario: Картинка на странице +- **WHEN** в HTML DOC-3 `<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/scheme.png?version=1">` +- **THEN** в `index.md` — `[📎 scheme.png](attachment:<id>)`, у вложения `<id>` `state = none`, файл не скачан + +#### Scenario: Прикреплённый файл с миниатюрой +- **WHEN** на странице миниатюра `…/<uuid>/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и + ссылка на `…/<uuid>/model.xlsx` +- **THEN** одно вложение `model.xlsx` с адресом файла; в тексте одна ссылка `[📎 model.xlsx](attachment:<id>)`, значка нет + +#### Scenario: Миниатюра в обёртке +- **WHEN** `<a href="…/model.xlsx"><span><img src="…/model.xlsx.meta/thumbnail.jpg"></span></a> текст` +- **THEN** в тексте `[📎 model.xlsx](attachment:<id>) текст` + +#### Scenario: Ссылка на другую страницу +- **WHEN** в HTML `<a href="project/Document/DOC-019834">Модель данных</a>` +- **THEN** в `index.md` — `[Модель данных](page:DOC-019834)` + +#### Scenario: Картинка-ссылка на другую страницу +- **WHEN** `<a href="project/Document/DOC-1"><img src="…/scheme.png"></a>`, `scheme.png` — вложение +- **THEN** в тексте `[📎 scheme.png](attachment:<id>) [DOC-1](page:DOC-1)` + +#### Scenario: Пробелы и скобки во внешнем адресе +- **WHEN** `<a href="http://ex.com/a b(c).html">док</a>` +- **THEN** в тексте `[док](http://ex.com/a%20b%28c%29.html)` + +#### Scenario: Обратная косая черта в конце текста +- **WHEN** `<a href="project/Document/DOC-1">путь C:\</a>` +- **THEN** ссылка на `page:DOC-1` с текстом «путь C:\» остаётся ссылкой при разборе Markdown + +### Requirement: Вложения вики по запросу +`POST /wiki/attachments/{id}/fetch` SHALL ставить пакет из одной команды чтения `wiki_fetch {attachment_id, url}` и +переводить вложение в `fetching`; для `fetching` — ответ текущим состоянием без нового пакета, для `ready` — сразу +`ready`. Вложение в `ready`, чьего файла нет в `files/`, SHALL считаться нескачанным: запрос ставит скачивание заново. +Агент `wiki` SHALL скачать файл с токеном Eva, отказаться от файла больше предела хранилища ядра (10 МБ) с +ошибкой «файл больше 10 МБ — откройте в Eva», загрузить файл `POST /agent/files` и отчитаться `done {file, size}`. Хаб +SHALL проверить, что файл есть в `files/` и размер совпадает, и перевести вложение в `ready`; иначе команда и вложение +`failed`. Адрес не на хосте Eva из конфига агента агент SHALL отвергать без запроса (`failed` «чужой хост»). +Файлы вложений в `ready` SHALL считаться ссылками для уборки `files/`. `GET /wiki/attachments/{id}` отдаёт +`{id, name, page_code, url, state, size, error}`, `GET /wiki/attachments/{id}/content` — содержимое (`ready`) или 409 +`not_ready`. + +#### Scenario: Скачивание картинки +- **WHEN** вложение 7 в `none`, `POST /wiki/attachments/7/fetch`, агент прислал файл 300 КБ +- **THEN** вложение 7 `ready`, `size = 307200`, `GET /wiki/attachments/7/content` — файл + +#### Scenario: Слишком большой файл +- **WHEN** агент отчитался `failed` «файл больше 10 МБ — откройте в Eva» +- **THEN** вложение `failed` с этим текстом; `GET …/content` — 409 `not_ready` + +#### Scenario: Вложение переживает синхронизацию +- **WHEN** вложение 7 `ready`, синхронизация вернула ту же страницу с той же ссылкой +- **THEN** у вложения тот же `id` 7 и `state = ready`, файл в `files/` не убран + +#### Scenario: Файл вложения пропал +- **WHEN** вложение 7 `ready`, его файла нет в `files/`; `POST /wiki/attachments/7/fetch` +- **THEN** 202, вложение 7 `fetching`, поставлен пакет `wiki_fetch {attachment_id: 7}` + +### Requirement: Маршруты вики +Хаб SHALL предоставлять под клиентским токеном (проект — `project=<метка>`, неизвестный — 404, без корня — 409 +`no_root`): +- `GET /wiki/status?project=` — `{root, root_title, synced_at, pages, error, batch: {id, status} | null}`; +- `GET /wiki/tree?project=` — `{"pages": [{code, title, depth, parent_code, size}]}` в порядке обхода; +- `GET /wiki/pages/{code}?project=` — `{code, title, path, url, synced_at, markdown, attachments: [{id, name, state, + size, error, image}]}`; `image` — `true`, если имя файла в адресе вложения (последний сегмент пути без запроса, + раскодированный) оканчивается на `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp` без учёта регистра, иначе `false` + (имя вложения из Eva для этого не годится: у картинок это часто `alt` без расширения); страницы нет в копии — 404; +- `GET /wiki/search?project=&q=&limit=` (`limit` 1–50, по умолчанию 10) — `{"results": [{code, title, path, snippet}]}` по + убыванию релевантности; слова запроса ищутся по префиксу, пустой запрос — 422; +- `POST /wiki/sync {project, author?}` → 201 `{batch_id}` (409 — см. «Синхронизация вики»); +- маршруты вложений — см. «Вложения вики по запросу». + +Под токеном агента `wiki` — `POST /agent/wiki/pages` (см. «Синхронизация вики»). + +#### Scenario: Поиск +- **WHEN** в копии `AWPCA` страница DOC-3 «Статусы» содержит «статус «На согласовании» ставит аналитик»; + `GET /wiki/search?project=AWPCA&q=согласов` +- **THEN** первым — DOC-3 с фрагментом, содержащим «На согласовании» + +#### Scenario: Проект без корня +- **WHEN** у `sfm_kedr` нет корня, `GET /wiki/tree?project=sfm_kedr` +- **THEN** 409 `no_root` + +#### Scenario: Признак картинки +- **WHEN** у вложения 9 страницы DOC-3 имя «Схема расчёта суммы элиминации по тройке», адрес + `…/vgo-matching-1.png?version=1`; у вложения 8 имя `attachment`, адрес `…/model.xlsx` +- **THEN** в `GET /wiki/pages/DOC-3?project=AWPCA` у 9 `image = true`, у 8 `image = false`