docs(plan): план wiki-module (#91)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b3ec23b9a2
commit
f44993c73f
603
docs/superpowers/plans/2026-10-24-wiki-module.md
Normal file
603
docs/superpowers/plans/2026-10-24-wiki-module.md
Normal file
@ -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 <noreply@anthropic.com>`. Каждая задача заканчивается зелёным `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/<module>`, 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<string, unknown>`. Поля 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` вложения → `[📎 <name>](attachment:@<n>)`, где `n` — индекс в возвращённом списке;
|
||||
- повторная ссылка на тот же `url` (миниатюра и файл) удаляется;
|
||||
- `ref` ссылки → `[<текст ссылки или код>](page:<code>)`;
|
||||
- `img`/`a` с адресом, начинающимся на `static/` или `/static/`, удаляется;
|
||||
- прочие ссылки остаются как есть;
|
||||
- сырого HTML (тегов) в результате нет;
|
||||
- заголовки ATX.
|
||||
- `convert.fill_ids(markdown, ids: list[int]) -> str` — `attachment:@<n>` → `attachment:<ids[n]>`.
|
||||
|
||||
- [ ] **Step 1: Фикстуры и падающие тесты.** Фикстуры — 3 синтетические страницы с реальными форматами адресов:
|
||||
- картинки `/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/image2026-7-27_14-20-6.png?version=1`;
|
||||
- файл: миниатюра `…/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и ссылка `…/model.xlsx`;
|
||||
- `<a href="project/Document/DOC-019834">Модель данных</a>`;
|
||||
- таблица;
|
||||
- вложенные списки;
|
||||
- внешняя ссылка `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>: <поле>» (обязательные строки: `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>`, у корня без номера;
|
||||
- `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/.<label>.new/` до коммита. Из-за этого `apply` разбит на `prepare`, вне транзакции, и `commit`, в `on_result`. Точное разбиение — решение исполнителя, с условием: `markdownify` и запись файлов не идут под `BEGIN IMMEDIATE`.
|
||||
3. Подмена каталога: `wiki/<label>` → `wiki/.<label>.old`, `.new` → `<label>`, удаление `.old`.
|
||||
4. `wiki_page` и FTS пишутся заново; пропавшие вложения удаляются.
|
||||
5. Запись `synced_at`, `pages_count`, `root_title`; `sync_error` и `failed_at` обнуляются; стадия команды очищается.
|
||||
- `sync.on_failed_or_reported_failed(conn, cmd, error)` — `sync_error`, `failed_at`, очистка стадии.
|
||||
- Повторная выдача (`on_claim` с `redelivery=True`) очищает стадию команды.
|
||||
- `sync.finish_pending_swaps(root: Path) -> None` — вызывается из `tick` модуля при первом тике:
|
||||
- есть `.<label>.new` и нет `<label>` → переименовать;
|
||||
- есть `.<label>.old` → удалить.
|
||||
- `search.ensure(conn) -> bool`: `CREATE VIRTUAL TABLE IF NOT EXISTS wiki_page_fts USING fts5(label UNINDEXED, code UNINDEXED, title, body, tokenize='unicode61 remove_diacritics 2')`; `OperationalError` → `False` и одно `log.warning`.
|
||||
- `search.replace(conn, label, pages: list[(code, title, markdown)])`.
|
||||
- `search.query(conn, label, q, limit) -> list[{code, title, path, snippet}]`:
|
||||
- слова запроса превращаются в `"<слово>"*` через AND, кавычки в слове экранируются;
|
||||
- `snippet(...)` по `body`;
|
||||
- без FTS — `LIKE` по файлам `index.md` проекта.
|
||||
|
||||
- [ ] **Step 1: Падающие тесты** (`tests/test_wiki_sync.py`; агента имитирует тест через HTTP на порту 0):
|
||||
|
||||
```python
|
||||
def test_first_sync(api) # DOC-1 корень, DOC-2, DOC-3 дети; пачка + done → 3 index.md, GET /projects wiki_pages 3, root_title
|
||||
def test_failed_keeps_copy(api) # вторая синхронизация failed "Eva: HTTP 503" → копия и поиск прежние, wiki_sync_error
|
||||
def test_missing_page_fails(api) # codes [DOC-1, DOC-2], пачка только DOC-1 → failed «нет страницы DOC-2», копия прежняя
|
||||
def test_page_removed(api) # было DOC-1..3, стало DOC-1, DOC-3 → каталога DOC-2 нет, поиск не находит, вложения удалены
|
||||
def test_attachment_id_survives(api) # вложение 7 ready; та же страница и url → id 7, state ready
|
||||
def test_stale_root_discarded(api) # wiki_sync {root DOC-12} выдан, корень → DOC-21, done → копия прежняя, result.discarded
|
||||
def test_redelivery_clears_stage(api) # пачка, повторная выдача, done без новых пачек → failed «нет страницы …»
|
||||
def test_pages_wrong_agent(api) # токеном jira POST /agent/wiki/pages → 401
|
||||
def test_pages_not_claimed(api) # команда queued → 409 not_claimed
|
||||
def test_bad_page(api) # страница без title → 422 bad_pages "DOC-2: title"
|
||||
def test_layout_names_safe(tmp_path) # названия "../x", "a/b", "😀"*200, два "Статусы" → все пути внутри target; "Статусы", "Статусы_2"
|
||||
def test_finish_pending_swap(tmp_path) # есть .AWPCA.new и нет AWPCA → после finish — AWPCA; .AWPCA.old удалён
|
||||
def test_search_prefix(conn) # страница с «статус «На согласовании»» находится по «согласов»; кавычка в запросе не роняет
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализовать.**
|
||||
- [ ] **Step 4: Эталон `routes`:** допустимое изменение — `POST ^/agent/wiki/pages$`. `uv run pytest -q` — PASS.
|
||||
- [ ] **Step 5: Commit** «wiki: синхронизация — пачки страниц, копия, поиск (#91)».
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Маршруты вики и вложения по запросу
|
||||
|
||||
**Files:**
|
||||
- Create: `src/jira_hub/modules/wiki/attachments.py`, `tests/test_wiki_api.py`
|
||||
- Modify: `src/jira_hub/modules/wiki/{api.py,commands.py,__init__.py}` (`file_refs`), эталон `routes`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 5, Task 7.
|
||||
- Produces (клиентский токен; проект — `project=<метка>`, неизвестный → 404, без корня → 409 `no_root`):
|
||||
- `GET /wiki/status` → `{root, root_title, synced_at, pages, error, batch: {id, status} | null}`.
|
||||
- `GET /wiki/tree` → `{"pages": [{code, title, depth, parent_code, size}]}`.
|
||||
- `GET /wiki/pages/{code}` → `{code, title, path, url, synced_at, markdown, attachments: [{id, name, state, size}]}`; страницы нет → 404.
|
||||
- `GET /wiki/search` с `q` и `limit` (1–50, по умолчанию 10; пустой `q` → 422 `bad_query`) → `{"results": [...]}`.
|
||||
- `POST /wiki/sync {project, author?}` → 201 `{batch_id}`; идёт синхронизация → 409 `sync_in_progress` с `batch_id` в `detail`.
|
||||
- Вложения:
|
||||
- `POST /wiki/attachments/{id}/fetch` → состояние `{id, name, page_code, url, state, size, error}`, при новой постановке 202;
|
||||
- `GET /wiki/attachments/{id}`;
|
||||
- `GET /wiki/attachments/{id}/content` → `Raw` с именем файла; не `ready` — 409 `not_ready`.
|
||||
- `attachments.request_fetch(conn, attachment_id, author) -> dict` — одна транзакция:
|
||||
- `ready` → как есть;
|
||||
- `fetching` → как есть;
|
||||
- иначе `state = fetching` и пакет `wiki_fetch`.
|
||||
- Хуки результата `wiki_fetch`:
|
||||
- `check_result` → файла `result.file` нет в `files/` или размер не совпал → `failed`;
|
||||
- `on_result` → `ready`, `file`, `size`, `fetched_at` либо `failed` с `error`;
|
||||
- `on_failed` → `failed`.
|
||||
- `file_refs(conn)` → `file` вложений в `ready`.
|
||||
|
||||
- [ ] **Step 1: Падающие тесты:**
|
||||
|
||||
```python
|
||||
def test_status_tree_page(api) # после синхронизации из Task 7: tree — 3 узла в порядке обхода; page DOC-3 — markdown и attachments
|
||||
def test_no_root(api) # sfm_kedr: /wiki/tree → 409 no_root
|
||||
def test_sync_in_progress(api) # второй POST /wiki/sync → 409 sync_in_progress, batch_id прежний
|
||||
def test_fetch_flow(api) # fetch → 202 fetching; агент POST /agent/files + done {file,size} → ready; content → байты
|
||||
def test_fetch_too_big(api) # агент failed «файл больше 10 МБ — откройте в Eva» → state failed, content → 409 not_ready
|
||||
def test_fetch_size_mismatch(api) # done с size ≠ фактическому → failed
|
||||
def test_file_refs_keep_ready(api) # files.sweep не удаляет файл ready-вложения
|
||||
def test_search_empty_query(api) # q="" → 422
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL. Step 3: Реализовать.**
|
||||
- [ ] **Step 4: Эталон `routes`** — только новые маршруты `/wiki/*`. PASS.
|
||||
- [ ] **Step 5: Commit** «wiki: маршруты и вложения по запросу (#91)».
|
||||
|
||||
---
|
||||
|
||||
### Task 9: MCP вики
|
||||
|
||||
**Files:**
|
||||
- Create: `src/jira_hub/modules/wiki/mcp.py`, `tests/test_wiki_mcp.py`, `tests/fixtures/baseline/mcp_wiki_tools.expected.json` (эталон через `BASELINE_WRITE=1`, тест в `tests/test_baseline.py::test_mcp_wiki_tools`)
|
||||
- Modify: `src/jira_hub/modules/wiki/__init__.py` (`mcp_server`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `core/mcp` (`guard`, `scoped`, `register_core_tools`, `create_project`, `client`, `PROJECT`), маршруты Task 8.
|
||||
- Produces:
|
||||
- `FastMCP("jira-hub-wiki")`.
|
||||
- Инструменты по таблице из спеки `mcp`, «MCP вики»: `tree`, `page`, `search`, `attachment`, `sync`, `status`, `root_set`, затем общие `projects`, `project_create`.
|
||||
- Тексты отказов дословно:
|
||||
- «у проекта <метка> нет корня вики: root_set DOC-…»;
|
||||
- «вики проекта ещё не синхронизирована: sync».
|
||||
- `page`:
|
||||
- порция 20 000 символов;
|
||||
- последняя строка «продолжение: offset=<N>», если текст не кончился;
|
||||
- поиск по `title`: точное совпадение без учёта регистра, иначе до 10 кандидатов «<код> <название>», поиск через `GET /wiki/tree`.
|
||||
- `attachment`:
|
||||
- ждёт до 60 с, опрос раз в 2 с;
|
||||
- готово — копия `downloads/wiki-<id>/<имя>` (каталог 0700, как у jira) и путь;
|
||||
- иначе — состояние и подсказка повторить.
|
||||
- `sync(wait=False)`: при `wait` ждёт пакет до 300 с.
|
||||
- `status` дописывает агента `wiki` из `GET /agent?module=wiki` в формате `fmt_agent` модуля jira. `fmt_agent` переносится в `core/mcp.py` как общий, тексты не меняются: эталон `mcp_tools` jira не меняется.
|
||||
|
||||
- [ ] **Step 1: Падающие тесты** (клиент хаба подменяется, как в `tests/test_mcp.py`):
|
||||
|
||||
```python
|
||||
def test_unbound_tools_refuse() # без --project: tree → NOT_BOUND
|
||||
def test_no_root_message() # проект без корня: tree → «у проекта sfm_kedr нет корня вики: root_set DOC-…»; запроса /wiki/tree нет
|
||||
def test_page_chunks() # markdown 45 000 → первые 20 000 + «продолжение: offset=20000»; offset 40000 → без строки продолжения
|
||||
def test_page_by_title_candidates() # title «Стат» → кандидаты с кодами
|
||||
def test_jira_server_unchanged() # baseline mcp_tools (jira) совпадает
|
||||
def test_mcp_wiki_tools() # эталон mcp_wiki_tools.expected.json
|
||||
def test_run_wiki_module() # core.mcp.run("wiki", "AWPCA") поднимает сервер jira-hub-wiki (без stdio: проверить registry.module("wiki").mcp_server().name)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
|
||||
- [ ] **Step 5: Commit** «wiki: MCP-сервер jira-hub-wiki (#91)».
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Веб — страница «Вики»
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/modules/wiki/{index.tsx,api/queries.ts,WikiPage.tsx,Tree.tsx,PageView.tsx,Search.tsx,RootForm.tsx,SyncStatus.tsx,wiki.css}` и тесты рядом, `web/e2e/wiki.spec.ts`
|
||||
- Modify: `web/src/modules/index.ts` (`MODULES = [jiraModule, wikiModule]`), `web/package.json` (+`react-markdown`, `remark-gfm`), `web/scripts/test-hub.sh`/`web/scripts/seed-hub.py` (засеять копию вики для e2e: корень и 3 страницы через `POST /agent/wiki/pages` токеном агента `wiki`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: маршруты Task 8, `useUpdateProject` (Task 4), `HubModule`.
|
||||
- Produces:
|
||||
- `wikiModule: HubModule`:
|
||||
- страница `/app/wiki`, пункт панели «Вики»;
|
||||
- `projectFields: [{name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text"}]`;
|
||||
- `SidebarStatus` — ход синхронизации текущего проекта (опрос `GET /wiki/status` раз в 3 с, пока `batch` не терминальный).
|
||||
- Поведение — по требованию спеки `web-app`, «Вики проекта»:
|
||||
- страница выбирается параметром `page`;
|
||||
- ссылки `page:` → `?project=…&page=<код>`;
|
||||
- ссылки `attachment:` → загрузка как у вложений карточки задачи: `POST …/fetch`, опрос `GET /wiki/attachments/{id}`, затем `api.blob` на `/content`;
|
||||
- Markdown — `react-markdown` + `remark-gfm`, без `rehype-raw`.
|
||||
- Дизайн — скилл `aura-design`, общие компоненты `web/src/ui/`, цвета только токенами.
|
||||
|
||||
- [ ] **Step 1: Падающие тесты** (Vitest):
|
||||
|
||||
```tsx
|
||||
test("ссылка page: открывает страницу") // клик по [Модель данных](page:DOC-2) → адрес ?project=AWPCA&page=DOC-2, справа DOC-2, узел выделен
|
||||
test("без корня — форма привязки") // status 409 no_root → форма; ввод DOC-001234 → PATCH /projects/sfm_kedr {wiki_root}
|
||||
test("поиск заменяет дерево") // ввод «согласов» → результаты GET /wiki/search; очистка → дерево
|
||||
test("сырой HTML не исполняется") // markdown с "<script>" показан текстом, script в DOM нет
|
||||
test("ошибка синхронизации видна") // status.error → плашка с текстом
|
||||
test("поле корня в окне нового проекта") // NewProjectDialog: поле «Корень вики»; ввод → wiki_root в теле
|
||||
```
|
||||
|
||||
e2e (`web/e2e/wiki.spec.ts`): открыть `/app/wiki?project=AWPCA`, увидеть дерево из 3 страниц, открыть страницу, найти по слову.
|
||||
|
||||
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4:** `make check`, `cd web && npm run e2e` — PASS.
|
||||
- [ ] **Step 5: Commit** «Веб: страница «Вики» (#91)».
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Граница, документация, эталоны — финал
|
||||
|
||||
**Files:**
|
||||
- Modify: `ARCHITECTURE.md`, `CLAUDE.md` (репозитория хаба), `README.md`, `tests/test_registry.py` (если нужно), `openspec/changes/wiki-module/design.md` (только раздел «Decisions made without discussion» — дописывает контроллер)
|
||||
|
||||
- [ ] **Step 1: Проверки:**
|
||||
- `grep -rn "долг hub-modules" src web/src` — пусто;
|
||||
- `test_core_has_no_jira_terms` — без исключений для меток долга;
|
||||
- новый тест `test_core_has_no_module_terms`: в `src/jira_hub/core/` нет `wiki_`, `wiki/`, `DOC-`, `eva`, кроме `core/registry.py`.
|
||||
- [ ] **Step 2: Документация:**
|
||||
- `ARCHITECTURE.md`:
|
||||
- агенты модулей;
|
||||
- поля проекта;
|
||||
- модуль `wiki` — таблица файлов, как у jira, и потоки «Синхронизация вики», «Вложение вики»;
|
||||
- раздел «Границы» без долга;
|
||||
- открытые вопросы: запись в вики, история, переименование.
|
||||
- `CLAUDE.md`:
|
||||
- данные `wiki/`;
|
||||
- команды `jira-hub mcp wiki`, `agent publish --module`, `tokens`.
|
||||
- `README.md` — подключение `jira-hub-wiki` в `.mcp.json` и установка eva-agent («отдельный репозиторий aep/eva-agent»).
|
||||
- [ ] **Step 3:** `make check` и `cd web && npm run e2e` — PASS; `openspec validate wiki-module --strict` — valid.
|
||||
- [ ] **Step 4: Commit** «Документация: модуль wiki и агенты модулей (#91)».
|
||||
Loading…
x
Reference in New Issue
Block a user