feat(hub): CLI, README, описание архитектуры

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 22:47:24 +04:00
parent f3ac1eb5c2
commit d7f82cb611
5 changed files with 313 additions and 15 deletions

View File

@ -1,15 +1,39 @@
# Архитектура
{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед architectural brainstorming. Обновляется вместе со спеками.}}
Хаб моста Jira: SQLite-зеркало задач AURORA/AWPCA, служебные заметки, очередь команд, HTTP API и MCP. Агент (`jira-agent`, виртуалка) опрашивает хаб, пишет в Jira и возвращает состояние; хаб Jira не вызывает.
## Модули
| Модуль | Ответственность | Зависит от |
|---|---|---|
| | | |
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/` | stdlib |
| `db` | схема SQLite, миграции, `connect` | stdlib |
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` |
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `backup` | копии файла базы, ротация | stdlib |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` | `queue`, `mirror`, `settings`, `backup` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio | `client` |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` |
Цепочки: `api → queue → commands/mirror/settings → db`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
## Потоки данных
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
## Границы
- Что не должно импортировать что.
- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят.
- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`.
- `queue` не повторяет команды: нет автоматических ретраев, повтор — новый пакет.
- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`.
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api` и `client`.
## Открытые вопросы
-
- Просмотрщик на живых данных и правки из UI — отдельные change'и (`viewer-live`, `viewer-edit`).
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`).

View File

@ -1,28 +1,35 @@
# jira-hub
## Что это
{{ОДНИМ АБЗАЦЕМ: что делает система, для кого, главный сценарий.}}
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спека: `openspec/changes/bridge-core/`.
## Стек и ограничения
- {{Язык, сборка, тесты — например: Python 3.12, uv, pytest.}}
- {{Жёсткие ограничения среды исполнения — без чего код не должен собираться.}}
- Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
- Хаб не повторяет команды сам: повтор — новая команда по запросу человека/интерфейса.
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`. Порт по умолчанию 8790.
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
## Архитектура
- `src/core/` (или {{ваш путь}}) — чистая логика без зависимостей от фреймворка/платформы. Покрывается тестами полностью.
- `src/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно.
- {{Ключевые интерфейсы/порты между ними, если есть.}}
- `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром.
- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`) ← `cli`.
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api`.
- Подробная карта модулей и границ — `ARCHITECTURE.md`.
## Команды
- `{{npm test / pytest}}` — весь набор тестов. Зелёным считается только весь набор.
- `{{npm run build / ...}}` — сборка.
- `{{npm run dev / ...}}` — локальный запуск.
- `uv run pytest` — весь набор тестов. Зелёным считается только весь набор.
- `uv sync` — установка окружения.
- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба.
- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены.
## Тесты
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
- {{Где лежит тестовое окружение (vault, БД, песочница).}}
- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют.
## Первая подсистема
{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}}
Очередь команд (`queue.py`, Task 5): пакеты, порядок `(batch.created_at, seq)`, одна команда в полёте, временные ключи `new:<n>`, подтверждение по состоянию зеркала. Остальное (API, MCP, импорт) надстраивается над ней.
## OpenSpec и superpowers
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.

174
README.md Normal file
View File

@ -0,0 +1,174 @@
# jira-hub
Хаб моста Jira для АРМ КА: зеркало задач проекта AURORA (метка AWPCA) в SQLite, служебные заметки, очередь команд,
HTTP API в локальной сети и MCP-сервер для Claude. Хаб работает на Mac mini (`192.168.0.106`); в Jira ходит только
агент на виртуалке (репозиторий `jira-agent`), пароль Jira хабу неизвестен.
Спека: `openspec/changes/bridge-core/` (proposal, design, specs).
## Установка
```bash
uv sync
```
Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`):
`hub.sqlite` и `backups/`. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API.
## Первый запуск
```bash
uv run jira-hub init --config ../admin/jira-sync/config.example.json
uv run jira-hub serve # 0.0.0.0:8790
```
`init` переносит в настройки хаба всё, что относится к проекту Jira (поля, типы, карту переходов, типы связей, доску,
приоритеты), и создаёт два токена: агентский и клиентский. Адрес и учётные данные Jira (`base_url`, `auth`, `verify_ssl`,
`ca_bundle`) остаются у агента и в хаб не попадают. Повторный `init` отказывает ("уже инициализирован"); с `--force`
настройки переписываются. Токены можно показать позже: `uv run jira-hub tokens`.
Передать агенту нужно адрес хаба и агентский токен: через общую папку виртуалки положить в `agent.json`
`"hub_url": "http://192.168.0.106:8790"` и `"hub_token": "<агентский токен>"`. Клиентский токен остаётся на этой машине:
его использует MCP (читает из базы сам).
Порт меняется ключом `--port` или настройкой `port`; адрес прослушивания — `--host`.
## Импорт пакета admin/jira-sync
```bash
uv run jira-hub import ../admin/jira-sync
```
Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON:
`notes` (сколько заметок записано), `expected_links`, `missing_keys` (ключи из CSV, которых нет в зеркале) и
`missing_links` (ожидаемые связи, которых нет). Запускать после первого `refresh` с `full: true`: пока зеркало пусто,
все ключи будут в `missing_keys`. Заметки к ещё не загруженным задачам сохраняются.
## MCP в Claude
`jira-hub mcp` запускает MCP-сервер по stdio; он ходит в API хаба клиентским токеном. Если хаб не запущен, инструменты
отвечают «хаб не запущен: jira-hub serve». Регистрация вручную (файл `~/.claude.json` скрипты хаба не правят),
блок для проекта `awp-ca`:
```json
"jira-hub": {"type": "stdio", "command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/awp-ca/jira-hub", "jira-hub", "mcp"]}
```
Либо командой: `claude mcp add jira-hub -- uv run --project /Users/andrey.epifancev/projects/awp-ca/jira-hub jira-hub mcp`.
| Инструмент | Параметры | Результат |
|---|---|---|
| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач |
| `issue` | `key` | поля, связи, заметки, последние команды |
| `notes_set` | `key`, поля заметок | обновлённые заметки |
| `batch_create` | `title`, `commands` | `id`, оценка длительности, число команд |
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
| `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту |
| `batches` | `limit` | последние пакеты |
| `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены |
| `refresh` | `keys` или `full`, `wait` | `sync_run` или id пакета |
| `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки |
| `sync_log` | `limit` | последние загрузки состояния |
| `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` |
Автор команд из MCP: `claude`; из API без автора: `api`.
## HTTP API
Все маршруты требуют `Authorization: Bearer <токен>`; ответы JSON, ошибки `{error, detail}` (400/401/404/409/422).
| Маршрут | Токен | Назначение |
|---|---|---|
| `POST /agent/claim` | агент | взять следующую команду (долгий запрос до 30 с) |
| `GET /agent/settings` | агент | настройки для агента (без токенов) |
| `GET /agent/commands/{id}` | агент | команда по id |
| `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) |
| `POST /agent/state` | агент | загрузка состояния задач пачками |
| `GET /agent/known-keys` | агент | ключи зеркала |
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` |
| `GET /issues/{key}` | клиент | задача целиком |
| `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд |
| `POST /batches/{id}/cancel` | клиент | отмена пакета |
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента, журнал загрузок |
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) |
```bash
TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p')
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=Спринт%202&limit=20"
```
## Контракт команд
Каждая команда описывает желаемое состояние одной задачи; агент читает, сравнивает и пишет только разницу, повтор с тем же
результатом завершается `done` без записи.
| Тип | Параметры |
|---|---|
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` |
| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` |
| `ensure_comment` | `key`, `marker`, `body` |
| `ensure_status` | `key`, `target` (имя статуса или список-путь) |
| `ensure_link` | `source`, `target`, `link_type`, `replace` |
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) |
| `refresh` | `keys` или `jql` |
Ключ в параметре может быть временным `new:<n>`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же
пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`):
```json
{"title": "1538 в спринт 2", "author": "claude", "commands": [
{"type": "ensure_fields", "params": {"key": "AURORA-1538", "duedate": "2026-10-23"}},
{"type": "ensure_link", "params": {"source": "AURORA-1537", "target": "AURORA-1538", "link_type": "связанные", "replace": true}},
{"type": "ensure_sprint", "params": {"key": "AURORA-1538", "sprint": "Спринт 2"}}
]}
```
Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с,
`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30 с, `refresh` 2 с (настройка `pacing`).
## Пример сценария
1. `jira-hub serve` на Mac mini, агент запущен на виртуалке.
2. В Claude: `refresh` с `full` загружает задачи в зеркало; `jira-hub import` переносит заметки из CSV.
3. `batch_create` ставит пакет; `batch_wait` ждёт результата; `issue` показывает итог.
## Эксплуатация
- Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-<batch>.sqlite`
и хранит последние `backup_keep` копий (по умолчанию 30).
- Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала,
хаб ставит пакет «Автообновление».
- Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`.
- Команда «нет ответа от агента»: команда в `claimed` без отчёта. Дождитесь агента (перезапущенный агент отчитается) или
отмените: `POST /commands/{id}/cancel` с `{"force": true}`. Хаб сам команды не повторяет; если факт уже виден после
`refresh`, команда становится `confirmed_by_state`. Повтор: `command_retry`.
- Автозапуск `serve` через launchd: `~/Library/LaunchAgents/ru.aepif.jira-hub.plist`
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>ru.aepif.jira-hub</string>
<key>ProgramArguments</key><array>
<string>uv</string><string>run</string><string>--project</string>
<string>/Users/andrey.epifancev/projects/awp-ca/jira-hub</string>
<string>jira-hub</string><string>serve</string>
</array>
<key>EnvironmentVariables</key><dict>
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/tmp/jira-hub.err.log</string>
</dict></plist>
```
Загрузить: `launchctl load ~/Library/LaunchAgents/ru.aepif.jira-hub.plist`.
## Тесты
```bash
uv run pytest
```

68
src/jira_hub/cli.py Normal file
View File

@ -0,0 +1,68 @@
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens."""
import argparse
import json
import sys
from . import db, importer, paths, settings
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены")
p.add_argument("--config", required=True)
p.add_argument("--force", action="store_true")
p = sub.add_parser("serve", help="запустить HTTP API")
p.add_argument("--host", default="0.0.0.0")
p.add_argument("--port", type=int)
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
p.add_argument("folder")
sub.add_parser("mcp", help="MCP-сервер по stdio")
sub.add_parser("tokens", help="показать токены агента и клиента")
args = ap.parse_args(argv)
if args.cmd == "serve":
from . import api
hub = api.Hub(paths.db_path())
conn = hub.conn()
port = args.port or int(settings.get(conn, "port") or 8790)
conn.close()
api.serve(hub, args.host, port)
return 0
if args.cmd == "mcp":
from . import mcp_server
mcp_server.run()
return 0
conn = db.connect(paths.db_path())
settings.ensure_defaults(conn)
try:
if args.cmd == "init":
with open(args.config, encoding="utf-8") as f:
cfg = json.load(f)
try:
keys = settings.init_from_config(conn, cfg, force=args.force)
except RuntimeError as e:
print(e, file=sys.stderr)
return 1
print("перенесено: " + ", ".join(keys))
_print_tokens(conn)
return 0
if args.cmd == "import":
print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1))
return 0
if args.cmd == "tokens":
_print_tokens(conn)
return 0
finally:
conn.close()
return 2
def _print_tokens(conn) -> None:
tok = settings.tokens(conn)
print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}")
if __name__ == "__main__":
sys.exit(main())

25
tests/test_cli.py Normal file
View File

@ -0,0 +1,25 @@
import json
from pathlib import Path
from jira_hub import cli, db, paths, settings
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
def test_init_import_tokens(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
assert cli.main(["init", "--config", str(FIXTURE)]) == 0
out = capsys.readouterr().out
assert "transitions_map" in out and "агентский токен" in out
assert cli.main(["init", "--config", str(FIXTURE)]) == 1
assert "уже инициализирован" in capsys.readouterr().err
conn = db.connect(paths.db_path())
assert settings.get(conn, "board_id") == 4216
(tmp_path / "pkg").mkdir()
(tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8")
assert cli.main(["import", str(tmp_path / "pkg")]) == 0
rep = json.loads(capsys.readouterr().out)
assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"]
assert cli.main(["tokens"]) == 0
assert settings.tokens(conn)["client"] in capsys.readouterr().out
conn.close()