docs(plan): план hub-modules (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4a5d6dbb90
commit
05352ff5e5
489
docs/superpowers/plans/2026-10-23-hub-modules.md
Normal file
489
docs/superpowers/plans/2026-10-23-hub-modules.md
Normal file
@ -0,0 +1,489 @@
|
||||
# Ядро и модуль jira (hub-modules) — план реализации
|
||||
|
||||
> **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:** разделить пакет `jira_hub` и веб-приложение на ядро и модуль `jira` с явным контрактом подключения, не меняя
|
||||
внешнего поведения.
|
||||
|
||||
**Architecture:** Python-пакет делится на `src/jira_hub/core/` и `src/jira_hub/modules/jira/`; модуль описывается
|
||||
объектом `Module` (`core/modules.py`), список модулей — `core/registry.py` (импорт модуля ленивый). Очередь, HTTP,
|
||||
настройки, MCP и CLI ядра зовут обработчики модуля по типу команды или по списку модулей. Веб: `web/src/modules/jira/`
|
||||
плюс описание модуля, оболочка собирает страницы и панель из `web/src/modules/index.ts`. Первая задача фиксирует
|
||||
снимки внешнего поведения (golden), дальше каждый шаг обязан их сохранить.
|
||||
|
||||
**Tech Stack:** Python 3.13 (uv, pytest, stdlib http.server, SQLite, пакет `mcp<2`), React 19 + TypeScript + Vite,
|
||||
Vitest, Playwright.
|
||||
|
||||
**Spec:** `openspec/changes/hub-modules/` — `design.md` (решения 1–13), `proposal.md`, `specs/mcp/spec.md`,
|
||||
`specs/modules/spec.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Внешнее поведение не меняется: адреса, коды, тела ответов HTTP API; инструменты MCP (имена, описания, схемы
|
||||
параметров, тексты ответов); адреса веба `/app/board`, `/app/issues`, `/app/feed`, `/app/issues/{key}`; ключи
|
||||
`localStorage` (`jira-hub-sidebar`, ключи доски); протокол агента `/agent/*`.
|
||||
- Схема базы остаётся v6 (`SCHEMA_VERSION = 6`), таблицы не переименовываются, файлы в каталоге данных не переносятся.
|
||||
- Имя пакета `jira_hub`, точка входа `jira-hub`, имя MCP-сервера `jira-hub`, каталог сборки веба `src/jira_hub/web/app/`
|
||||
— не меняются.
|
||||
- Ядро (`src/jira_hub/core/`) не импортирует `jira_hub.modules.*`, кроме `core/registry.py` (ленивый импорт внутри
|
||||
функции) и шагов истории миграций в `core/db.py` (ленивый импорт внутри шага).
|
||||
- Новые имена в ядре — без «jira».
|
||||
- Тесты меняют только импорты, обращения к перенесённым именам и места подстановки зависимостей (`monkeypatch` по
|
||||
имени модуля, атрибуты `Hub`);
|
||||
утверждения (`assert`) существующих тестов не меняются. Эталоны `tests/fixtures/baseline/*.expected.json` после
|
||||
Task 1 не меняются.
|
||||
- Каждая задача заканчивается зелёным `uv run pytest -q` (весь набор); задачи, трогающие `web/`, — ещё `make check`.
|
||||
- Места долга (design, решение 12) помечаются комментарием `# долг hub-modules: проекты → модуль jira (change wiki)`
|
||||
(в TS — `// долг hub-modules: …`).
|
||||
- Язык комментариев и коммитов — русский; коммит заканчивается строкой
|
||||
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Хаб стартует как процесс (`jira-hub serve`, `jira-hub mcp`) без циклического импорта — в тестах всё уже импортировано,
|
||||
и цикл виден только в чистом интерпретаторе. → Task 2 (тест `test_fresh_imports`).
|
||||
2. Запись `.mcp.json` awp-ca (`jira-hub mcp --project AWPCA`) поднимает тот же сервер; `project_create` отдаёт фрагмент
|
||||
`.mcp.json` с прежним путём к репозиторию (`HUB_ROOT` после переноса файла). → Task 6.
|
||||
3. Собранное веб-приложение находится там же (`WEB_APP` после переноса `api.py`). → Task 5.
|
||||
4. Старая база (v1…v5) мигрирует до v6 после сборки схемы из модулей. → Task 3 (существующие тесты `test_db.py` +
|
||||
сверка схемы с эталоном).
|
||||
5. Отчёт агента по `refresh`/`fetch_attachment`/`ensure_issue` (в том числе поздний — после отмены) даёт те же записи в
|
||||
`sync_run`, `attachment_file`, заметках и тот же `reconcile`. → Task 4 (существующие тесты `test_queue.py`).
|
||||
|
||||
---
|
||||
|
||||
## Структура файлов (итог)
|
||||
|
||||
```
|
||||
src/jira_hub/
|
||||
__init__.py
|
||||
cli.py # тонкая точка входа: from .core.cli import main (pyproject не меняется)
|
||||
web/app/ # сборка (не в git), место прежнее
|
||||
core/
|
||||
__init__.py
|
||||
paths.py backup.py files.py relay.py release.py client.py # перенос как есть (files.sweep — решение 12)
|
||||
errors.py # CommandError, HttpMapped
|
||||
modules.py # Module, CommandHooks, Route
|
||||
registry.py # modules(), module_of(), command_types(), check()
|
||||
db.py # ядро схемы + сборка schema(), история MIGRATIONS
|
||||
settings.py # ключи ядра + объединение с модулями
|
||||
projects.py # метка, название, проверка, список (+ долг: board_id, issues_count)
|
||||
queue.py # механика пакетов
|
||||
http.py # ApiError, Raw, общие помощники обработчиков
|
||||
api.py # Hub, Handler, маршруты ядра, tick
|
||||
mcp.py # client, guard, scoped, projects/project_create, run()
|
||||
cli.py # main(): init, serve, tokens, mcp, agent publish + подкоманды модулей
|
||||
modules/
|
||||
__init__.py
|
||||
jira/
|
||||
__init__.py # MODULE = Module(...)
|
||||
schema.py # SQL таблиц модуля
|
||||
settings.py # умолчания, проверка, ключи агента, init_from_config
|
||||
commands.py # перенос (SPEC, validate, substitute, check_confirmed, estimate → pause)
|
||||
hooks.py # CommandHooks модуля: check_batch, tail, deliver_params, on_claim, …
|
||||
projects.py # base_jql, boards, record_board_types, parse_legacy_labels, ensure_legacy, migrate_v3
|
||||
mirror.py history.py attachments.py importer.py # перенос
|
||||
fetch.py # request_fetch, TooLarge, AgentTooOld, FETCH_AGENT_VERSION
|
||||
refresh.py # is_full_refresh, record_state, maybe_auto_refresh (tick)
|
||||
api.py # маршруты модуля
|
||||
mcp.py # mcp = FastMCP("jira-hub") и инструменты модуля
|
||||
cli.py # подкоманда import
|
||||
web/src/
|
||||
modules/index.ts # export const MODULES: HubModule[] = [jiraModule]
|
||||
modules/types.ts # тип HubModule
|
||||
modules/jira/
|
||||
index.tsx # jiraModule
|
||||
board/ issues/ feed/ # перенос
|
||||
refresh/ # refresh.ts, RefreshProvider.tsx, refreshStatus.ts (+ тесты), DataStatus.tsx
|
||||
api/queries.ts # запросы модуля из api/queries.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Эталоны внешнего поведения
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_baseline.py`, `tests/fixtures/baseline/{mcp_tools,routes,settings,schema}.expected.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: тест `tests/test_baseline.py`, который в каждой следующей задаче меняет только строки импорта.
|
||||
|
||||
- [ ] **Step 1: Написать тест со сбором снимков**
|
||||
|
||||
Четыре теста, каждый сравнивает снимок с `tests/fixtures/baseline/<имя>.expected.json`; при переменной окружения
|
||||
`BASELINE_WRITE=1` — пишет эталон (так же, как устроены golden-тесты проекта: `tests/fixtures/<name>.expected.*`).
|
||||
- `test_mcp_tools`: `asyncio.run(mcp_server.mcp.list_tools())` → список `{name, description, inputSchema}` в порядке
|
||||
регистрации.
|
||||
- `test_routes`: `api.ROUTES` → список `[method, pattern, scope, handler.__name__]`, отсортированный по
|
||||
`(pattern, method)` (порядок может смениться при сборке из модулей; набор и сопоставление — нет).
|
||||
- `test_settings`: `{"defaults": settings.DEFAULTS, "agent_keys": settings.AGENT_KEYS, "protected": sorted(settings.PROTECTED)}`.
|
||||
- `test_schema`: свежая база в `tmp_path` через `db.connect` → `SELECT type, name, sql FROM sqlite_master WHERE name NOT
|
||||
LIKE 'sqlite_%' ORDER BY type, name` и `schema_version`.
|
||||
|
||||
- [ ] **Step 2: Записать эталоны и проверить**
|
||||
|
||||
Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && uv run pytest tests/test_baseline.py -q`
|
||||
Expected: 4 passed (второй прогон — сравнение с записанным).
|
||||
|
||||
- [ ] **Step 3: Весь набор зелёный, коммит**
|
||||
|
||||
Run: `uv run pytest -q` — Expected: всё passed.
|
||||
```bash
|
||||
git add tests/test_baseline.py tests/fixtures/baseline
|
||||
git commit -m "test: эталоны внешнего поведения перед перестройкой (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Пакеты core и modules.jira — перенос файлов без логики
|
||||
|
||||
**Files:**
|
||||
- Move (git mv): `paths.py backup.py files.py relay.py release.py client.py` → `src/jira_hub/core/`;
|
||||
`mirror.py history.py attachments.py commands.py importer.py` → `src/jira_hub/modules/jira/`;
|
||||
`db.py settings.py projects.py queue.py api.py mcp_server.py cli.py` → `src/jira_hub/core/` (временно целиком —
|
||||
разделяются в Task 3–7).
|
||||
- Create: `src/jira_hub/core/__init__.py`, `src/jira_hub/modules/__init__.py`, `src/jira_hub/modules/jira/__init__.py`
|
||||
(пустые), `src/jira_hub/cli.py` (`from .core.cli import main`), `tests/test_layout.py`.
|
||||
- Modify: импорты во всём `src/`, `tests/`, `web/scripts/seed-hub.py`; `api.WEB_APP`, `mcp_server.HUB_ROOT` — путь
|
||||
от нового места файла.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: модули по новым путям (`jira_hub.core.queue`, `jira_hub.modules.jira.mirror` …). `pyproject.toml`
|
||||
(`jira-hub = "jira_hub.cli:main"`) не меняется.
|
||||
|
||||
- [ ] **Step 1: Тест на чистый импорт и пути**
|
||||
|
||||
`tests/test_layout.py`:
|
||||
- `test_fresh_imports`: `subprocess.run([sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp_server, jira_hub.cli"])`
|
||||
→ `returncode == 0` (в Task 5–6 имя `core.mcp_server` заменится на `core.mcp` и `modules.jira.mcp`).
|
||||
- `test_web_app_path`: `api.WEB_APP == Path(jira_hub.__file__).parent / "web" / "app"`.
|
||||
- `test_hub_root`: `mcp_server.HUB_ROOT` — каталог, где лежит `pyproject.toml` проекта.
|
||||
|
||||
- [ ] **Step 2: Убедиться, что тест падает** — Run: `uv run pytest tests/test_layout.py -q` — Expected: FAIL (нет `jira_hub.core`).
|
||||
|
||||
- [ ] **Step 3: Перенести файлы `git mv`, поправить импорты**
|
||||
|
||||
Относительные импорты: из `core/*` к соседям — `from . import …`, к модулю jira — `from ..modules.jira import …` (это
|
||||
временно, такие импорты уберут Task 3–7). Из `modules/jira/*` — `from ...core import db, settings, …`. Поиск остатков:
|
||||
`grep -rn "jira_hub\.\(db\|queue\|mirror\|api\|settings\|projects\|history\|files\|commands\|attachments\|mcp_server\|relay\|release\|client\|paths\|backup\|importer\)\b" src tests web/scripts`
|
||||
— пусто. `monkeypatch.setattr("jira_hub.X…")` в тестах — на новые пути.
|
||||
|
||||
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed, включая `test_baseline.py` и `test_layout.py`.
|
||||
|
||||
- [ ] **Step 5: Коммит** — `git commit -m "refactor: пакеты core и modules.jira — перенос без логики (#87)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Контракт модуля, реестр, схема и настройки
|
||||
|
||||
**Files:**
|
||||
- Create: `core/errors.py`, `core/modules.py`, `core/registry.py`, `modules/jira/schema.py`, `modules/jira/settings.py`,
|
||||
`modules/jira/projects.py`, `tests/test_registry.py`
|
||||
- Modify: `core/db.py`, `core/settings.py`, `core/projects.py`, `modules/jira/__init__.py`, `modules/jira/commands.py`
|
||||
(`CommandError` — из `core.errors`), `core/cli.py` (`init`), `tests/test_baseline.py` (импорты)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`core/errors.py`):
|
||||
```python
|
||||
class CommandError(ValueError): ... # прежний commands.CommandError; commands реэкспортирует его
|
||||
class HttpMapped(Exception): # примесь: исключение модуля со своим кодом HTTP
|
||||
status: int = 500
|
||||
error: str = "internal"
|
||||
```
|
||||
- Produces (`core/modules.py`) — dataclass'ы `frozen=True`; поля, не нужные модулю, — по умолчанию:
|
||||
```python
|
||||
Route = tuple[str, str, Callable, str | None] # method, regex, handler(hub, conn, m, q, body), scope
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandHooks:
|
||||
types: frozenset[str]
|
||||
read_types: frozenset[str] # не пишут во внешнюю систему: без хвоста и копии базы
|
||||
validate: Callable[[list], list[dict]] # CommandError
|
||||
check_batch: Callable[[Connection, list[dict], str | None, Path | None], list[dict]] # project, files_dir
|
||||
tail: Callable[[list[dict]], dict | None] # хвостовая команда пишущего пакета
|
||||
pause: Callable[[Connection, str], int] # пауза типа, с
|
||||
deliver_params: Callable[[Connection, dict, Path | None], dict]
|
||||
temp_definer: tuple[str, str, str] | None # (тип, поле params с временным ключом, поле result с ключом)
|
||||
temp_keys_used: Callable[[dict], list[str]]
|
||||
substitute: Callable[[str, dict, dict], dict]
|
||||
drop_unresolved: Callable[[dict, list[str]], list[str]] # поправить params команды, вернуть неразрешённые
|
||||
retry_params: Callable[[dict], dict]
|
||||
check_confirmed: Callable[[Connection, dict], tuple[bool, dict | None]]
|
||||
on_claim: Callable[[Connection, dict, str, bool], None] # cmd, now, redelivery
|
||||
on_failed: Callable[[Connection, dict, str], None] # исчерпаны выдачи
|
||||
check_result: Callable[[Connection, dict, str, dict | None, Path | None], tuple[str, str | None]]
|
||||
on_result: Callable[[Connection, dict, str, dict | None, int, int, str, Path | None], None] # в транзакции
|
||||
after_result: Callable[[Connection, dict, str], None] # вне транзакции
|
||||
on_late_result: Callable[[Connection, dict, str, dict | None, int, int], bool]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Module:
|
||||
name: str
|
||||
schema: str = ""
|
||||
settings_defaults: dict = field(default_factory=dict)
|
||||
agent_keys: tuple[str, ...] = ()
|
||||
validate_setting: Callable[[str, object], bool] | None = None # True — ключ модуля проверен (ValueError — плохо)
|
||||
settings_view: Callable[[Connection], dict] | None = None # вычисляемые поля GET/PUT /settings
|
||||
on_init: Callable[[Connection, dict, bool], list[str]] | None = None # init --config: строки для вывода
|
||||
commands: CommandHooks | None = None
|
||||
routes: tuple[Route, ...] = ()
|
||||
tick: tuple[Callable[[Any, Connection], None], ...] = () # (hub, conn)
|
||||
file_refs: Callable[[Connection], set[str]] | None = None # sha файлов, нужных командам
|
||||
on_project_created: Callable[[Connection, dict, str], dict | None] | None = None # project, author → пакет
|
||||
mcp_server: Callable[[], Any] | None = None # FastMCP модуля
|
||||
cli_register: Callable[[Any], None] | None = None # subparsers
|
||||
cli_run: Callable[[Any], int | None] | None = None # None — не своя подкоманда
|
||||
```
|
||||
- Produces (`core/registry.py`): `_cache: list[Module] | None = None`; `modules() -> list[Module]` (при `_cache is None`
|
||||
— импорт `modules.jira` внутри функции и `check`, результат в `_cache`; тесты подменяют `_cache`);
|
||||
`module(name) -> Module | None`; `module_of(ctype: str) -> Module | None`; `check(mods: list[Module], core_routes,
|
||||
core_settings: dict) -> None` — `RuntimeError` с текстом конфликта: тип команды «тип refresh: модули jira и x»,
|
||||
ключ настроек, маршрут (метод + шаблон).
|
||||
- Produces (`core/db.py`): `CORE_SCHEMA` (`setting`, `project`, `batch`, `command`, `agent_status`); `schema() -> str`
|
||||
= `CORE_SCHEMA` + `schema` модулей по порядку; `connect`/`migrate` используют `schema()`. `MIGRATIONS` — на месте;
|
||||
шаги, зовущие код модуля, импортируют его внутри шага. `jira_utc` и `_fill_comment_utc` — в
|
||||
`modules/jira/mirror.py` (или `history.py`, где используется), шаг v4→v5 зовёт их лениво.
|
||||
- Produces (`core/settings.py`): `CORE_DEFAULTS` (`backup_keep`, `min_agent_version`, `port`, `settings_version`,
|
||||
`initialized`, `relay_dir`), `defaults() -> dict` (ядро + модули), `agent_keys() -> list[str]`; `validate` — сначала
|
||||
ключи ядра, затем `validate_setting` модулей, затем проверка по типу умолчания из `defaults()`; `get_all`,
|
||||
`ensure_defaults`, `for_agent` — по объединённому набору. `PROTECTED` — в ядре. Константы `DEFAULTS`/`AGENT_KEYS`
|
||||
остаются как вычисляемые значения для обратной совместимости тестов? — **нет**: тесты и эталон зовут `defaults()` и
|
||||
`agent_keys()` (меняется только вызов в `test_baseline.py`, эталон тот же).
|
||||
- Produces (`modules/jira/settings.py`): `DEFAULTS` (остальные ключи, включая `pacing`, `auto_refresh_minutes`,
|
||||
`project_key`, `closed_statuses`), `AGENT_KEYS`, `DEFAULT_PACING`, `pacing(conn)`, `validate_setting` (`base_jql`,
|
||||
`board_id` — отказ с прежними текстами, `pacing`, `auto_refresh_minutes`), `settings_view(conn) -> {"base_jql": …}`,
|
||||
`on_init(conn, cfg, force) -> list[str]` — прежние `settings.init_from_config` + `projects.ensure_legacy` и строки
|
||||
вывода (`перенесено: …`, `проект AWPCA «АРМ КА», доска N`).
|
||||
- Produces (`modules/jira/projects.py`): `base_jql`, `boards`, `record_board_types`, `parse_legacy_labels`,
|
||||
`ensure_legacy`, `migrate_v3`, `LEGACY_LABEL`, `LEGACY_NAME`. В `core/projects.py` остаются `validate`, `get`,
|
||||
`create`, `list_projects`, `light_list`, `labels`, `ProjectError`, `ProjectExists`; `board_id`/`board_type`/
|
||||
`issues_count` в них — с меткой долга.
|
||||
- `modules/jira/__init__.py`: `MODULE = Module(name="jira", schema=schema.SQL, settings_defaults=settings.DEFAULTS,
|
||||
agent_keys=tuple(settings.AGENT_KEYS), validate_setting=…, settings_view=…, on_init=…)` — остальные поля добавляют
|
||||
Task 4–6.
|
||||
|
||||
- [ ] **Step 1: Тесты реестра** — `tests/test_registry.py`:
|
||||
- `test_jira_registered`: `[m.name for m in registry.modules()] == ["jira"]`.
|
||||
- `test_command_type_conflict`: два `Module` с `CommandHooks(types=frozenset({"refresh"}), …)` → `check` бросает
|
||||
`RuntimeError`, в тексте `refresh`, `jira`, имя второго модуля.
|
||||
- `test_settings_conflict`: модуль с `settings_defaults={"port": 1}` → `RuntimeError` с `port`.
|
||||
- `test_route_conflict`: модуль с маршрутом `("GET", r"^/projects$", h, "client")` → `RuntimeError` с `/projects`.
|
||||
- `test_core_imports`: AST всех `src/jira_hub/core/*.py`, кроме `registry.py` и `db.py`, — нет импортов
|
||||
`modules`/`jira_hub.modules`; в `registry.py` и `db.py` такие импорты только внутри функций.
|
||||
- [ ] **Step 2: Тесты падают** — Run: `uv run pytest tests/test_registry.py -q` — Expected: FAIL (нет `registry`).
|
||||
- [ ] **Step 3: Реализовать `errors`, `modules`, `registry`, разделить `db`, `settings`, `projects`; `init` в
|
||||
`core/cli.py` зовёт `on_init` модулей и печатает их строки в прежнем порядке, затем токены.** Эталон схемы требует
|
||||
тот же набор таблиц и тот же SQL: порядок `CREATE` внутри `sqlite_master` сравнивается по имени, поэтому порядок
|
||||
сборки на эталон не влияет, а текст SQL таблиц переносится символ в символ.
|
||||
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed (`test_db.py` — миграции v1…v5,
|
||||
`test_settings.py`, `test_projects.py`, `test_cli.py`, эталоны).
|
||||
- [ ] **Step 5: Коммит** — `git commit -m "refactor: контракт модуля, реестр, схема и настройки по модулям (#87)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Очередь — механика в ядре, смысл типов в модуле
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jira/hooks.py`, `modules/jira/fetch.py`, `modules/jira/refresh.py`, `tests/test_queue_core.py`
|
||||
- Modify: `core/queue.py`, `core/files.py` (`sweep` берёт ссылки из `file_refs` модулей), `modules/jira/commands.py`
|
||||
(`estimate` уходит, пауза — `hooks.pause`), `modules/jira/__init__.py` (`commands=hooks.HOOKS`,
|
||||
`file_refs=hooks.file_refs`), `tests/test_queue.py`, `tests/test_files.py` (импорты: `request_fetch`, `TooLarge`,
|
||||
`AgentTooOld`, `maybe_auto_refresh`, `is_full_refresh`, `record_state` — из модуля jira)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CommandHooks`, `registry.module_of`, `registry.modules` (Task 3).
|
||||
- Produces (`core/queue.py`, публичное): `create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dict`
|
||||
(прежняя сигнатура), `insert_batch(conn, title, author, cmds, project=None) -> dict` (бывший `_insert_batch`),
|
||||
`claim`, `report_result`, `reconcile`, `cancel_batch`, `cancel_command`, `retry_command`, `batch_view`,
|
||||
`list_batches`, `get_command`, `agent_view`, `deliver_params(conn, cmd, files_dir) -> dict` (делегирует модулю),
|
||||
`agent_version(conn) -> str | None`, `has_active(conn) -> bool` (есть `queued`/`claimed`), `estimate(conn, types) ->
|
||||
int` (сумма `pause` модулей), `TERMINAL`, `MAX_DELIVERIES`, `ERROR_LIMIT`, `ONLINE_MINUTES`, `ClaimedError`,
|
||||
`NotClaimedError`.
|
||||
- Produces (`modules/jira/fetch.py`): `TooLarge(CommandError, HttpMapped)` с `status=413`, `error="too_large"`;
|
||||
`AgentTooOld(ValueError, HttpMapped)` с `status=409`, `error="agent_too_old"`; `FETCH_AGENT_VERSION = 14`;
|
||||
`request_fetch(conn, files_dir, attachment_id, author) -> dict` (прежнее поведение, через `queue.insert_batch`,
|
||||
`queue.agent_version`).
|
||||
- Produces (`modules/jira/refresh.py`): `is_full_refresh(conn, command_id) -> bool`, `record_state(conn, command_id,
|
||||
issues_count, links_count)`, `maybe_auto_refresh(conn) -> dict | None` (через `queue.agent_view`, `queue.has_active`,
|
||||
`queue.create_batch`), `auto_refresh_tick(hub, conn) -> None`.
|
||||
- Правила ядра:
|
||||
- Модуль пакета: модули известных типов команд; больше одного — `CommandError("команды разных модулей в одном
|
||||
пакете")`; ни одного — `validate` первого модуля реестра (он выдаёт прежнюю ошибку о неизвестном типе).
|
||||
- `create_batch`: `validate` → `check_batch(conn, cmds, project, files_dir)` (внутри модуля прежний порядок:
|
||||
`_check_project`, если `project` задан, затем `_check_setup`, затем `_assign_boards`) → `tail(cmds)` → `insert_batch`.
|
||||
- «Пакет с записями» в `claim` — есть команда, чей тип не в `read_types` своего модуля (SQL с типами из реестра,
|
||||
а не литералами).
|
||||
- Временные ключи: `_mapping`/`_definer_status` строятся по `temp_definer` модуля; `_next` зовёт `temp_keys_used`,
|
||||
`substitute`, `drop_unresolved` (у jira: `refresh` выбрасывает временные ключи из `keys`, если остались ключи —
|
||||
неразрешённых нет).
|
||||
- `report_result`: терминальная команда → `on_late_result`; иначе `check_result` (может сменить статус на `failed`
|
||||
и ошибку) → запись команды и `agent_status` → `on_result` (в той же транзакции) → после транзакции
|
||||
`after_result` (jira: `reconcile` после `refresh` `done`).
|
||||
- `files.sweep(conn, root, now=None)` — защищённые файлы = объединение `file_refs(conn)` модулей.
|
||||
- `retry_command` берёт `params` через `retry_params` (jira: без `board_id` у `ensure_sprint`).
|
||||
|
||||
- [ ] **Step 1: Тест ядра очереди на модуле-заглушке** — `tests/test_queue_core.py`: модуль `"t"` с типами `{"w", "r"}`
|
||||
(`r` — `read_types`), `pause` = 7, `tail` возвращает `{"type": "r", "params": {}}` для пакета с `w`; реестр
|
||||
подменяется через `monkeypatch.setattr(registry, "_cache", [jira.MODULE, stub])`.
|
||||
- `test_tail_and_estimate`: пакет `[w]` → две команды `w`, `r`; `estimate_seconds == 14`.
|
||||
- `test_mixed_modules`: пакет `[w, refresh]` → `CommandError`, текст «команды разных модулей в одном пакете».
|
||||
- `test_on_result_called`: `claim` → `report_result(done)` → заглушка `on_result` получила `status == "done"`,
|
||||
`after_result` вызван после фиксации (видит команду `done` в новом соединении).
|
||||
- `test_sweep_uses_file_refs`: файл старше 30 дней, `file_refs` заглушки возвращает его sha → не удалён.
|
||||
- [ ] **Step 2: Тесты падают** — Run: `uv run pytest tests/test_queue_core.py -q` — Expected: FAIL.
|
||||
- [ ] **Step 3: Перенести логику типов в `modules/jira/hooks.py`, `fetch.py`, `refresh.py`; `core/queue.py` — без
|
||||
`'refresh'`, `'ensure_*'`, `'fetch_attachment'`, `sync_run`, `issue_attachment`, `attachment_file`; без импортов
|
||||
`mirror`, `history`, `attachments`, `projects`-модуля.** Тексты ошибок и порядок проверок — прежние (их проверяет
|
||||
`test_queue.py`).
|
||||
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed (99 тестов `test_queue.py` без изменения
|
||||
утверждений).
|
||||
- [ ] **Step 5: Коммит** — `git commit -m "refactor: очередь ядра и обработчики команд модуля jira (#87)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: HTTP — сервер ядра, маршруты модуля
|
||||
|
||||
**Files:**
|
||||
- Create: `core/http.py`, `modules/jira/api.py`
|
||||
- Modify: `core/api.py`, `modules/jira/__init__.py` (`routes`, `tick`, `on_project_created`), `tests/test_api.py`,
|
||||
`tests/test_web_app.py`, `tests/test_baseline.py`, `tests/test_layout.py` (импорты; `hub.downloads_dir` — см. ниже)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`core/http.py`): `ApiError(status, error, detail=None)`, `Raw(content_type, data, headers=None)`,
|
||||
`int_group(m, i=1) -> int` (бывший `_int`), `int_param(q, name, default) -> int`, `not_found(obj, what)`,
|
||||
`project_param(conn, q) -> str | None`, `mapped(e: HttpMapped) -> ApiError`.
|
||||
- Produces (`core/api.py`): `Hub` (без `downloads_dir`; новое `hub.module_state: dict[str, dict]` — память модулей
|
||||
между тиками), `CORE_ROUTES`, `ROUTES` (вычисляется при импорте: `CORE_ROUTES` + `routes` модулей, после
|
||||
`registry.check`), `make_server`, `start`, `serve`, `WEB_APP` (путь прежний — `src/jira_hub/web/app`).
|
||||
`Hub.tick`: `sweep_files` ядра, затем `tick` модулей; сбой каждой работы логируется и не мешает остальным.
|
||||
Обработчики ядра, ловившие `queue.TooLarge`/`AgentTooOld`, ловят `HttpMapped` раньше `CommandError`.
|
||||
`GET`/`PUT /settings` дописывают `settings_view` модулей. `POST /projects`: в одной транзакции `projects.create` и
|
||||
`on_project_created` модулей; `batch_id` ответа — из первого вернувшего пакет.
|
||||
- Produces (`modules/jira/api.py`): обработчики и `ROUTES` модуля: `/agent/state`, `/agent/history`,
|
||||
`/agent/known-keys`, `/issues`, `/issues/{key}`, `/issues/{key}/note`, `/board`, `/changes`, `/attachments/…` (три),
|
||||
`/refresh`, `/sync-runs`. `downloads_tick(hub, conn)` — уборка `downloads/` (`<каталог базы>/downloads`) раз в
|
||||
сутки, время прошлой — `hub.module_state["jira"]["downloads_at"]`; `on_project_created(conn, project, author)` —
|
||||
пакет «Новый проект <метка>».
|
||||
- Маршруты ядра — все остальные из нынешней `ROUTES` (design, решение 6).
|
||||
|
||||
- [ ] **Step 1: Тест сборки маршрутов** — в `tests/test_registry.py`: `test_routes_assembled`:
|
||||
`{(m, p) for m, p, _, _ in api.ROUTES} == {(m, p) for m, p, _, _ in api.CORE_ROUTES} | {(m, p) for m, p, _, _ in jira.MODULE.routes}`
|
||||
и пересечения нет. Эталон `routes.expected.json` — тот же (в `test_baseline.py` меняется только импорт).
|
||||
- [ ] **Step 2: Тест падает** — Run: `uv run pytest tests/test_registry.py::test_routes_assembled -q` — Expected: FAIL.
|
||||
- [ ] **Step 3: Разделить `core/api.py`; тесты, читавшие `hub.downloads_dir`, берут путь `hub.db_path.parent / "downloads"`.**
|
||||
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed (`test_api.py`, `test_web_app.py`,
|
||||
`test_e2e_hub.py`, `test_layout.py::test_web_app_path`, эталоны).
|
||||
- [ ] **Step 5: Коммит** — `git commit -m "refactor: HTTP — сервер ядра, маршруты модуля jira (#87)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: MCP и CLI по модулям
|
||||
|
||||
**Files:**
|
||||
- Create: `core/mcp.py`, `modules/jira/mcp.py` (из `core/mcp_server.py`), `modules/jira/cli.py`
|
||||
- Delete: `core/mcp_server.py`
|
||||
- Modify: `core/cli.py`, `modules/jira/__init__.py` (`mcp_server`, `cli_register`, `cli_run`), `tests/test_mcp.py`,
|
||||
`tests/test_cli.py`, `tests/test_baseline.py`, `tests/test_layout.py` (импорты)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`core/mcp.py`): `HUB_ROOT` (каталог с `pyproject.toml`, как сейчас), `mcp_json(label) -> dict`,
|
||||
`unknown_project`, `guard`, `scoped`, `client()`, `bound_project`, `fmt_projects`, функции инструментов `projects()`,
|
||||
`project_create(label, name, board_id=None)` (долг: `board_id`), `register_core_tools(mcp) -> None`,
|
||||
`run(module_name: str, project: str | None) -> int` — неизвестный модуль: печать в stderr
|
||||
`неизвестный модуль <имя>; модули: jira`, код 2, сервер не запускается.
|
||||
- Produces (`modules/jira/mcp.py`): `mcp = FastMCP("jira-hub")`, инструменты модуля на нём в прежнем порядке, затем
|
||||
`register_core_tools(mcp)` — `projects`, `project_create` последними, как сейчас.
|
||||
- CLI: `jira-hub mcp [module] [--project <метка>]` — позиционный `module` с `nargs="?"`, `default="jira"`, без
|
||||
`choices` (проверка в `run`, текст из спеки). `import` — подкоманда модуля (`cli_register`/`cli_run`).
|
||||
`agent publish`, `init`, `serve`, `tokens` — ядро.
|
||||
|
||||
- [ ] **Step 1: Тесты** — в `tests/test_cli.py`:
|
||||
- `test_mcp_default_module`: `main(["mcp", "--project", "AWPCA"])` и `main(["mcp", "jira", "--project", "AWPCA"])`
|
||||
при подменённом `mcp.run` (как в `test_mcp.py`) — оба запускают объект `modules.jira.mcp.mcp`, привязка — `AWPCA`.
|
||||
- `test_mcp_unknown_module`: `main(["mcp", "nope"]) == 2`, в stderr `неизвестный модуль nope` и `jira`.
|
||||
- [ ] **Step 2: Тесты падают** — Run: `uv run pytest tests/test_cli.py -q` — Expected: FAIL.
|
||||
- [ ] **Step 3: Разделить MCP и CLI.**
|
||||
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed (эталон `mcp_tools.expected.json` тот же,
|
||||
`test_layout.py::test_hub_root`, `test_fresh_imports` с `jira_hub.core.mcp, jira_hub.modules.jira.mcp`).
|
||||
- [ ] **Step 5: Ручная проверка запуска** — Run: `printf '' | timeout 5 uv run jira-hub mcp nope; echo $?` —
|
||||
Expected: строка `неизвестный модуль nope; модули: jira` и `2`.
|
||||
- [ ] **Step 6: Коммит** — `git commit -m "refactor: MCP и CLI по модулям; jira-hub mcp [модуль] (#87)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Веб — модуль jira и сборка оболочки из модулей
|
||||
|
||||
**Files:**
|
||||
- Move (git mv): `web/src/{board,issues,feed}` → `web/src/modules/jira/`; `web/src/shell/{refresh.ts,refresh.test.tsx,RefreshProvider.tsx,refreshStatus.ts,refreshStatus.test.ts,DataStatus.tsx}` → `web/src/modules/jira/refresh/`
|
||||
- Create: `web/src/modules/types.ts`, `web/src/modules/index.ts`, `web/src/modules/jira/index.tsx`,
|
||||
`web/src/modules/jira/api/queries.ts`, `web/src/modules/jira/issues/overlay.tsx`, `web/src/modules/modules.test.tsx`
|
||||
- Modify: `web/src/App.tsx`, `web/src/shell/{Layout,Sidebar,pages}.tsx`, `web/src/api/queries.ts` (остаются запросы
|
||||
ядра: проекты, настройки, пакеты), `web/src/projects/NewProjectDialog.tsx` (метка долга у поля доски),
|
||||
`web/scripts/{test-hub.sh,golden-from-links.ts}`, `web/e2e/board.spec.ts`, `web/src/test/*` (пути)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`web/src/modules/types.ts`):
|
||||
```ts
|
||||
export type NavItem = { to: string; label: string; icon: React.ReactNode };
|
||||
export type Overlay = { pageLocation: Location | null; element: React.ReactNode }; // null — окна нет
|
||||
export type HubModule = {
|
||||
name: string;
|
||||
pages: RouteObject[]; // без "*" — NotFound добавляет оболочка последним
|
||||
nav: NavItem[];
|
||||
Provider?: React.ComponentType<{ children: React.ReactNode }>;
|
||||
SidebarStatus?: React.ComponentType; // плашка данных и кнопка обновления в боковой панели
|
||||
useOverlay?: () => Overlay; // окно поверх страницы (карточка задачи)
|
||||
useOnProjectCreated?: () => (batchId: number) => void;
|
||||
};
|
||||
```
|
||||
- Produces (`web/src/modules/jira/index.tsx`): `jiraModule: HubModule` — `pages`: `/` → `/board`, `/board`, `/issues`,
|
||||
`/feed` (как в нынешнем `shell/pages.tsx`); `nav`: «Доска связей», «Задачи», «Лента» с прежними значками;
|
||||
`Provider`: `RefreshProvider`; `SidebarStatus`: `DataStatus` + кнопка обновления с событием пакета (то, что сейчас
|
||||
рисует `Sidebar` через `useJiraRefresh`/`refreshText`); `useOverlay`: логика окна карточки из `Layout.tsx`
|
||||
(`issueKey`, `cardState`, `bgLocation`, `close`, `abortClose`, `CloseAbortContext`, `IssueModal`);
|
||||
`useOnProjectCreated`: `refresh.track(batchId)`.
|
||||
- Оболочка: `App.tsx` вкладывает `Provider` всех модулей; `pages.tsx` — `PAGE_ROUTES` = страницы модулей + `NotFound`;
|
||||
`Sidebar.tsx` — `nav` модулей (тип `Section` становится `string`), `SidebarStatus` модулей; `Layout.tsx` — первый
|
||||
`useOverlay`, вернувший `pageLocation`, определяет адрес страницы и окно. Хуки модулей вызываются в постоянном
|
||||
порядке (список модулей — константа).
|
||||
|
||||
- [ ] **Step 1: Тест сборки** — `web/src/modules/modules.test.tsx`:
|
||||
- пути `PAGE_ROUTES` == `["/", "/board", "/issues", "/feed", "*"]`;
|
||||
- пункты панели (подписи, адреса) == «Доска связей» `/board`, «Задачи» `/issues`, «Лента» `/feed`;
|
||||
- имена модулей уникальны.
|
||||
- [ ] **Step 2: Тест падает** — Run: `cd web && npx vitest run src/modules/modules.test.tsx` — Expected: FAIL.
|
||||
- [ ] **Step 3: Перенести папки `git mv`, вынести описание модуля, переписать оболочку.** Поиск остатков:
|
||||
`grep -rn "\.\./board\|\.\./issues\|\.\./feed\|shell/refresh\|shell/DataStatus\|src/board" web/src web/scripts web/e2e`
|
||||
— только новые пути.
|
||||
- [ ] **Step 4: Проверки** — Run: `make check` — Expected: pytest, lint, vitest — всё passed (golden доски — без
|
||||
изменения эталонов, `Sidebar.test.tsx`, `IssueModal.test.tsx`, `projects.test.tsx` — только импорты).
|
||||
- [ ] **Step 5: e2e** — Run: `cd web && npm run e2e` — Expected: всё passed.
|
||||
- [ ] **Step 6: Коммит** — `git commit -m "refactor(web): модуль jira и оболочка из списка модулей (#87)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Граница ядра, метки долга, документация
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_registry.py`, места долга в `core/projects.py`, `core/mcp.py`, `core/api.py` (если
|
||||
`issues_count`/`board_id` проходят через него), `web/src/projects/NewProjectDialog.tsx`; `ARCHITECTURE.md`,
|
||||
`CLAUDE.md`, `README.md` (пути модулей, команда `jira-hub mcp [модуль]`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: всё выше.
|
||||
|
||||
- [ ] **Step 1: Тест границы** — `test_core_has_no_jira_terms` в `tests/test_registry.py`: в файлах
|
||||
`src/jira_hub/core/*.py`, кроме `db.py` (история `MIGRATIONS`), строки с любым из `'refresh'`, `"refresh"`,
|
||||
`ensure_`, `fetch_attachment`, `sync_run`, `issue_attachment`, `attachment_file`, `issue_comment`, `jql` допустимы
|
||||
только если в той же или предыдущей строке есть `долг hub-modules`. Падение показывает файл и строку.
|
||||
- [ ] **Step 2: Прогнать** — Run: `uv run pytest tests/test_registry.py -q` — Expected: PASS; если FAIL — перенести
|
||||
найденное в модуль (не помечать долгом ничего, кроме перечисленного в design, решение 12).
|
||||
- [ ] **Step 3: Метки долга на месте** — Run: `grep -rn "долг hub-modules" src web/src` — Expected: места из
|
||||
design (решение 12): `core/projects.py` (`board_id`/`board_type`, проверка `board_id`, `issues_count`),
|
||||
`core/mcp.py` (`project_create.board_id`, `fmt_projects`), `NewProjectDialog.tsx` (поле доски).
|
||||
- [ ] **Step 4: Документация** — `ARCHITECTURE.md`: таблица модулей ядра и модуля jira с новыми путями, контракт
|
||||
`Module`/`CommandHooks`, раздел «Границы» (ядро не импортирует модули; типы команд — в модулях; долг проектов),
|
||||
цепочки зависимостей; веб — `web/src/modules/`. `CLAUDE.md`: раздел «Архитектура» и команды (`jira-hub mcp [модуль]
|
||||
[--project]`). Переименование в sdlc-hub — строка в «Открытых вопросах» `ARCHITECTURE.md`.
|
||||
- [ ] **Step 5: Полная проверка** — Run: `make check && (cd web && npm run e2e)` — Expected: всё passed.
|
||||
- [ ] **Step 6: Коммит** — `git commit -m "docs: ядро и модули хаба; тест границы ядра (#87)"`
|
||||
Loading…
x
Reference in New Issue
Block a user