From aa621a078a232ce707d482e9916c8850b847e0b4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 00:41:27 +0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D1=80=D0=B5=D0=BB=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=B9=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B2=20serve;=20README=20=E2=80=94=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=BF=D0=BE=D1=80=D1=82=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BF=D0=B0=D0=BF=D0=BA=D1=83=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 7 ++++--- CLAUDE.md | 4 ++-- README.md | 24 ++++++++++++++++++++++++ src/jira_hub/api.py | 15 ++++++++++++--- tests/test_relay.py | 26 ++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d77b461..07ca460 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,13 +13,14 @@ | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `backup` | копии файла базы, ротация | stdlib | -| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` | `queue`, `mirror`, `settings`, `backup` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` | `queue`, `mirror`, `settings`, `backup`, `relay` | +| `relay` | реле общей папки: поток в `serve`, файлы `req/` → HTTP `127.0.0.1` → `resp/`; HTTP-обработчики о нём не знают | `settings` | | `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`. +Цепочки: `api → queue → commands/mirror/settings → db`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. @@ -31,7 +32,7 @@ - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не повторяет команды: нет автоматических ретраев, повтор — новый пакет. - `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`. -- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api` и `client`. +- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). ## Открытые вопросы - Просмотрщик на живых данных и правки из UI — отдельные change'и (`viewer-live`, `viewer-edit`). diff --git a/CLAUDE.md b/CLAUDE.md index 376ab86..f2f6f6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # jira-hub ## Что это -Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спека: `openspec/changes/bridge-core/`. +Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/folder-transport/`. ## Стек и ограничения - Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10). @@ -13,7 +13,7 @@ ## Архитектура - `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром. -- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`) ← `cli`. +- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`. - `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api`. - Подробная карта модулей и границ — `ARCHITECTURE.md`. diff --git a/README.md b/README.md index 2f92d70..b5d036c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,30 @@ uv run jira-hub import ../admin/jira-sync Автор команд из MCP: `claude`; из API без автора: `api`. +## Транспорт через общую папку + +Если сеть между виртуалкой и Mac mini закрыта (VPN «Континент ZTN» на виртуалке режет локальную сеть), агент ходит +в хаб через общую папку UTM. Реле работает внутри `serve` и включается настройкой: + +```bash +TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') +curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + --data '{"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"}' http://127.0.0.1:8790/settings +``` + +или из Claude: `settings_set relay_dir "/Volumes/externalSSD/utm/rshb/jira-bridge"`. Выключить — `null`. Перезапуск не +нужен: реле перечитывает настройку раз в 5 с, создаёт `req/`, `resp/`, `work/`; если папки нет (SSD не подключён) — +пишет одно предупреждение и ждёт. У агента: `"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"`. + +Как устроено: агент пишет запрос файлом `req/.json` (`method`, `path`, `body`, токен, `deadline`), реле +переименованием забирает его в `work/`, пересылает в свой HTTP и кладёт ответ `resp/.json` (`status`, `body`). +Просроченные запросы выбрасываются; `claim` пересылается, только если до срока ≥ 35 с. В логе — строка +`relay <метод> <путь> <код> <секунды>` на запрос. Круг через VirtioFS ≈ 0,8 с. + +Если launchd-хаб не видит папку (в логе «папки … нет — жду» при подключённом SSD) — macOS не пускает процесс на внешний +диск: «Системные настройки → Конфиденциальность и безопасность → Полный доступ к диску» → добавить `/opt/homebrew/bin/uv` +и python из `.venv` хаба, перезапустить задание. + ## HTTP API Все маршруты требуют `Authorization: Bearer <токен>`; ответы JSON, ошибки `{error, detail}` (400/401/404/409/422). diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index d8dbd89..6251d20 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -11,7 +11,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse -from . import backup, db, mirror, queue, settings +from . import backup, db, mirror, queue, relay, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .queue import ClaimedError @@ -343,7 +343,9 @@ def make_server(hub: Hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer: return ThreadingHTTPServer((host, port), handler) -def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: +def start(hub: Hub, host="0.0.0.0", port=8790) -> tuple[ThreadingHTTPServer, threading.Event]: + """Создать HTTP-сервер и запустить в фоне тикер автообновления и реле общей папки. + Обслуживание HTTP (serve_forever) запускает вызывающий; stop останавливает тикер и реле.""" srv = make_server(hub, host, port) stop = threading.Event() @@ -352,7 +354,14 @@ def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: hub.tick() threading.Thread(target=ticker, daemon=True).start() - print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}") + target = "127.0.0.1" if host in ("", "0.0.0.0") else host + threading.Thread(target=relay.Relay(hub, srv.server_port, host=target).run, args=(stop,), daemon=True).start() + return srv, stop + + +def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: + srv, stop = start(hub, host, port) + print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}", flush=True) try: srv.serve_forever() except KeyboardInterrupt: diff --git a/tests/test_relay.py b/tests/test_relay.py index a032004..7e1eb17 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -241,3 +241,29 @@ def test_log_line(env, capsys): put_request(root, "l1", "GET", "/agent/settings", env["agent"]) run_once(r) assert "relay GET /agent/settings 200" in capsys.readouterr().out + + +def test_serve_runs_relay(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + root = tmp_path / "bridge" + root.mkdir() + conn = h.conn() + settings.set_(conn, "relay_dir", str(root)) + auth = "Bearer " + settings.tokens(conn)["agent"] + conn.close() + srv, stop = api.start(h, "127.0.0.1", 0) # реле стучится на тот же хост, что слушает HTTP + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + try: + t0 = time.monotonic() + while not (root / "req").is_dir() and time.monotonic() - t0 < 3: + time.sleep(0.05) + put_request(root, "e1", "GET", "/agent/settings", auth) + while response(root, "e1") is None and time.monotonic() - t0 < 5: + time.sleep(0.05) + assert response(root, "e1")["status"] == 200 + finally: + stop.set() + srv.shutdown() + srv.server_close() + th.join(5)