diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0d0aaf6..283211a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,12 +1,12 @@ # Архитектура -Хаб моста Jira: ядро + модули; сейчас один модуль — `jira`: SQLite-зеркало задач проектов-меток Jira-проекта AURORA, служебные заметки, очередь команд, HTTP API и MCP. Агент (`jira-agent`, виртуалка) опрашивает хаб, пишет в Jira и возвращает состояние; хаб Jira не вызывает. +Хаб моста Jira: ядро + два модуля. `jira` — SQLite-зеркало задач проектов-меток Jira-проекта AURORA, служебные заметки, очередь команд, HTTP API и MCP; `wiki` — Markdown-копия вики Eva по проектам, поиск и вложения по запросу. У каждого модуля свой агент на виртуалке (`jira-agent`, `eva-agent`): он опрашивает хаб, ходит в свою внешнюю систему и возвращает данные; хаб во внешние системы не ходит. ## Ядро и модули Хаб — ядро (`src/jira_hub/core/`) и подключаемые модули (`src/jira_hub/modules/<имя>/`). Ядро держит механику: базу, очередь команд, HTTP, настройки, реле, выпуск агента, каркас MCP и CLI. Модуль даёт смысл: схему своих таблиц, типы -команд, маршруты, настройки, MCP-инструменты, подкоманды. Сейчас модуль один — `jira`; следующие (wiki, sdlc) подключаются +команд, маршруты, настройки, MCP-инструменты, подкоманды. Сейчас модулей два — `jira` и `wiki`; следующие (sdlc) подключаются так же, строкой в `core/registry.py`. ### Ядро (`core/`) @@ -17,19 +17,19 @@ | `errors` | ошибки, общие для ядра и модулей (`CommandError`, `HttpMapped`) | stdlib | | `http` | общее для обработчиков HTTP: `ApiError`, `Raw`, разбор параметров, `mapped`, `not_found` | `errors`, `projects` | | `modules` | контракт модуля: `Module` и `CommandHooks` (только структуры) | stdlib | -| `registry` | реестр модулей: ленивая сборка, `modules()`, `module(name)`, `module_of(тип_команды)`, проверка конфликтов (типы команд, маршруты, ключи настроек) | `modules`; модули — ленивым импортом внутри функции | -| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v6; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | -| `settings` | настройки (`setting`), токены, умолчания модулей (`ensure_defaults`) | `registry` (база — через `conn` вызывающего) | -| `projects` | проекты-метки: таблица `project`, проверка метки, создание, список (долг: `board_id`, `board_type`, `issues_count` — см. «Границы») | `db` | +| `registry` | реестр модулей: ленивая сборка, `modules()`, `module(name)`, `module_of(тип_команды)`, `agent_modules()`, `DEFAULT_AGENT`, проверка конфликтов (типы команд, маршруты, ключи настроек, поля проекта) | `modules`; модули — ленивым импортом внутри функции | +| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v7; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | +| `settings` | настройки (`setting`), токены (клиентский + по агенту на модуль: `setting.tokens = {client, agents: {<модуль>: <токен>}}`), умолчания модулей (`ensure_defaults`) | `registry` (база — через `conn` вызывающего) | +| `projects` | проекты-метки: таблица `project` (`label`, `name`, `created_at`), проверка метки, создание и правка (`PATCH`) с полями модулей, список = поля ядра + `project_view` модулей | `db`, `registry` | | `queue` | механика очереди: пакеты, выдача (`claim`), отчёты, отмена, повтор, временные ключи `new:`; смысл типов команд берёт у модуля через `CommandHooks` (`registry.module_of`) | `backup`, `db`, `registry`, `settings`, `errors` | | `backup` | копии файла базы, ротация | `paths` | | `files` | хранилище файлов `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put`, `touch`, `sweep`, `sweep_downloads`, `path`/`exists`/`size`/`read` | `paths`, `registry` | | `api` | HTTP-сервер, авторизация по токенам, фоновый `tick`, `Hub`; маршруты ядра `CORE_ROUTES` (`/agent/*`, `/files`, `/projects`, `/settings`, раздача `/app…`) и сводный `ROUTES` = ядро + маршруты модулей; уборка `files/` раз в сутки | `queue`, `projects`, `settings`, `backup`, `db`, `files`, `relay`, `release`, `registry`, `http`, `errors`, `paths` | | `relay` | реле общей папки (`relay_dir`): файлы `req/` → свой HTTP → `resp/`; агент с `hub_url = folder:<путь>` ходит через него | `settings` | -| `release` | опубликованная версия агента `release/agent.py` + `manifest.json`: `publish` (только вверх), `current`, `load`, `hint` | `paths` | +| `release` | опубликованная версия агента модуля `release/<модуль>/agent.py` + `manifest.json`: `publish` (только вверх), `current`, `load`, `hint` | `paths` | | `client` | HTTP-клиент хаба (`HubClient`, `from_home`, `get_bytes`) | `db`, `paths`, `settings` (только `from_home`) | | `mcp` | каркас MCP: охрана («хаб выключен» — подсказка), привязка сессии к проекту (`PROJECT`), общие инструменты `projects` и `project_create`, `register_core_tools`, запуск сервера модуля по stdio | `client`, `registry` | -| `cli` | `init`, `serve`, `mcp [модуль] [--project]`, `tokens`, `agent publish` и подкоманды модулей (`Module.cli_register`/`cli_run`) | `api`, `mcp`, `registry`, `release`, `settings`, `db`, `paths` | +| `cli` | `init`, `serve`, `mcp [модуль] [--project]`, `tokens`, `agent publish [--module]` и подкоманды модулей (`Module.cli_register`/`cli_run`) | `api`, `mcp`, `registry`, `release`, `settings`, `db`, `paths` | ### Модуль `jira` (`modules/jira/`) @@ -51,12 +51,35 @@ | `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`jira-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | +### Модуль `wiki` (`modules/wiki/`) + +Точка входа — `modules/wiki/__init__.py` (`MODULE: Module`). Копию страниц хаб хранит файлами `wiki/<метка>/` в каталоге данных (Markdown + `_tree.json`), строки и FTS — в базе. + +| Модуль | Ответственность | Зависит от | +|---|---|---| +| `schema` | таблицы модуля: `wiki_project`, `wiki_page`, `wiki_attachment`, `wiki_sync_stage`; таблица FTS5 `wiki_page_fts` создаётся лениво при первой синхронизации (`search.ensure`), в `schema` её нет | stdlib | +| `settings` | умолчания (`wiki_pacing`, `wiki_auto_sync_hours`, `wiki_min_agent_version`), ключи для агента, проверка значений | `core.settings` | +| `paths` | каталог копий `<каталог базы>/wiki/<метка>/` | `core.db` | +| `projects` | поле проекта `wiki_root` (изменяемое), `project_view` (`wiki_root`, `pages_count`, `synced_at`…), `on_project_created`/`on_project_updated` (постановка `wiki_sync`, отмена `queued`, сброс копии при смене корня) | `core.projects`, `core.queue`, `commands`, `paths` | +| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` | +| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:` и `page:<код>`; HTML считается недоверенным | stdlib, `markdownify` | +| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage` | `core.db`, `convert` | +| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS | `core.db`, `paths`, `search`, `convert` | +| `search` | поиск по FTS5 (запасной LIKE по `index.md`, если FTS5 в SQLite нет), фрагменты | `paths` | +| `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` | +| `tick` | фоновая синхронизация проектов с корнем раз в `wiki_auto_sync_hours` | `core.db`, `core.settings`, `commands`, `sync`, `search` | +| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` | +| `mcp` | инструменты сервера `jira-hub-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` | + ### Контракт модуля `core/modules.py` задаёт две структуры; ядро знает только их. -- `Module(name, schema, settings_defaults, agent_keys, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, on_project_created, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. -- `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result`. Очередь вызывает их по типу команды через `registry.module_of`. +- `Module(name, schema, settings_defaults, agent_keys, agent_min_version_key, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, project_fields, project_view, on_project_created, on_project_updated, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. Модуль с `commands` — «вид агента»: у него свой агент, токен, `agent_status` и релиз. +- Поля проекта: ядро знает только `label`, `name`, `created_at`. Остальное объявляет модуль: `ProjectField(name, validate, editable)`; значения для `GET /projects` (в т.ч. вычисляемые) отдаёт `project_view(conn, labels)`; `on_project_created(conn, project, fields, author)` и `on_project_updated(...)` пишут свои поля и возвращают пакеты. `POST /projects` принимает поля всех модулей (неизвестное — 422), `PATCH /projects/{label}` — только изменяемые (`editable`). `registry.check` падает при совпадении имени поля у двух модулей или с полем ядра. +- `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`. +- `before_result(conn, cmd, status, result, files_dir)` ядро вызывает в `report_result` до транзакции отчёта, только для выданной (`claimed`) команды. Нужен, чтобы тяжёлая подготовка (сборка копии вики, запись файлов) шла вне `BEGIN IMMEDIATE` и не держала блокировку базы, в которой ждут `claim` другого агента и веб (рулинг R-6). `ValueError` из него ядро превращает в `RuntimeError` (500, агент повторит отчёт), чтобы не спутать с неверным статусом отчёта. +- `abort_result(conn, cmd)` ядро вызывает, если отчёт не дошёл до `after_result` (поздний или идемпотентный выход, исключение в транзакции): модуль снимает подготовку `before_result` и её блокировку. При успехе подготовку снимает `after_result`. `registry.check` при сборке падает на конфликте: один тип команды у двух модулей, пересечение маршрутов или ключей настроек. Уникальность имён модулей проверяется только в вебе (`web/src/modules/modules.test.tsx`); в Python реестр её не проверяет. @@ -69,6 +92,11 @@ `web/src/modules/types.ts` (`pages`, `nav`, `Provider`, `SidebarStatus`, `TopbarNotice`, `useOverlay`, `useOnProjectCreated`); список модулей — константа `MODULES` в `web/src/modules/index.ts` (хуки вызываются в постоянном порядке). +Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница +(`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` +скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), +плашка синхронизации в боковой панели (`SyncStatus`); поле «Корень вики» в окне «Новый проект» — через `projectFields`. + Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/board`), «Задачи» (`/app/issues`), «Лента» (`/app/feed`), карточка `/app/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи @@ -84,8 +112,9 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк Цепочки зависимостей (Python): - ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`; +- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`; - модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `importer → mirror`; -- веб: `shell → modules/index → modules/jira → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта). +- веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта). ## Потоки данных - Запись в Jira: клиент (MCP/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. @@ -97,24 +126,35 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк агент v13 после состояния читает changelog Jira → `POST /agent/history` → `history.record_changes` (повторы отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона (новая отметка) → `GET /changes`, `GET /issues/{key}.changes`, MCP `changes`. +- Синхронизация вики: `wiki_sync {label, root}` ставят MCP `sync`, кнопка «Обновить» (`POST /wiki/sync`), привязка/смена корня (`on_project_created`/`on_project_updated`) и `tick` (раз в `wiki_auto_sync_hours`) → `claim` агента `wiki` → eva-agent обходит дерево и шлёт пачки `POST /agent/wiki/pages` (хаб принимает ≤50 страниц в пачке; срез по 4 МБ делает агент) → `stage.accept` переводит HTML в Markdown вне транзакции и кладёт в `wiki_sync_stage` → отчёт `done {root_title, codes}` → `before_result` (`sync.prepare`: сборка `wiki/.<метка>.new/`, выделение id вложений, под per-label `Lock`) → транзакция отчёта (`on_result` = `sync.commit`: строки `wiki_page`/`wiki_attachment`/FTS, атомарная подмена каталога) → `after_result` снимает подготовку; при сбое — `abort_result`. Отчёт по команде с устаревшим корнем отбрасывается (`result.discarded`), копия прежняя. +- Вложение вики: `POST /wiki/attachments/{id}/fetch` (веб, MCP `attachment`) → `attachments.request_fetch` (одна транзакция: `state = fetching` + пакет `wiki_fetch {attachment_id, url}`) → `claim` → eva-agent качает файл только с хоста Eva → `POST /agent/files` → результат `{file, size}` → хаб проверяет файл в `files/` и ставит `ready` (иначе `failed`) → `GET /wiki/attachments/{id}/content`; в MCP копия `downloads/wiki-/<имя>`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. -- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. -- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. +- Самообновление агента: `jira-hub agent publish [--module M] PATH` → `release/<модуль>/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. +- Токены: клиентский — всё, кроме `/agent/*`; по токену на каждый модуль-агент (`jira`, `wiki`) — только `/agent/*`, причём маршруты `scope=agent` модуля пускают лишь его агента (токен чужого — 401); `claim` выдаёт агенту только команды его модуля, по одной в полёте на агента; `agent_status` (ключ — имя агента), релиз `release/<модуль>/` и самообновление — по агентам; MCP берёт клиентский токен из базы через `HubClient.from_home`. Токен Eva в хабе не хранится — только в `agent.json` eva-agent. ## Границы - Ядро (`core/`) не импортирует модули (`jira_hub.modules.*`): исключения — `core/registry.py` (ленивый импорт внутри функции) и шаги истории миграций в `core/db.py` (ленивый импорт внутри шага). Проверяет `tests/test_registry.py::test_core_imports`. -- Типы команд (`ensure_*`, `refresh`, `fetch_attachment` и др.) и термины jira (`sync_run`, `issue_attachment`, `attachment_file`, `issue_comment`, `jql`) живут только в модулях; ядро знает их лишь через `CommandHooks`. Проверяет `test_core_has_no_jira_terms` (исключения — `core/db.py`, история миграций, и строки с меткой `долг hub-modules`). -- Новые имена в ядре — без «jira». Модуль не вызывается из ядра иначе как через `registry` и контракт `Module`. -- Долг проектов (change wiki): проект-метка пока устроен под jira — `board_id`, `board_type` и проверка `board_id` в `core/projects.py`, `issues_count` в списке проектов и `projects.create(..., board_id)` в HTTP-слое (`core/api.py`: `h_projects_get`, `h_projects_post`), `project_create.board_id` и `fmt_projects` в `core/mcp.py`, поле доски в `web/src/projects/NewProjectDialog.tsx`. Места помечены комментарием `долг hub-modules: проекты → модуль jira (change wiki)`; убирать их можно только вместе с переносом проектов в модуль. +- Типы команд (`ensure_*`, `refresh`, `fetch_attachment`, `wiki_sync`, `wiki_fetch` и др.) и термины модулей (jira: `sync_run`, `issue_attachment`, `attachment_file`, `issue_comment`, `jql`, поля досок; wiki: `eva`, `wiki_`, `wiki/`, `DOC-`) живут только в модулях; ядро знает их лишь через `CommandHooks` и поля проекта. Проверяют `test_core_has_no_jira_terms` (исключение — `core/db.py`, история миграций) и `test_core_has_no_module_terms` (исключение — `core/registry.py`). Меток-исключений «долг» нет. +- Новые имена в ядре — без названий модулей. Модуль не вызывается из ядра иначе как через `registry` и контракт `Module`. +- Копию вики (`wiki/<метка>/`, `wiki_page`, FTS) пишет только `sync.commit` в отчёте `wiki_sync` (и удаляет при сбросе корня `projects._drop_copy` в транзакции PATCH); MCP и API её читают. Модуль wiki не обращается к Eva и не хранит токен Eva. - Зеркало (включая `issue_attachment`) пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. - Журнал `issue_change` пишет только `history.record_changes` по `POST /agent/history`; API и MCP его только читают. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). -- MCP (`core/mcp`, `modules/jira/mcp`) не импортирует `queue`/`db`/`projects` и модульные `mirror`/`history` напрямую, ходит только через `client`; из `core.files` берёт только предел `MAX_SIZE`, из `attachments` — только `fmt_size`; `downloads/` пишет только он (хаб эту папку лишь убирает). +- MCP (`core/mcp`, `modules/*/mcp`) не импортирует `queue`/`db`/`projects` и модульные `mirror`/`history` напрямую, ходит только через `client`; из `core.files` берёт только предел `MAX_SIZE`, из `attachments` — только `fmt_size`; `downloads/` пишет только он (jira — `/`, wiki — `wiki-/`) (хаб эту папку лишь убирает). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `core/api`, `modules/*/api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. Модуль веба не импортирует другой модуль; общее — в `shell`, `ui`, `api`, `projects`. +## Выкатка и откат версии со схемой v7 +- Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить. +- До kickstart не запускать MCP и CLI `jira-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её до v7, а хаб на прежнем коде (v6) с такой базой не работает (`agent_status`, токены). +- Откат на копию v6 из `backups/` (`*schema-v6*`): вместе с базой вернуть публикацию агента `release/jira/*` в `release/` (прежняя раскладка `release/agent.py`, `manifest.json`); иначе старый код увидит «публикации нет» и самообновление агента молчит. + ## Открытые вопросы - Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. - Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их. +- Запись в вики Eva (правка страниц из хаба) не делается: модуль только читает. +- История страниц вики и частичная перекачка: сейчас каждая синхронизация перекачивает всё дерево, копия без истории. +- Переименование проекта-метки и перенос корня вики между проектами не предусмотрены (меняется только корень — `PATCH /projects`). +- Ручной просмотр вёрстки веб-страницы «Вики» на живых данных (таблицы, вложенные списки) — за владельцем после выкатки. - Переименование хаба в sdlc-hub (модули jira + wiki, eva-agent) — после перестройки кода; имена пакета, команды и MCP-сервера пока `jira-hub`. diff --git a/CLAUDE.md b/CLAUDE.md index 8f4cae8..e884326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # jira-hub ## Что это -Хаб моста Jira для АРМ КА: единственный источник истины по задачам проектов-меток Jira-проекта AURORA на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`). +Хаб моста Jira для АРМ КА: единственный источник истины по задачам проектов-меток Jira-проекта AURORA на Mac mini (`192.168.0.106`) и Markdown-копии вики Eva по проектам (модуль `wiki`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`). ## Стек и ограничения - Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git. @@ -9,15 +9,16 @@ - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). -- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/`, `files/` и `downloads/` — права 0700. Порт по умолчанию 8790. -- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/<модуль>/` (опубликованная версия агента модуля), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `/<имя>`, у wiki `wiki-/<имя>`, уборка 30 дней), `wiki/<метка>/` (Markdown-копия вики проекта: `index.md` в каталоге страницы, `_tree.json`); каталог данных, `backups/`, `release/<модуль>/`, `files/`, `downloads/` и `wiki/` — права 0700. Порт по умолчанию 8790. +- Статические токены (в таблице `setting`): клиентский — всё, кроме `/agent/*`; по одному токену агента на модуль (`jira`, `wiki`) — только `/agent/*` и только маршруты своего модуля. Токен Eva в хабе не хранится. ## Архитектура - `src/jira_hub/core/` — ядро: `db`, `settings`, `projects`, `queue` (механика очереди), `files`, `api` (HTTP, `CORE_ROUTES` + маршруты модулей в `ROUTES`, фоновый `tick`), `relay`, `release`, `client`, `mcp` (каркас MCP), `cli`; контракт модуля `Module`/`CommandHooks` — `core/modules.py`, реестр — `core/registry.py`. - `src/jira_hub/modules/jira/` — модуль jira: `schema`, `mirror`, `history`, `attachments`, `fetch`, `commands`, `refresh`, `projects`, `hooks` (смысл типов команд для очереди), `api`, `mcp`, `settings`, `importer`/`cli`. Входная точка — `MODULE` в `modules/jira/__init__.py`. -- Граница: ядро не импортирует модули (кроме ленивого импорта в `registry.py` и шагах миграций `db.py`), типы команд и термины jira живут в модуле, новые имена в ядре — без «jira»; проверяет `tests/test_registry.py`. Места долга проектов (`board_id`, `board_type`, `issues_count`) помечены `долг hub-modules`. +- `src/jira_hub/modules/wiki/` — модуль wiki: `schema`, `settings`, `paths`, `projects` (поле `wiki_root`), `commands` (`wiki_sync`, `wiki_fetch` и хуки очереди), `convert` (HTML → Markdown), `stage`, `sync`, `search` (FTS5), `attachments`, `tick`, `api`, `mcp`. Входная точка — `MODULE` в `modules/wiki/__init__.py`. +- Граница: ядро не импортирует модули (кроме ленивого импорта в `registry.py` и шагах миграций `db.py`), типы команд и термины модулей (jira, wiki, eva) живут в модуле, новые имена в ядре — без названий модулей; проверяют `test_core_imports`, `test_core_has_no_jira_terms`, `test_core_has_no_module_terms` в `tests/test_registry.py`. Меток-исключений нет. - Цепочка ядра: `db` ← `settings`, `projects` ← `queue` ← `api` ← `cli`; `files` ← `queue`, `api`; реле `relay` — поток в `serve`; `backup` вызывается из `api` и из `db`. Модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core`. -- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); модуль jira — страницы доски, задач и ленты. +- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); модуль jira — страницы доски, задач и ленты; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`). - Подробная карта модулей и границ — `ARCHITECTURE.md`. ## Команды @@ -27,8 +28,8 @@ - `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. - Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`, лента изменений — `/app/feed?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). -- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [модуль] [--project <метка>]` — MCP модуля по stdio (по умолчанию `jira`; с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены. -- `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх). +- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [модуль] [--project <метка>]` — MCP модуля по stdio (по умолчанию `jira`; `jira-hub mcp wiki` — сервер `jira-hub-wiki`; с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены (клиентский и по агенту на модуль). +- `uv run jira-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`). ## Тесты - Golden-тесты: `tests/fixtures/.*` + эталон `.expected.*`. Внешние сервисы в тестах мокаются. diff --git a/README.md b/README.md index 6cb9c45..ff090c1 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ Python для хаба uv ставит свой (3.13, `.python-version`; сис `pyproject.toml`): в SQLite 3.51.0–3.51.1 есть взаимная блокировка, от которой хаб зависал под параллельными запросами. Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`. -Код: ядро — `src/jira_hub/core/`, модуль jira — `src/jira_hub/modules/jira/`, веб — `web/src/` (модуль jira — `web/src/modules/jira/`); карта — `ARCHITECTURE.md`. +Код: ядро — `src/jira_hub/core/`, модуль jira — `src/jira_hub/modules/jira/`, модуль wiki — `src/jira_hub/modules/wiki/`, веб — `web/src/` (модуль jira — `web/src/modules/jira/`); карта — `ARCHITECTURE.md`. Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`): -`hub.sqlite`, `backups/`, `release/` и `files/` (файлы вложений по sha256; каталоги — 0700). Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. +`hub.sqlite`, `backups/`, `release/<модуль>/`, `files/` (файлы вложений по sha256), `downloads/` (копии вложений для MCP) и `wiki/<метка>/` (Markdown-копия вики проекта); каталоги — 0700. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. ## Первый запуск @@ -28,12 +28,12 @@ uv run jira-hub serve # 0.0.0.0:8790 ``` `init` переносит в настройки хаба всё, что относится к проекту Jira (поля, типы, карту переходов, типы связей, доску, -приоритеты), и создаёт два токена: агентский и клиентский. Адрес и учётные данные Jira (`base_url`, `auth`, `verify_ssl`, +приоритеты), и создаёт токены: клиентский и по одному на агента каждого модуля (`jira`, `wiki`). Адрес и учётные данные Jira (`base_url`, `auth`, `verify_ssl`, `ca_bundle`) остаются у агента и в хаб не попадают. Повторный `init` отказывает ("уже инициализирован"); с `--force` настройки переписываются. Токены можно показать позже: `uv run jira-hub tokens`. -Передать агенту нужно адрес хаба и агентский токен: через общую папку виртуалки положить в `agent.json` -`"hub_url": "http://192.168.0.106:8790"` и `"hub_token": "<агентский токен>"`. Клиентский токен остаётся на этой машине: +Передать агенту jira нужно адрес хаба и токен агента jira (`uv run jira-hub tokens` → «агент jira»): через общую папку виртуалки положить в `agent.json` +`"hub_url": "http://192.168.0.106:8790"` и `"hub_token": "<токен агента jira>"`. Клиентский токен остаётся на этой машине: его использует MCP (читает из базы сам). Порт меняется ключом `--port` или настройкой `port`; адрес прослушивания — `--host`. @@ -61,6 +61,22 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj После перезапуска хаба или обновления кода MCP — `/mcp` → reconnect. +MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp wiki`). Регистрация в `.mcp.json` репозитория проекта (рядом с `jira-hub`, с привязкой к проекту): + +```json +{ + "mcpServers": { + "jira-hub-wiki": { + "command": "uv", + "args": ["run", "--project", "/Users/andrey.epifancev/projects/jira-bridge/jira-hub", "jira-hub", "mcp", "wiki", "--project", "AWPCA"] + } + } +} +``` + +Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`. Пока у проекта нет корня +вики (`root_set DOC-…` или `wiki_root` в `project_create`), они отвечают «у проекта <метка> нет корня вики». + Сессия MCP работает в разрезе одного проекта-метки. Привязка — `--project <метка>` в `.mcp.json` репозитория проекта (метка сравнивается точно, с учётом регистра): @@ -113,6 +129,23 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj {"type": "ensure_attachment", "params": {"key": "new:1", "path": "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}}] ``` +## Вики и eva-agent + +Копию вики Eva хаб получает от `eva-agent` — отдельного агента на той же виртуалке (отдельный репозиторий `aep/eva-agent`, +каталог `jira-bridge/eva-agent/`; Python 3.9, один файл `src/agent.py` + `run.py` — как jira-agent). Хаб в Eva не ходит и токен Eva не хранит. + +1. `uv run jira-hub tokens` — взять токен агента `wiki` (у `jira` и `wiki` токены разные; чужой токен на маршрутах другого агента даёт 401). +2. На виртуалке отдельным каталогом положить eva-agent и `agent.json`: `hub_url` (`folder:<путь>` или http), `hub_token` — токен агента `wiki`, + `eva: {base_url, token}`, `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, `auto_update`. Токен Eva — только здесь. Первый запуск + делает владелец руками (свой процесс и блокировка). +3. Публикация версии для самообновления: `uv run jira-hub agent publish --module wiki ../eva-agent/src/agent.py` + (файл ложится в `release/wiki/`; `min_agent_version` модуля — настройка `wiki_min_agent_version`). +4. Корень вики проекта: `project_create` с `wiki_root` или MCP `root_set DOC-…` / форма на странице «Вики» (`PATCH /projects/{метка}`) — + хаб ставит `wiki_sync`; дальше синхронизация раз в `wiki_auto_sync_hours` (по умолчанию 24, `0` — выключена), кнопка «Обновить» или MCP `sync`. + +Копия лежит в `~/.local/share/jira-hub/wiki/<метка>/` (Markdown, по каталогу на страницу). Настройки модуля: `wiki_pacing` (пауза агента между +запросами к Eva, с), `wiki_auto_sync_hours`, `wiki_min_agent_version`. + ## Транспорт через общую папку Если сеть между виртуалкой и Mac mini закрыта (VPN «Континент ZTN» на виртуалке режет локальную сеть), агент ходит @@ -143,18 +176,19 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j Опубликовать новую версию: ```bash -uv run jira-hub agent publish ../jira-agent/src/agent.py +uv run jira-hub agent publish ../jira-agent/src/agent.py # модуль jira (по умолчанию) +uv run jira-hub agent publish --module wiki ../eva-agent/src/agent.py ``` Версия берётся из строки `AGENT_VERSION = N` и должна быть больше опубликованной; файл и `manifest.json` ложатся в -`~/.local/share/jira-hub/release/` (0700). Перезапуск хаба не нужен: версия уходит агенту в ответе `claim` +`~/.local/share/jira-hub/release/<модуль>/` (0700). Перезапуск хаба не нужен: версия уходит агенту в ответе `claim` (`agent_release`), файл — по `GET /agent/release`. Агент ставит её в паузе между командами; через несколько минут `agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, запускатель вернёт прежнюю — `agent_status` покажет «откат с v …»; исправление публикуется следующим номером. Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и -выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб +выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/<модуль>/` нет. Манифест, который хаб не может разобрать (`version` не целое, `sha256` не 64 символа `[0-9a-f]`), означает «публикации нет» — одно предупреждение в логе хаба на каждую новую ошибку. @@ -207,10 +241,15 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint= ``` cd ~/projects/jira-bridge/jira-hub git pull -make web launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub +make web ``` +Порядок важен для версии со схемой v7 (модуль wiki): сразу после `git pull` — kickstart, потом `make web`. До kickstart не +запускать MCP и CLI `jira-hub` (`tokens`, `mcp`, `import`): они открывают базу и переводят её в v7, а хаб на прежнем коде +с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` — +вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента. + Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно (метка, название, необязательно номер доски Jira): после создания приложение переходит в новый проект и показывает ход пакета обновления. Текущий проект — параметр `project` в адресе; без него приложение подставляет последний выбранный diff --git a/docs/superpowers/plans/2026-10-24-wiki-module.md b/docs/superpowers/plans/2026-10-24-wiki-module.md new file mode 100644 index 0000000..a2f582d --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-wiki-module.md @@ -0,0 +1,603 @@ +# Модуль wiki, виды агентов и поля проектов — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** проект хаба связывается с корнем Eva Wiki; хаб держит Markdown-копию дерева, отдаёт её через MCP `jira-hub-wiki` и страницу «Вики»; ядро различает агентов (jira, wiki) и больше не знает про доски Jira. + +**Architecture:** ядро получает агентов по модулям (токен → модуль, `agent_status`/релиз/`claim` по агенту) и поля проекта от модулей (`ProjectField`, `project_view`, `on_project_created/updated`). Модуль `jira_hub/modules/wiki/` — по образцу модуля jira: схема, настройки, хуки команд `wiki_sync`/`wiki_fetch`, приём пачек страниц, перевод HTML → Markdown, сборка копии с атомарной подменой каталога, FTS5-поиск, маршруты, MCP, фоновая синхронизация. Веб — модуль `web/src/modules/wiki/`. Миграция схемы 6 → 7 одним шагом. + +**Tech Stack:** Python 3.13 (uv), stdlib `http.server`, SQLite (FTS5), `mcp<2` (FastMCP), `markdownify`; React 19 + TS + Vite, TanStack Query, `react-markdown` + `remark-gfm`, Vitest, Playwright. + +**Spec:** `openspec/changes/wiki-module/` — `proposal.md`, `design.md` (решения 1–16, «Decisions made without discussion» 1–17), `specs/{modules,data-model,hub-api,wiki,mcp,web-app}/spec.md`. Спека — источник истины; план — её аргумент. + +## Global Constraints + +- jira-agent v14 работает без обновления: прежний агентский токен, `POST /agent/claim` (тело с `last_jira_call_at`), ответ `claim`, `GET /agent/settings` (те же ключи и значения, кроме удалённого `board_id`), `/agent/state`, `/agent/history`, `/agent/known-keys`, `/agent/files*`, `/agent/release`. +- `tests/fixtures/baseline/mcp_tools.expected.json` (сервер `jira-hub`) НЕ меняется ни в одной задаче. Эталоны `routes`, `settings`, `schema` переписываются (`BASELINE_WRITE=1`) только в задачах, где это сказано, и в отчёте задачи перечисляется каждая изменившаяся строка эталона. +- Схема — `SCHEMA_VERSION = 7`, один шаг `MIGRATIONS[6]`; копия базы перед миграцией — прежний механизм `db.migrate`. +- Адреса веба, ключи `localStorage`, адреса и ответы API модуля jira — прежние; `GET /agent` без параметра — агент `jira`. +- Новые имена в ядре — без «jira» и без «wiki»; ядро не импортирует модули, кроме `core/registry.py` и шагов `MIGRATIONS` (`tests/test_registry.py`). +- Токен Eva в хабе не появляется нигде (ни в настройках, ни в логах, ни в тестах). +- Тестовые HTML-страницы Eva — синтетические, с форматами адресов из design.md (Context); текст ТЗ из awp-ca не копируется. +- Живой хаб не трогать: ни launchd, ни `~/.local/share/jira-hub`; тесты — во временном `JIRA_HUB_HOME`/`tmp_path`. Любой запуск CLI в тестах и вручную — с `JIRA_HUB_HOME` во временном каталоге. +- Новые зависимости: Python — `markdownify` (последняя стабильная, нижняя граница в `pyproject.toml`); веб — `react-markdown`, `remark-gfm` (точные версии, как остальные в `package.json`). +- Коммиты — по-русски, последней строкой `Co-Authored-By: Claude Opus 5.5 `. Каждая задача заканчивается зелёным `uv run pytest -q` (и `make check`, если задача трогает `web/`). +- Каждое решение исполнителя, которого нет в спеке и плане, — в отчёт задачи (контроллер переносит в design.md «Decisions made without discussion»). + +## Review Focus + +1. Хаб v6 с живыми данными (токен, `agent_status` id=1 v14, доски AWPCA 4216 scrum, публикация `release/agent.py` v14) после обновления: jira-agent с прежним токеном получает команды и релиз v14 — тест `test_v6_upgrade_keeps_jira_agent` (Task 1, дополняется в Task 2 и Task 3). +2. Синхронизация вики идёт часами (агент не установлен) — jira-команды выдаются, автообновление jira не блокируется: `test_wiki_queue_does_not_block_jira` (Task 2), `test_auto_refresh_ignores_wiki_batches` (Task 5). +3. HTML Eva с повторяющимися ссылками, миниатюрами файлов, значками `static/`, относительными `project/Document/DOC-…`, таблицами — копия без сырого HTML, одна ссылка на файл: тесты `tests/test_wiki_convert.py` (Task 6). +4. Сбой посреди подмены каталога (хаб упал после переименования старой копии) — при старте копия доводится, поиск и страницы сходятся: `test_finish_pending_swap` (Task 7). +5. Два проекта с одним корнем или пересекающимися деревьями; название страницы с `/`, `..`, эмодзи, 200 символов, совпадающие названия соседей — пути внутри `wiki/<метка>/`, без выхода наружу: `test_layout_names_safe` (Task 7). + +--- + +## Целевая раскладка + +``` +src/jira_hub/core/ + modules.py + ProjectField; Module: project_fields, project_view, on_project_created(conn, project, fields, author), + on_project_updated, agent_min_version_key, mcp_project_columns + registry.py + agent_modules(); check(): поля проекта + settings.py tokens → {client, agents}; agent_token(), agent_keys(module), for_agent(conn, module); min_agent_version → jira + http.py + AGENT, agent_of(q) + db.py SCHEMA_VERSION 7, MIGRATIONS[6], agent_status по агенту + queue.py claim(conn, agent, …), agent_version(conn, agent), agent_view(conn, agent), has_active(conn, module=None) + release.py + module_dir(), migrate_layout() + projects.py только label/name/created_at + api.py Hub.agent_update по агентам, Hub.release_root, /agent?module, /agents, POST/PATCH /projects из полей модулей, do_PATCH + mcp.py register_core_tools(mcp, *, projects_doc, columns, project_create), create_project(), mcp_json(label, module) + cli.py tokens по агентам, agent publish --module +src/jira_hub/modules/jira/ + schema.py + jira_project; projects.py: доски из jira_project, migrate_v6 (перенос) + project_fields.py поле board_id, project_view (board_id, board_type, issues_count) +src/jira_hub/modules/wiki/ + __init__.py schema.py settings.py paths.py projects.py commands.py convert.py stage.py sync.py + search.py attachments.py api.py mcp.py tick.py +web/src/modules/wiki/ index.tsx, api/queries.ts, WikiPage.tsx, Tree.tsx, PageView.tsx, Search.tsx, RootForm.tsx, SyncStatus.tsx +web/src/projects/NewProjectDialog.tsx поля из MODULES[].projectFields +tests/fixtures/schema_v6.json копия baseline/schema.expected.json до Task 1 +tests/v6db.py make_v6_db(path) +``` + +--- + +### Task 1: Миграция v7 — токены и статус по агентам + +**Files:** +- Create: `tests/fixtures/schema_v6.json` (копия `tests/fixtures/baseline/schema.expected.json` на коммите до правок), `tests/v6db.py`, `tests/test_agents.py` +- Modify: `src/jira_hub/core/{db.py,settings.py,registry.py,modules.py,http.py,api.py,cli.py,queue.py}`, `src/jira_hub/modules/jira/{settings.py,api.py,__init__.py}`, `tests/fixtures/baseline/{schema,settings,routes}.expected.json` (переписать), затронутые тесты (`test_settings.py`, `test_api.py`, `test_cli.py`, `test_db.py` — обращения к `tokens["agent"]`, `agent_status id=1`) + +**Interfaces:** +- Produces: + - `Module.agent_min_version_key: str | None = None` (у jira — `"min_agent_version"`). + - `registry.agent_modules() -> list[Module]` — модули с `commands`, в порядке реестра. + - `settings.tokens(conn) -> {"client": str, "agents": {module: str}}`; `settings.agent_token(conn, module) -> str`; `settings.agent_keys(module: str) -> list[str]` (ключи модуля, затем `CORE_AGENT_KEYS = ["settings_version"]`); `settings.for_agent(conn, module) -> dict`. + - `http.AGENT = "_agent"`; `http.agent_of(q) -> str` — имя модуля агента запроса (диспетчер кладёт в `query[AGENT]` для scope `agent`, перезаписывая значение из адреса). + - Таблица `agent_status(agent TEXT PRIMARY KEY, last_seen_at, agent_version, settings_version, current_command_id, last_call_at)`; строки для `registry.agent_modules()` вставляет `db.migrate`. + - `tests/v6db.py: make_v6_db(path, *, agent_token="T-agent", client_token="T-client") -> None` — база v6 из `schema_v6.json`: `schema_version = 6`, `tokens = {agent, client}`, `agent_status` id=1 `agent_version = "14"`, проекты `AWPCA` (4216, `scrum`) и `sfm_kedr` (`NULL`, `NULL`), умолчания настроек v6. + +- [ ] **Step 1: Скопировать эталон схемы v6 в `tests/fixtures/schema_v6.json`, написать `tests/v6db.py`.** + +- [ ] **Step 2: Фикстуры второго модуля в `tests/conftest.py`.** `stub_module` — `Module(name="stub", commands=CommandHooks(types={"stub_cmd"}, read_types={"stub_cmd"}, …заглушки…), agent_keys=("stub_key",), settings_defaults={"stub_key": 1}, routes=(("POST", r"^/agent/stub$", h, "agent"),))`, подключается подменой `registry._cache` (`monkeypatch`). На её основе — `conn_two_modules` и `hub_server_two_modules`. Ими пользуются Task 1–3. + +- [ ] **Step 2a: Падающие тесты в `tests/test_agents.py`:** + +```python +def test_v6_upgrade_keeps_jira_agent(tmp_path): + make_v6_db(tmp_path / "hub.sqlite") + conn = db.connect(tmp_path / "hub.sqlite") + assert db._schema_version(conn) == 7 + tok = settings.tokens(conn) + assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok + row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone() + assert row["agent_version"] == "14" + assert list((tmp_path / "backups").glob("*schema-v6*")) # копия перед миграцией + +def test_agent_token_routes(hub_server): # hub_server — фикстура теста API (порт 0), как в test_api.py + # токен jira: /agent/claim 200, /agent/state 200; клиентский на /agent/claim — 401 +def test_module_agent_route_requires_own_module(hub_server_two_modules): + # заглушка второго модуля с commands (types={"stub_cmd"}) и маршрутом ("POST", r"^/agent/stub$", h, "agent"): + # его токеном POST /agent/state — 401; токеном jira POST /agent/stub — 401; своим — 200 +def test_agent_settings_per_module(hub_server_two_modules): + # токен jira: ключи jira + settings_version, без ключей заглушки и без board_id; токен заглушки — её ключи + settings_version +def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys): + # JIRA_HUB_HOME=tmp_path; `jira-hub tokens` → строки «клиентский токен: …», «агент jira: …» +``` + +- [ ] **Step 3: Запустить — FAIL** (`uv run pytest tests/test_agents.py -q`). + +- [ ] **Step 4: Реализовать.** + - `db.py`: `SCHEMA_VERSION = 7`; в `CORE_SCHEMA` новая `agent_status`; `MIGRATIONS[6] = [_tokens_v7, _agent_status_v7]`: + - `_tokens_v7`: `tokens {agent, client}` → `{client, agents: {jira: agent}}`; + - `_agent_status_v7`: пересоздание таблицы (CREATE `agent_status_new`, INSERT … SELECT `'jira'`, `last_jira_call_at` → `last_call_at`, DROP, RENAME). + - Шаги ядра не импортируют модули: имя `jira` в шаге миграции — данные истории (как `_migrate_projects`), допустимо по границе (исключение `core/db.py` в `test_core_has_no_jira_terms` уже есть). + - В конце `migrate` вместо `INSERT … (id) VALUES (1)` — по строке на `registry.agent_modules()`. + - `settings.py`: + - `ensure_defaults` создаёт `tokens` с `client` и токеном каждого агента и дописывает недостающие токены агентов при каждом вызове; + - `min_agent_version` уходит из `CORE_DEFAULTS` и `validate` в модуль jira (`DEFAULTS`, `validate_setting`, `AGENT_KEYS`), ключ и значение прежние; + - `board_id` удалить из `AGENT_KEYS` jira. + - `api.py`: + - `_COMPILED` хранит модуль-владелец маршрута (`None` у ядра); + - `_auth(scope, owner) -> str | None`: для `agent` ищет модуль по токену (`hmac.compare_digest` с каждым), владелец не `None` и не совпал — 401; возвращает имя модуля; + - `_dispatch` кладёт его в `query[http.AGENT]`; + - `h_agent_settings` → `settings.for_agent(conn, http.agent_of(q))`. + - `queue.py`: `agent_status` — `WHERE agent = ?` (пока везде `'jira'`, Task 2 заменит на агента запроса). + - `cli._print_tokens`: «клиентский токен: …», затем «агент <модуль>: …» по `agent_modules()`, затем «база: …». + +- [ ] **Step 5: Эталоны.** `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py`. Допустимые изменения: + - `schema` — версия 7 и `agent_status`; + - `settings` — `min_agent_version` переехал в ключи модуля, нет `board_id` в `agent_keys`. + + `mcp_tools` не меняется. Затем `uv run pytest -q` — PASS. + +- [ ] **Step 6: Commit** «Миграция v7: токены и статус по агентам модулей (#91)». + +--- + +### Task 2: Очередь, релиз и статус по агенту + +**Files:** +- Modify: `src/jira_hub/core/{queue.py,api.py,release.py,cli.py,paths.py}`, `src/jira_hub/modules/jira/{fetch.py,refresh.py,mcp.py}`, `tests/test_agents.py`, `tests/test_queue*.py`, `tests/test_release.py`, `tests/test_cli.py`, `tests/fixtures/baseline/routes.expected.json` + +**Interfaces:** +- Consumes: Task 1 — `http.agent_of`, `registry.agent_modules`, `agent_status` по агенту. +- Produces: + - `queue.claim(conn, agent: str, agent_version=None, settings_version=None, last_call_at=None, backup=None, files_dir=None)` — `_next`/`_redeliver` берут только типы `registry.module(agent).commands.types`; `current_command_id`, `last_seen_at` — строки агента. + - `queue.agent_version(conn, agent: str) -> str | None`; `queue.agent_view(conn, agent: str) -> dict` (поле `module`; `warning` по `Module.agent_min_version_key`; `redelivered`, `rate_limited_24h` — только команды типов модуля). + - `queue.has_active(conn, module: str | None = None) -> bool` — `None` как сейчас (любой модуль). + - `release.module_dir(root, module) -> Path` (`root/`, 0700); `release.migrate_layout(root) -> None` — перенос `root/agent.py`, `root/manifest.json` в `root/jira/`, если `root/jira/` нет; ошибка — `log.warning`, без исключения. + - `Hub.release_root: Path` (бывший `Hub.release_dir`), `Hub.agent_update: dict[str, dict]`, `Hub.note_agent_update(agent, body)`. + - `GET /agent?module=<имя>` (по умолчанию `jira`; неизвестный — 404 `not_found`), `GET /agents` → `{"agents": [view, …]}` в порядке `agent_modules()`. + - CLI `jira-hub agent publish [--module M] PATH` (по умолчанию `jira`; неизвестный или без команд — код 2, stderr «неизвестный модуль M; модули с агентами: jira, …»). + +- [ ] **Step 1: Падающие тесты** (`tests/test_agents.py`, дописать): + +```python +def test_wiki_queue_does_not_block_jira(conn_two_modules): + # пакет 1 stub_cmd, пакет 2 ensure_issue; claim(conn, "stub") → stub_cmd; claim(conn, "jira") → ensure_issue, + # хотя stub_cmd claimed; agent_status: current_command_id своих строк +def test_redeliver_only_own(conn_two_modules): # claimed stub_cmd не довыдаётся агенту jira +def test_agent_version_per_agent(conn_two_modules) # claim(conn, "jira", agent_version=14) не трогает строку stub +def test_release_per_module(tmp_path) # publish в module_dir(root,"stub") v1 при jira v14: claim агентов — свои версии +def test_release_layout_migration(tmp_path) # root/agent.py+manifest v14 → после Hub(...) — root/jira/, claim jira v14 +def test_publish_unknown_module(tmp_path, capsys) # код 2, stderr «неизвестный модуль nope» +def test_agents_endpoint(hub_server_two_modules) # GET /agents — два элемента с module; GET /agent — jira; GET /agent?module=nope — 404 +def test_v6_upgrade_keeps_jira_agent(...) # дописать: release/agent.py v14 в tmp → claim токеном T-agent отдаёт agent_release v14 +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Реализовать.** + - `h_claim` берёт агента из `q`, читает `body.get("last_call_at", body.get("last_jira_call_at"))`, релиз — `release.module_dir(hub.release_root, agent)`. + - `h_agent_release` — релиз своего агента. + - `Hub.__init__` вызывает `release.migrate_layout(self.release_root)`. + - Модуль jira: + - `fetch.py` — `agent_version(conn, "jira")`; + - `refresh.maybe_auto_refresh` — `has_active(conn, "jira")`; + - `mcp.py`: `batch_wait`/`agent_status` — `GET /agent` (как было). + - Реле не меняется. + +- [ ] **Step 4: Эталон `routes`** (`BASELINE_WRITE=1`). Допустимое изменение — новый маршрут `GET ^/agents$`. + + `uv run pytest -q` — PASS. + +- [ ] **Step 5: Commit** «Очередь, релиз и статус по агенту модуля (#91)». + +--- + +### Task 3: Поля проекта от модулей; доски — в модуль jira + +**Files:** +- Modify: `src/jira_hub/core/{modules.py,registry.py,projects.py,api.py,mcp.py,http.py}`, `src/jira_hub/modules/jira/{schema.py,projects.py,hooks.py,settings.py,api.py,mcp.py,__init__.py}` +- Create: `src/jira_hub/modules/jira/project_fields.py`, `tests/test_project_fields.py` +- Modify tests: `tests/test_projects.py`, `tests/test_api.py`, `tests/test_registry.py` (снять исключение строк `долг hub-modules` в `test_core_has_no_jira_terms`), `tests/conftest.py::add_project` (доска — в `jira_project`), эталоны `schema`, `routes` + +**Interfaces:** +- Produces: + ```python + @dataclass(frozen=True) + class ProjectField: + name: str # ключ в теле POST/PATCH и в GET /projects + validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение + editable: bool = False + # Module: + project_fields: tuple[ProjectField, ...] = () + project_view: Callable[[Connection, list[str]], dict[str, dict]] | None = None # label → {поле: значение} + on_project_created: Callable[[Connection, dict, dict, str], dict | None] | None = None # project, свои поля, author → пакет + on_project_updated: Callable[[Connection, dict, dict, str], list[dict]] | None = None # project, изменённые свои поля, author → пакеты + ``` + - Ядро: + - `projects.create(conn, label, name) -> dict`, `projects.validate(label, name)`, `projects.get`; + - `projects.list_projects(conn) -> list[dict]` — только `label`, `name`, `created_at`; + - `projects.ProjectError(field, reason)`, `ProjectExists` — прежние. + - `registry.check(mods, core_routes, core_settings)` дополнительно падает на поле проекта в двух модулях или с именем `label|name|created_at|author` («поле проекта <имя>: <модуль> и <модуль>»). + - `api.project_rows(conn, labels=None) -> list[dict]` — поля ядра плюс `project_view` всех модулей. + - `POST /projects` — неизвестное поле 422 `bad_project` «<поле>: неизвестное поле». + - `PATCH /projects/{label}` → `{"project": row, "batch_ids": [...]}`; неизменяемое поле — 422 `bad_project` «<поле> после создания не меняется». + - `core/mcp.py`: + - `register_core_tools(mcp, *, projects_doc: str, columns: list[tuple[str, Callable[[dict], str]]], project_create: Callable) -> None`; + - `create_project(label, name, fields: dict, module: str) -> str` — тело запроса и текст ответа прежние; + - `mcp_json(label, module="jira")` — у jira прежний фрагмент, у прочих — сервер `jira-hub-<модуль>` с аргументами `mcp <модуль> --project <метка>`; + - `fmt_projects(items, current, columns)`. + - Модуль jira: + - таблица `jira_project(label TEXT PRIMARY KEY REFERENCES project(label), board_id INTEGER, board_type TEXT)`; + - `projects.boards`, `projects.record_board_types`, `light_list` читают и пишут `jira_project`; + - шаг миграции `projects.migrate_v6(conn)` — ленивый импорт в `MIGRATIONS[6]`: копирует доски, пересоздаёт `project` без колонок досок; + - `project_fields.FIELDS = (ProjectField("board_id", validate_board, editable=False),)` — сообщение прежнее «ожидается целое > 0 или null»; + - `project_fields.view(conn, labels)` → `board_id`, `board_type`, `issues_count`; + - в `modules/jira/mcp.py` своя `project_create(label, name, board_id=None)` с прежними docstring и сигнатурой, она вызывает `core.create_project`; `register_core_tools(mcp, projects_doc=<прежний docstring projects>, columns=[("доска", …), ("тип доски", … «неизвестен»), ("задач", …)], project_create=project_create)`. + +- [ ] **Step 1: Падающие тесты** (`tests/test_project_fields.py`): + +```python +def test_v6_boards_moved(tmp_path): # make_v6_db → connect: jira_project AWPCA 4216 scrum, sfm_kedr NULL NULL; в project нет board_id +def test_projects_get_shape(api) # GET /projects: AWPCA {label,name,created_at,board_id:4216,board_type:"scrum",issues_count:…} +def test_post_unknown_field(api) # {label:"X",name:"X",board:5} → 422, detail содержит "board"; проекта X нет +def test_patch_board_forbidden(api) # PATCH /projects/AWPCA {board_id:1} → 422 «board_id после создания не меняется» +def test_patch_unknown_project(api) # PATCH /projects/NOPE {} → 404 +def test_field_conflict() # registry.check с двумя модулями-заглушками с полем "root" → RuntimeError, текст содержит "root" и оба имени +def test_mcp_jira_tools_unchanged() # список инструментов jira-сервера == baseline mcp_tools (уже в test_baseline — убедиться, что зелёный) +def test_mcp_json_per_module() # mcp_json("AWPCA") — прежний; mcp_json("AWPCA","wiki") → ключ "jira-hub-wiki", args [..., "mcp","wiki","--project","AWPCA"] +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Реализовать.** `Handler.do_PATCH` → `_dispatch("PATCH")` (спека `hub-api`: 501 — только методы кроме GET, POST, PUT, PATCH). Шаг `projects.migrate_v6` дописывается в конец `MIGRATIONS[6]`. Снять все метки `долг hub-modules` в `src/`; веб — Task 4. `test_core_has_no_jira_terms` без исключения для меток. `grep -rn "долг hub-modules" src` — пусто. + +- [ ] **Step 4: Эталоны** `schema` (`project` без досок, `jira_project`) и `routes` (`PATCH ^/projects/([^/]+)$`). `mcp_tools` не меняется. `uv run pytest -q` — PASS. + +- [ ] **Step 5: Commit** «Поля проекта от модулей, доски — в модуль jira (#91)». + +--- + +### Task 4: Веб — поля проекта модулей + +**Files:** +- Modify: `web/src/modules/types.ts`, `web/src/modules/jira/index.tsx`, `web/src/projects/NewProjectDialog.tsx`, `web/src/api/{types.ts,queries.ts}`, тесты рядом (`*.test.tsx`), `web/e2e/` (если создание проекта там проверяется) + +**Interfaces:** +- Produces: + - `HubModule.projectFields?: ProjectFieldDef[]`, где `type ProjectFieldDef = { name: string; label: string; hint: string; kind: "int" | "text" }`. + - Окно собирает поля из `MODULES.flatMap(m => m.projectFields ?? [])`. Пустое значение в тело не попадает, `int` — `Number(...)`. + - Тип `Project` — `{label, name, created_at} & Record`. Поля jira (`board_id`, `board_type`, `issues_count`) типизирует модуль jira. + - `useUpdateProject()` — `PATCH /projects/{label}`; понадобится в Task 10. + - У jira: `{name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int"}`. + +- [ ] **Step 1: Падающий тест** `NewProjectDialog.test.tsx`: доска пусто → тело `{label, name}` без `board_id`; доска «5120» → `board_id: 5120`. Поле с подписью «Номер доски Jira» есть. +- [ ] **Step 2: FAIL** (`cd web && npx vitest run src/projects`). +- [ ] **Step 3: Реализовать.** Метку `долг hub-modules` из диалога снять. Вёрстка окна — как сейчас. +- [ ] **Step 4:** `make check` и `cd web && npm run e2e` — PASS. +- [ ] **Step 5: Commit** «Веб: поля проекта от модулей (#91)». + +--- + +### Task 5: Модуль wiki — каркас, корень проекта, команды + +**Files:** +- Create: + - `src/jira_hub/modules/wiki/{__init__.py,schema.py,settings.py,paths.py,projects.py,commands.py,tick.py}`; + - `tests/test_wiki_projects.py`, `tests/test_wiki_commands.py`. +- Modify: + - `src/jira_hub/core/registry.py` (`mods = [jira.MODULE, wiki.MODULE]`); + - `pyproject.toml` (`markdownify`), `uv.lock`; + - эталоны `schema`, `settings`, `routes`; + - `tests/test_agents.py` (токен `wiki` появляется при миграции v6). + +**Interfaces:** +- Consumes: Task 1–3 (агенты, `ProjectField`, `on_project_*`). +- Produces: + - `schema.SQL`: + - `wiki_project(label PK REFERENCES project(label), root_code TEXT NOT NULL, root_title TEXT, synced_at TEXT, failed_at TEXT, pages_count INTEGER NOT NULL DEFAULT 0, sync_error TEXT)`; + - `wiki_page(label, code, eva_id, title, parent_code, orderno, depth, path, url, size, synced_at, PRIMARY KEY(label, code))`; + - `wiki_attachment(id INTEGER PK AUTOINCREMENT, label, page_code, url, name, state TEXT NOT NULL DEFAULT 'none', file, size, error, fetched_at, UNIQUE(label, page_code, url))`; + - `wiki_sync_stage(command_id, code, payload TEXT NOT NULL, PRIMARY KEY(command_id, code))`. + - `settings.DEFAULTS = {"wiki_pacing": 0.2, "wiki_auto_sync_hours": 24, "wiki_min_agent_version": 1}`, `AGENT_KEYS = ["wiki_pacing"]`. Проверки: + - `wiki_pacing` — число 0–10; + - `wiki_auto_sync_hours` — целое ≥ 0; + - `wiki_min_agent_version` — целое ≥ 0. + - `paths.root(conn) -> Path` — `Path(<файл базы из PRAGMA database_list>).parent / "wiki"`; `paths.project_dir(conn, label) -> Path`. + - `projects.parse_root(value) -> str | None` — `None`/`""` → `None`; строка с `DOC-\d+` (без учёта регистра, в том числе внутри ссылки) → `DOC-<цифры>` в верхнем регистре; иначе `ValueError("ожидается код DOC-… или ссылка на документ Eva")`. + - `projects.FIELDS = (ProjectField("wiki_root", parse_root, editable=True),)`. + - `projects.view(conn, labels)` → `wiki_root`, `wiki_root_title`, `wiki_pages`, `wiki_synced_at`, `wiki_sync_error` (`None`, если корня нет). + - `projects.on_created(conn, project, fields, author) -> dict | None` — корень задан: строка `wiki_project` и пакет `wiki_sync`. + - `projects.on_updated(conn, project, fields, author) -> list[dict]`: + - отменяет `queued` `wiki_sync` проекта (`queue.cancel_batch`); + - новый корень — обновляет `root_code`, ставит пакет; + - `None` — удаляет строки проекта (`wiki_project`, `wiki_page`, `wiki_attachment`, FTS) и каталог `paths.project_dir`. + - `commands.TYPES = {"wiki_sync", "wiki_fetch"}`, оба в `read_types`; параметры `wiki_sync {label: str, root: "DOC-…"}`, `wiki_fetch {attachment_id: int, url: str}`; `commands.HOOKS: CommandHooks` (пауза — 0; `check_batch` — проект пакета равен `label`). + - `commands.enqueue_sync(conn, label, root, author) -> dict` — пакет «Вики <метка>» с `project = label`; `commands.active_sync(conn, label) -> dict | None` — пакет `wiki_sync` проекта не в терминальном статусе. + - `tick.auto_sync_tick(hub, conn)`: + - по каждому `wiki_project`: `wiki_auto_sync_hours > 0`, с `max(synced_at, failed_at)` прошло больше часов, нет `active_sync` → `enqueue_sync(…, "hub")`; + - атрибут `label = "синхронизация вики"`. + - `MODULE = Module(name="wiki", agent_min_version_key="wiki_min_agent_version", …)`. + +- [ ] **Step 1: Падающие тесты:** + +```python +def test_parse_root(): + assert parse_root("https://eva.example.ru/project/Document/doc-001234") == "DOC-001234" + assert parse_root(" DOC-7 ") == "DOC-7" and parse_root(None) is None and parse_root("") is None + with pytest.raises(ValueError): parse_root("страница про статусы") +def test_post_project_with_root(api) # POST {label:"S",name:"S",wiki_root:"DOC-001234"} → 201; batch_id — refresh jira; в очереди wiki_sync {label:"S",root:"DOC-001234"} +def test_patch_root_existing(api) # PATCH /projects/AWPCA {wiki_root:"DOC-000777"} → 200, batch_ids из одного wiki_sync; GET /projects wiki_root +def test_patch_root_bad(api) # {wiki_root:"страница"} → 422, detail про wiki_root; корень прежний +def test_patch_root_cancels_queued(api) # корень DOC-12 + queued wiki_sync; PATCH DOC-21 → прежний пакет cancelled, новый {root:"DOC-21"} +def test_reset_root_removes_copy(api, tmp_path) # каталог wiki/AWPCA и строки есть; PATCH {wiki_root:None} → каталога и строк нет +def test_auto_sync_tick(conn) # synced_at 25 ч назад → пакет; 23 ч → нет; active_sync → нет; wiki_auto_sync_hours 0 → нет +def test_auto_refresh_ignores_wiki_batches(conn) # queued wiki_sync не мешает maybe_auto_refresh jira +def test_wiki_agent_token_after_upgrade(tmp_path) # make_v6_db → tokens.agents имеет jira=T-agent и новый wiki +``` + +- [ ] **Step 2: FAIL.** +- [ ] **Step 3: Реализовать.** `on_result`, `check_result` и прочие хуки результата пока пустые: Task 7 их заполнит. +- [ ] **Step 4: Эталоны** `schema`, `settings`, `routes` (новых маршрутов пока нет — `routes` не должен измениться). `uv run pytest -q` — PASS. +- [ ] **Step 5: Commit** «Модуль wiki: корень проекта, команды, фоновая синхронизация (#91)». + +--- + +### Task 6: Перевод HTML Eva в Markdown + +**Files:** +- Create: `src/jira_hub/modules/wiki/convert.py`, `tests/test_wiki_convert.py`, `tests/fixtures/wiki/*.html` (синтетические страницы) + +**Interfaces:** +- Produces: + - `convert.to_markdown(html: str, attachments: list[dict], links: list[dict]) -> tuple[str, list[dict]]`: + - вход — `attachments [{ref, url, name}]`, `links [{ref, code}]`, как в спеке; + - выход — Markdown и список вложений страницы `[{url, name}]` без повторов по `url`, в порядке первого появления. + - Правила замены: + - `ref` вложения → `[📎 ](attachment:@)`, где `n` — индекс в возвращённом списке; + - повторная ссылка на тот же `url` (миниатюра и файл) удаляется; + - `ref` ссылки → `[<текст ссылки или код>](page:)`; + - `img`/`a` с адресом, начинающимся на `static/` или `/static/`, удаляется; + - прочие ссылки остаются как есть; + - сырого HTML (тегов) в результате нет; + - заголовки ATX. + - `convert.fill_ids(markdown, ids: list[int]) -> str` — `attachment:@` → `attachment:`. + +- [ ] **Step 1: Фикстуры и падающие тесты.** Фикстуры — 3 синтетические страницы с реальными форматами адресов: + - картинки `/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A/image2026-7-27_14-20-6.png?version=1`; + - файл: миниатюра `…/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и ссылка `…/model.xlsx`; + - `Модель данных`; + - таблица; + - вложенные списки; + - внешняя ссылка `https://example.org`. + +```python +def test_image_becomes_attachment() # одна строка "[📎 image2026-7-27_14-20-6.png](attachment:@0)" +def test_thumbnail_and_file_one_link() # список вложений — 1 (model.xlsx); в тексте одна ссылка; "attach-file" нет +def test_page_link() # "[Модель данных](page:DOC-019834)" +def test_external_link_kept() # "(https://example.org)" есть +def test_no_raw_html() # re.search(r"<[a-zA-Z/]", md) is None +def test_table_kept() # строка с "|" и разделитель "---" +def test_fill_ids() # fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)" +``` + +- [ ] **Step 2: FAIL.** +- [ ] **Step 3: Реализовать** на `markdownify.MarkdownConverter` (подкласс с `convert_img`/`convert_a`). + - `ref` сравнивается со значением атрибута ровно, без нормализации. + - Неизвестные теги разворачиваются в текст. +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** «wiki: перевод HTML Eva в Markdown (#91)». + +--- + +### Task 7: Синхронизация — приём пачек, сборка копии, поиск + +**Files:** +- Create: `src/jira_hub/modules/wiki/{stage.py,sync.py,search.py}`, `tests/test_wiki_sync.py` +- Modify: `src/jira_hub/modules/wiki/{commands.py,__init__.py,api.py (создать: маршрут агента)}`, эталон `routes` + +**Interfaces:** +- Consumes: Task 5 (`paths`, `commands`, таблицы), Task 6 (`to_markdown`, `fill_ids`). +- Produces: + - Маршрут `POST /agent/wiki/pages` (scope `agent`, владелец `wiki`) → `stage.accept(conn, command_id, pages) -> {"accepted": n}`. + - Перевод `to_markdown` — до транзакции; в стадию кладётся JSON `{code, eva_id, title, parent_code, orderno, url, markdown, attachments}`. + - Ошибки: 409 `not_claimed` (команда не `claimed` или не `wiki_sync`); 422 `bad_pages` «: <поле>» (обязательные строки: `code`, `title`, `html`; `orderno` — целое или `null`; `parent_code` — строка или `null`). + - `sync.build_copy(stage_pages: list[dict], codes: list[str], target: Path, ids: dict[tuple[str, str], int]) -> list[dict]`: + - строит дерево по `parent_code`, корень — `codes[0]`; + - соседи сортируются по `(orderno is None, orderno, code)`; + - каталоги `NN_`, у корня без номера; + - `safe_name` — как в `eva_wiki_export.py`: `[<>:"/\\|?*\x00-\x1f]` → `_`, обрезка 80 символов, `.` и пробелы по краям убираются, пустое → `untitled`; + - совпадения у соседей — `_2`, `_3`; `..` исключён; + - каждый путь проверяется: он внутри `target`; + - пишет `index.md` (frontmatter `code`, `title`, `url`, `synced_at`; `# title`; текст) и `_tree.json`; + - права каталогов 0700; + - возвращает строки `wiki_page`. + - `sync.apply(conn, cmd, result) -> None` — вызывается из `CommandHooks.on_result` при `done`: + 1. Проверки: + - `params.root` ≠ текущий `root_code` или корня нет → `result.discarded = "корень сменился"`, ничего не меняется; + - код из `codes` без стадии → статус `failed` «нет страницы <код>». Эту проверку делает `check_result`. + 2. Выделение `id` вложений: существующие по `(label, page_code, url)` сохраняются, новые вставляются. Это делается в той же транзакции, что и запись; сборка копии идёт в `wiki/.