# Модуль 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/.