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..1e5ddef 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-хаб не видит папку (в логе «relay: нет доступа к папке … — Operation not permitted; жду» или «папки … нет — +жду» при подключённом SSD) — macOS не пускает процесс на внешний диск: «Системные настройки → Конфиденциальность и безопасность → Полный доступ к диску» → добавить `/opt/homebrew/bin/uv` +и python из `.venv` хаба, перезапустить задание. + ## HTTP API Все маршруты требуют `Authorization: Bearer <токен>`; ответы JSON, ошибки `{error, detail}` (400/401/404/409/422). diff --git a/docs/superpowers/plans/2026-10-12-folder-transport-hub.md b/docs/superpowers/plans/2026-10-12-folder-transport-hub.md new file mode 100644 index 0000000..1a63908 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-folder-transport-hub.md @@ -0,0 +1,768 @@ +# Реле хаба через общую папку — план реализации (jira-hub) + +> **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 serve` пересылает файлы-запросы агента из общей папки в свой HTTP и кладёт ответы обратно, чтобы агент за VPN «Континент ZTN» работал с хабом без сети. + +**Architecture:** Новый модуль `src/jira_hub/relay.py` — класс `Relay` с шагом `step()` (перечитать `relay_dir`, взять запросы переименованием `req/` → `work/`, раз в минуту уборка) и обработчиком запроса в отдельном потоке (`process`: проверка `deadline`, пересылка на `127.0.0.1:` через `urllib`, атомарная запись ответа). `api.serve()` разбивается на `api.start()` (HTTP, тикер, реле в фоне) и `serve_forever`. Настройка `relay_dir` проверяется в `settings.validate`. HTTP API, обработчики, токены не меняются. + +**Tech Stack:** Python 3.13, stdlib (`urllib`, `threading`, `pathlib`, `json`), pytest, uv. + +**Spec:** `openspec/changes/folder-transport/` (proposal.md, design.md, specs/hub-api, specs/data-model). Агент — отдельный план `jira-agent/docs/superpowers/plans/2026-10-12-folder-transport-agent.md`. + +## Global Constraints + +- Только stdlib в `relay.py`; новых зависимостей в `pyproject.toml` нет. +- HTTP API, `ROUTES`, обработчики `h_*`, проверка токенов (`Handler._auth`) и `MAX_BODY` не меняются. +- Папки: `/req/`, `/resp/`, `/work/`; файлы с именем на точку никто не читает. +- Запрос: `{id, method, path, body, authorization, deadline}`; `deadline` — ISO 8601 UTC. Ответ: `{id, status, body}`, `body` — строка ответа HTTP. +- `POST /agent/claim` пересылается, только если до `deadline` ≥ 35 с; просроченные запросы выбрасываются без ответа. +- Сбой соединения с собственным HTTP — ответ `status = 502`. +- Перечитывание `relay_dir` — не реже раза в 5 с; опрос `req/` — каждые 0,1 с; нечитаемый JSON запроса — перечитывать до 2 с. +- Уборка раз в минуту: `resp/` старше 600 с, `req/` с `deadline` старше 60 с, файлы на точку старше 600 с; при подготовке папки `work/` очищается. +- Лог реле — `print(..., flush=True)` строкой `[dd/Mon/YYYY HH:MM:SS] relay <секунды> с` (тот же формат времени, что `Handler.log_message`). +- `relay_dir`: `null` или абсолютный путь строкой; иначе `ValueError` → 422; в `AGENT_KEYS` не входит. +- Тесты: `uv run pytest` — весь набор зелёный; сообщения коммитов на русском, трейлер `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Хаб запущен с `--host <конкретный IP>` (не `0.0.0.0`) — реле должно стучаться на этот адрес, а не на `127.0.0.1`, где никто не слушает (тест в Task 3: `start(hub, "127.0.0.1", 0)` и пересылка на тот же хост). +2. Файл запроса с `path` без ведущего `/`, с `method` не из GET/POST/PUT или с переводом строки в `authorization` — ответ `400` с объяснением, поток реле не падает (тест в Task 2: `test_bad_request_fields_get_400`). +3. Между `listdir` и переименованием агент удалил свой просроченный запрос — реле молча пропускает, без трассировки (тест в Task 2: `test_vanished_request_is_skipped`). +4. Каталог `relay_dir` пропал на ходу (SSD отключили) — одно предупреждение, без потока трассировок каждые 0,1 с; после возвращения каталога работа продолжается (тест в Task 2: `test_missing_dir_warns_once_then_works`). +5. Запрос без `deadline` или с неразбираемым `deadline` — выбрасывается как просроченный, без ответа (тест в Task 2: `test_missing_deadline_is_dropped`). + +--- + +### Task 1: Настройка `relay_dir` + +**Files:** +- Modify: `src/jira_hub/settings.py` (DEFAULTS, `validate`) +- Test: `tests/test_settings.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: — +- Produces: `settings.get(conn, "relay_dir") -> str | None`; `settings.validate("relay_dir", value)` бросает `ValueError` для не-`None` не-строки и для относительного пути. + +- [ ] **Step 1: Написать падающие тесты** + +В `tests/test_settings.py` добавить: + +```python +def test_relay_dir_default_and_validation(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + settings.ensure_defaults(conn) + assert settings.get(conn, "relay_dir") is None + settings.validate("relay_dir", None) + settings.validate("relay_dir", "/Volumes/externalSSD/utm/rshb/jira-bridge") + for bad in ("rshb/jira-bridge", "", 5, ["/x"]): + with pytest.raises(ValueError): + settings.validate("relay_dir", bad) + assert "relay_dir" not in settings.AGENT_KEYS + conn.close() +``` + +(Импорты `db`, `settings`, `pytest` в файле уже есть — проверить шапку; при отсутствии добавить `import pytest` и `from jira_hub import db, settings`.) + +В `tests/test_api.py` добавить: + +```python +def test_relay_dir_put_validation(hub): + c = hub["client"] + before = c.get("/settings")["settings_version"] + with pytest.raises(HubError) as e: + c.put("/settings", {"relay_dir": "rshb/jira-bridge"}) + assert e.value.status == 422 + assert c.get("/settings")["settings_version"] == before + assert c.put("/settings", {"relay_dir": "/tmp/jira-bridge"})["settings_version"] == before + 1 + assert c.put("/settings", {"relay_dir": None})["settings_version"] == before + 2 + assert "relay_dir" not in hub["agent"].get("/agent/settings") +``` + +- [ ] **Step 2: Убедиться, что падают** + +Run: `uv run pytest tests/test_settings.py::test_relay_dir_default_and_validation tests/test_api.py::test_relay_dir_put_validation -v` +Expected: FAIL (`relay_dir` нет в настройках, относительный путь принимается). + +- [ ] **Step 3: Реализация** + +В `src/jira_hub/settings.py`: +- в `DEFAULTS` после `"board_id": None,` добавить `"relay_dir": None,`; +- в начало модуля `import os`; +- в `validate` перед строкой `default = DEFAULTS.get(key)` вставить: + +```python + if key == "relay_dir": + if value is not None and (not isinstance(value, str) or not os.path.isabs(value)): + raise ValueError("relay_dir — абсолютный путь папки реле или null") + return +``` + +- обновить docstring `validate`: добавить строку «relay_dir — null или абсолютный путь строкой;». + +- [ ] **Step 4: Тесты зелёные** + +Run: `uv run pytest -q` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/settings.py tests/test_settings.py tests/test_api.py +git commit -m "feat(settings): настройка relay_dir — абсолютный путь папки реле или null (#5)" +``` + +--- + +### Task 2: Модуль реле + +**Files:** +- Create: `src/jira_hub/relay.py` +- Test: `tests/test_relay.py` + +**Interfaces:** +- Consumes: `api.Hub` (`hub.conn()`), `settings.get(conn, "relay_dir")` из Task 1; `api.make_server` для тестов. +- Produces: + - `relay.Relay(hub, port: int, host: str = "127.0.0.1", poll: float = 0.1)`; + - `Relay.step() -> None` — один проход (перечитать настройку по таймеру, подготовить папку, взять запросы, уборка по таймеру); + - `Relay.run(stop: threading.Event) -> None` — цикл `step()` до `stop`; + - `Relay.join(timeout: float = 5) -> None` — дождаться потоков обработки (для тестов); + - `Relay.cleanup(now: float) -> None`; + - `Relay.root: Path | None`; + - модульные константы `CLAIM_MIN_LEFT = 35`, `SETTINGS_EVERY = 5`, `CLEANUP_EVERY = 60`, `RESP_TTL = 600`, `REQ_GRACE = 60`, `TMP_TTL = 600`, `BAD_JSON_WAIT = 2`, `FORWARD_TIMEOUT = 90` (тесты подменяют через `monkeypatch.setattr(relay, ...)`); + - `relay.write_atomic(directory: Path, name: str, payload: dict) -> None`; `relay.parse_deadline(value) -> float | None`. + +- [ ] **Step 1: Написать падающие тесты** + +Создать `tests/test_relay.py`: + +```python +import json +import os +import threading +import time +from datetime import datetime, timedelta, timezone + +import pytest + +from jira_hub import api, relay, settings +from jira_hub.client import HubClient + + +@pytest.fixture +def env(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + root = tmp_path / "bridge" + root.mkdir() + settings.set_(conn, "relay_dir", str(root)) + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + r = relay.Relay(h, srv.server_port) + client = HubClient(f"http://127.0.0.1:{srv.server_port}", tok["client"]) + yield {"hub": h, "root": root, "relay": r, "agent": "Bearer " + tok["agent"], "client": client, + "port": srv.server_port} + srv.shutdown() + srv.server_close() + th.join(5) + + +def deadline(seconds): + return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat() + + +def put_request(root, rid, method, path, auth, body=None, seconds=60, **extra): + req = {"id": rid, "method": method, "path": path, "body": body, "authorization": auth, + "deadline": deadline(seconds), **extra} + relay.write_atomic(root / "req", f"{rid}.json", req) + + +def response(root, rid): + p = root / "resp" / f"{rid}.json" + return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None + + +def run_once(r): + r.step() + r.join() + + +def test_request_roundtrip(env): + r, root = env["relay"], env["root"] + r.step() # подготовка: req/, resp/, work/ + put_request(root, "a1", "GET", "/agent/settings", env["agent"]) + run_once(r) + resp = response(root, "a1") + assert resp["status"] == 200 and resp["id"] == "a1" + assert json.loads(resp["body"])["project_key"] == "AURORA" + assert not list((root / "work").iterdir()) and not list((root / "req").iterdir()) + + +def test_status_passes_through(env): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "u1", "GET", "/issues", env["agent"]) # агентский токен на клиентский маршрут + put_request(root, "u2", "GET", "/agent/commands/999", env["agent"]) + run_once(r) + assert response(root, "u1")["status"] == 401 + assert response(root, "u2")["status"] == 404 + + +def test_expired_and_short_claim_dropped(env): + r, root, c = env["relay"], env["root"], env["client"] + b = c.post("/batches", {"title": "t", "author": "test", + "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + r.step() + put_request(root, "c1", "POST", "/agent/claim", env["agent"], body={}, seconds=20) + put_request(root, "s1", "GET", "/agent/settings", env["agent"], seconds=-1) + run_once(r) + assert response(root, "c1") is None and response(root, "s1") is None + statuses = [x["status"] for x in c.get(f"/batches/{b['id']}")["commands"]] + assert statuses[0] == "queued" + assert not list((root / "work").iterdir()) + + +def test_claim_with_enough_time_is_forwarded(env): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "c2", "POST", "/agent/claim", env["agent"], body={"agent_version": 2}, seconds=50) + run_once(r) + assert response(root, "c2")["status"] == 200 + + +def test_long_claim_does_not_block_other_requests(env): + env["hub"].claim_wait = 3 + r, root = env["relay"], env["root"] + r.step() + put_request(root, "c3", "POST", "/agent/claim", env["agent"], body={}, seconds=60) + r.step() + put_request(root, "s3", "GET", "/agent/settings", env["agent"]) + t0 = time.monotonic() + while response(root, "s3") is None and time.monotonic() - t0 < 2.5: + r.step() + time.sleep(0.05) + assert response(root, "s3")["status"] == 200 + assert response(root, "c3") is None # claim ещё ждёт + r.join() + assert response(root, "c3")["status"] == 200 + + +def test_unreachable_hub_gives_502(env): + r = relay.Relay(env["hub"], 1) # на порту 1 никто не слушает + root = env["root"] + r.step() + put_request(root, "p1", "GET", "/agent/settings", env["agent"]) + run_once(r) + resp = response(root, "p1") + assert resp["status"] == 502 and "хаб недоступен" in resp["body"] + + +def test_dotfiles_ignored_and_bad_json_dropped(env, monkeypatch, capsys): + monkeypatch.setattr(relay, "BAD_JSON_WAIT", 0.2) + r, root = env["relay"], env["root"] + r.step() + (root / "req" / ".x1.json.tmp").write_text("{}", encoding="utf-8") + (root / "req" / "bad.json").write_text("{не json", encoding="utf-8") + run_once(r) + assert (root / "req" / ".x1.json.tmp").exists() + assert response(root, "bad") is None and not list((root / "work").iterdir()) + assert "нечитаемый запрос bad.json" in capsys.readouterr().out + + +def test_bad_request_fields_get_400(env): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "b1", "GET", "agent/settings", env["agent"]) # путь без / + put_request(root, "b2", "DELETE", "/agent/settings", env["agent"]) # метод не из списка + put_request(root, "b3", "GET", "/agent/settings", "Bearer x\r\nX-Evil: 1") # перевод строки в заголовке + run_once(r) + for rid in ("b1", "b2", "b3"): + assert response(root, rid)["status"] == 400, rid + + +def test_missing_deadline_is_dropped(env): + r, root = env["relay"], env["root"] + r.step() + relay.write_atomic(root / "req", "d1.json", {"id": "d1", "method": "GET", "path": "/agent/settings", + "authorization": env["agent"]}) + relay.write_atomic(root / "req", "d2.json", {"id": "d2", "method": "GET", "path": "/agent/settings", + "authorization": env["agent"], "deadline": "завтра"}) + run_once(r) + assert response(root, "d1") is None and response(root, "d2") is None + + +def test_vanished_request_is_skipped(env, capsys): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "v1", "GET", "/agent/settings", env["agent"]) + orig = os.rename + + def vanish(src, dst): + os.remove(src) # агент удалил свой запрос раньше реле + return orig(src, dst) + + os.rename = vanish + try: + run_once(r) + finally: + os.rename = orig + assert response(root, "v1") is None + assert "Traceback" not in capsys.readouterr().err + + +def test_work_cleared_on_prepare(env, capsys): + r, root = env["relay"], env["root"] + (root / "work").mkdir() + (root / "work" / "old.json").write_text("{}", encoding="utf-8") + r.step() + assert not (root / "work" / "old.json").exists() + assert "old.json" in capsys.readouterr().out + + +def test_missing_dir_warns_once_then_works(env, capsys): + r, root = env["relay"], env["root"] + gone = root.parent / "bridge-later" + conn = env["hub"].conn() + settings.set_(conn, "relay_dir", str(gone)) + conn.close() + for _ in range(5): + r.step() + out = capsys.readouterr().out + assert out.count("нет — жду") == 1 + gone.mkdir() + r.step() + assert (gone / "req").is_dir() and (gone / "resp").is_dir() and (gone / "work").is_dir() + put_request(gone, "m1", "GET", "/agent/settings", env["agent"]) + run_once(r) + assert response(gone, "m1")["status"] == 200 + + +def test_disabled_and_reenabled_without_restart(env, monkeypatch): + r, root = env["relay"], env["root"] + conn = env["hub"].conn() + settings.set_(conn, "relay_dir", None) + conn.close() + r.step() + assert r.root is None and not (root / "req").exists() + monkeypatch.setattr(relay, "SETTINGS_EVERY", 0) + conn = env["hub"].conn() + settings.set_(conn, "relay_dir", str(root)) + conn.close() + r.step() + assert r.root == root and (root / "req").is_dir() + + +def test_cleanup_by_age(env): + r, root = env["relay"], env["root"] + r.step() + old, fresh = root / "resp" / "old.json", root / "resp" / "fresh.json" + tmp_old = root / "resp" / ".t.json.tmp" + for p in (old, fresh, tmp_old): + p.write_text("{}", encoding="utf-8") + now = time.time() + os.utime(old, (now - 700, now - 700)) + os.utime(tmp_old, (now - 700, now - 700)) + stale_req = root / "req" / ".q.json.tmp" + stale_req.write_text("{}", encoding="utf-8") + os.utime(stale_req, (now - 700, now - 700)) + r.cleanup(now) + assert not old.exists() and not tmp_old.exists() and not stale_req.exists() + assert fresh.exists() + + +def test_log_line(env, capsys): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "l1", "GET", "/agent/settings", env["agent"]) + run_once(r) + assert "relay GET /agent/settings 200" in capsys.readouterr().out +``` + +- [ ] **Step 2: Убедиться, что падают** + +Run: `uv run pytest tests/test_relay.py -q` +Expected: FAIL с `ImportError: cannot import name 'relay'`. + +- [ ] **Step 3: Реализация** + +Создать `src/jira_hub/relay.py`: + +```python +"""Реле через общую папку (specs/hub-api, «Реле через общую папку»): файлы-запросы агента из /req/ +пересылаются в HTTP этого же хаба, ответы кладутся в /resp/. HTTP API, обработчики и токены не меняются; +реле для них — ещё один клиент. Нужно, когда VPN на виртуалке закрывает сеть до хаба (design.md folder-transport).""" +import json +import os +import threading +import time +import traceback +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from . import settings + +CLAIM_PATH = "/agent/claim" +CLAIM_MIN_LEFT = 35 # с: claim пересылается, только если до deadline осталось не меньше (хаб держит claim ≤ 30 с) +SETTINGS_EVERY = 5 # с: как часто перечитывать relay_dir +CLEANUP_EVERY = 60 # с: как часто убирать мусор +RESP_TTL = 600 # с: ответ, за которым агент не пришёл +REQ_GRACE = 60 # с после deadline: запрос, который никто не взял +TMP_TTL = 600 # с: брошенные временные файлы (имя на точку) +BAD_JSON_WAIT = 2 # с: сколько перечитывать нечитаемый запрос +FORWARD_TIMEOUT = 90 # с: ожидание ответа своего HTTP (claim держит до 30 с) +METHODS = ("GET", "POST", "PUT") + + +def say(msg: str) -> None: + print(f"[{time.strftime('%d/%b/%Y %H:%M:%S')}] {msg}", flush=True) + + +def parse_deadline(value) -> float | None: + """ISO 8601 → секунды эпохи; без зоны считается UTC; мусор → None.""" + if not isinstance(value, str): + return None + try: + dt = datetime.fromisoformat(value) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + + +def write_atomic(directory: Path, name: str, payload: dict) -> None: + """Записать JSON под временным именем на точку и переименовать: читатель видит файл только целиком.""" + tmp = Path(directory) / f".{name}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, Path(directory) / name) + + +class Relay: + def __init__(self, hub, port: int, host: str = "127.0.0.1", poll: float = 0.1): + self.hub, self.port, self.host, self.poll = hub, port, host, poll + self.root: Path | None = None + self._settings_at: float | None = None + self._cleanup_at = 0.0 + self._ready = False + self._warned = False + self.workers: list[threading.Thread] = [] + + # --- цикл --------------------------------------------------------------------- + def run(self, stop: threading.Event) -> None: + while not stop.is_set(): + try: + self.step() + except Exception: # noqa: BLE001 — сбой одного шага не останавливает реле + traceback.print_exc() + stop.wait(self.poll) + + def step(self) -> None: + now = time.time() + if self._settings_at is None or now - self._settings_at >= SETTINGS_EVERY: + self._settings_at = now + self._reload() + if self.root is None: + return + if not self.root.is_dir(): + if not self._warned: + say(f"relay: папки {self.root} нет — жду") + self._warned = True + self._ready = False + return + if not self._ready: + self._prepare() + for path in sorted((self.root / "req").iterdir()): + if path.name.startswith(".") or not path.name.endswith(".json"): + continue + self._take(path) + if now - self._cleanup_at >= CLEANUP_EVERY: + self._cleanup_at = now + self.cleanup(now) + + def join(self, timeout: float = 5) -> None: + for w in list(self.workers): + w.join(timeout) + + # --- папка ---------------------------------------------------------------------- + def _reload(self) -> None: + conn = self.hub.conn() + try: + value = settings.get(conn, "relay_dir") + finally: + conn.close() + root = Path(value) if value else None + if root != self.root: + self.root, self._ready, self._warned = root, False, False + say(f"relay: папка {root}" if root else "relay: выключено") + + def _prepare(self) -> None: + for sub in ("req", "resp", "work"): + (self.root / sub).mkdir(exist_ok=True) + for stale in (self.root / "work").iterdir(): + stale.unlink(missing_ok=True) + say(f"relay: удалён незавершённый запрос {stale.name} (перезапуск реле)") + self._ready, self._warned = True, False + + def _take(self, path: Path) -> None: + target = self.root / "work" / path.name + try: + os.rename(path, target) + except FileNotFoundError: # агент успел удалить свой просроченный запрос + return + worker = threading.Thread(target=self.process, args=(self.root, target), daemon=True) + worker.start() + self.workers = [w for w in self.workers if w.is_alive()] + [worker] + + # --- обработка запроса ------------------------------------------------------------- + def process(self, root: Path, work: Path) -> None: + started = time.monotonic() + try: + req = self._read(work) + if req is None: + say(f"relay: нечитаемый запрос {work.name} — выброшен") + return + method, path = req.get("method"), req.get("path") + deadline = parse_deadline(req.get("deadline")) + left = deadline - time.time() if deadline is not None else -1.0 + if left <= 0 or (method == "POST" and path == CLAIM_PATH and left < CLAIM_MIN_LEFT): + say(f"relay {method} {path} просрочен (осталось {left:.0f} с) — выброшен") + return + status, body = self.forward(method, path, req.get("body"), req.get("authorization")) + write_atomic(root / "resp", work.name, {"id": req.get("id"), "status": status, "body": body}) + say(f"relay {method} {path} {status} {time.monotonic() - started:.1f} с") + except OSError as e: # папка пропала на ходу — агент дождётся своего срока + say(f"relay: сбой файла {work.name}: {e}") + finally: + work.unlink(missing_ok=True) + + def _read(self, work: Path) -> dict | None: + end = time.monotonic() + BAD_JSON_WAIT + while True: + try: + with open(work, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else None + except (ValueError, OSError): + if time.monotonic() >= end: + return None + time.sleep(0.1) + + def forward(self, method, path, body, authorization) -> tuple[int, str]: + if method not in METHODS or not isinstance(path, str) or not path.startswith("/"): + return 400, _error("bad_request", f"неверный метод или путь: {method} {path}") + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + try: + req = urllib.request.Request(f"http://{self.host}:{self.port}{path}", data=data, method=method) + if authorization: + req.add_header("Authorization", str(authorization)) + if data is not None: + req.add_header("Content-Type", "application/json; charset=utf-8") + with urllib.request.urlopen(req, timeout=FORWARD_TIMEOUT) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + try: + return e.code, e.read().decode("utf-8", "replace") + finally: + e.close() + except ValueError as e: # недопустимый заголовок (перевод строки) или URL + return 400, _error("bad_request", str(e)) + except (urllib.error.URLError, OSError) as e: + return 502, _error("relay", f"хаб недоступен изнутри: {e}") + + # --- уборка ------------------------------------------------------------------------ + def cleanup(self, now: float) -> None: + if self.root is None or not self.root.is_dir(): + return + for p in _entries(self.root / "resp"): + ttl = TMP_TTL if p.name.startswith(".") else RESP_TTL + if _age(p, now) > ttl: + p.unlink(missing_ok=True) + for p in _entries(self.root / "req"): + if p.name.startswith("."): + if _age(p, now) > TMP_TTL: + p.unlink(missing_ok=True) + continue + try: + deadline = parse_deadline(json.loads(p.read_text(encoding="utf-8")).get("deadline")) + except (OSError, ValueError, AttributeError): + deadline = None + if deadline is None or now - deadline > REQ_GRACE: + p.unlink(missing_ok=True) + + +def _error(error: str, detail: str) -> str: + return json.dumps({"error": error, "detail": detail}, ensure_ascii=False) + + +def _entries(directory: Path) -> list[Path]: + try: + return list(directory.iterdir()) + except FileNotFoundError: + return [] + + +def _age(p: Path, now: float) -> float: + try: + return now - p.stat().st_mtime + except FileNotFoundError: + return 0.0 +``` + +Примечание к `test_missing_dir_warns_once_then_works`: после смены `relay_dir` в базе `step()` перечитывает настройку только раз в `SETTINGS_EVERY`; первый `step()` в этом тесте — первый для объекта, поэтому настройка читается сразу. Если тест падает из-за таймера, проверить порядок: фикстура не вызывает `step()` до теста. + +- [ ] **Step 4: Тесты зелёные** + +Run: `uv run pytest tests/test_relay.py -v`, затем `uv run pytest -q` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/relay.py tests/test_relay.py +git commit -m "feat(relay): реле через общую папку — запросы агента из файлов в свой HTTP и обратно (#5)" +``` + +--- + +### Task 3: Реле в `serve`, документация + +**Files:** +- Modify: `src/jira_hub/api.py:346-362` (`serve` → `start` + `serve`) +- Modify: `README.md`, `ARCHITECTURE.md`, `CLAUDE.md` +- Test: `tests/test_relay.py` (сквозной тест через `api.start`) + +**Interfaces:** +- Consumes: `relay.Relay`, `relay.write_atomic` из Task 2. +- Produces: `api.start(hub, host="0.0.0.0", port=8790) -> tuple[ThreadingHTTPServer, threading.Event]` — поднимает тикер и реле в фоне, HTTP ещё не обслуживает (вызывающий запускает `serve_forever`); `api.serve` использует `start`. + +- [ ] **Step 1: Падающий сквозной тест** + +Добавить в `tests/test_relay.py`: + +```python +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) +``` + +Run: `uv run pytest tests/test_relay.py::test_serve_runs_relay -v` +Expected: FAIL `AttributeError: module 'jira_hub.api' has no attribute 'start'`. + +- [ ] **Step 2: Реализация** + +В `src/jira_hub/api.py`: +- в импорт `from . import backup, db, mirror, queue, settings` добавить `relay`; +- заменить функцию `serve` (строки 346–362) на: + +```python +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() + + def ticker(): + while not stop.wait(60): + hub.tick() + + threading.Thread(target=ticker, daemon=True).start() + 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: + pass + finally: + stop.set() + srv.server_close() +``` + +- [ ] **Step 3: Тесты зелёные** + +Run: `uv run pytest -q` +Expected: всё зелёное. + +- [ ] **Step 4: Документация** + +`README.md` — после раздела «MCP в Claude» добавить раздел: + +````markdown +## Транспорт через общую папку + +Если сеть между виртуалкой и 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` хаба, перезапустить задание. +```` + +`ARCHITECTURE.md` — в карту модулей добавить строку про `relay` (рядом с `api`): «`relay` — реле общей папки: поток в `serve`, файлы `req/` → HTTP `127.0.0.1` → `resp/`; зависит от `settings`; HTTP-обработчики о нём не знают». В граф зависимостей: `api` → `relay` → `settings`. + +`CLAUDE.md` — в разделе «Архитектура» в строку с `api` добавить «, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)»; в разделе «Что это» заменить «Спека: `openspec/changes/bridge-core/`» на «Спеки: `openspec/specs/`; текущий change — `openspec/changes/folder-transport/`». + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/api.py tests/test_relay.py README.md ARCHITECTURE.md CLAUDE.md +git commit -m "feat(api): реле общей папки запускается в serve; README — транспорт через папку (#5)" +``` + +--- + +## Приёмка (после обоих планов, вне задач) + +1. Прогон на моке Jira через временную папку: хаб `JIRA_HUB_HOME` во временном каталоге с `relay_dir`, агент из ветки `jira-agent` с `hub_url = folder:<та же папка>`; сценарий 12.10.2026 (пакет всех типов, повтор без записей, SIGINT в паузе, обрыв Jira). Критерий: число записей в мок-Jira как по HTTP, зеркало совпадает. +2. Настоящая папка: launchd-хаб, `relay_dir = /Volumes/externalSSD/utm/rshb/jira-bridge`, запрос-файл с Mac mini даёт ответ — проверка доступа launchd к SSD. +3. Виртуалка с включённым Континентом: `check` — обе строки «доступен»; запуск агента, `refresh full`, `jira-hub import`, сверка с CSV. diff --git a/openspec/changes/folder-transport/.openspec.yaml b/openspec/changes/folder-transport/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/folder-transport/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/folder-transport/design.md b/openspec/changes/folder-transport/design.md new file mode 100644 index 0000000..afc5218 --- /dev/null +++ b/openspec/changes/folder-transport/design.md @@ -0,0 +1,83 @@ +## Context + +Мост из `bridge-core`: хаб (`jira-hub serve`, Mac mini, launchd, `0.0.0.0:8790`) и агент (`agent.py` на виртуалке, +Python 3.9, stdlib). Агент обращается к хабу шестью запросами (`claim` с ожиданием до 30 с, `settings`, `commands/{id}`, +`result`, `state`, `known-keys`); все идут через `Http.call()` агента, ошибки сведены к `ConnectionError` (хаб +недоступен) и `HttpError` (код ответа). Повторы отчёта, журнал `claimed/started/finished/acked` и восстановление опираются +только на эти два исключения. В хабе `serve()` уже держит фоновый поток `ticker`. + +При включённом VPN «Континент ZTN» сеть между виртуалкой и Mac mini закрыта (см. proposal.md). Общая папка UTM +(VirtioFS) проверена: круг 0,75–0,82 с, атомарное переименование видно целиком, удаления видны с обеих сторон. + +## Goals / Non-Goals + +Goals: агент работает с хабом при включённом Континенте; всё, что проверено прогоном на моке Jira 12.10.2026 (пакет всех +типов, повтор без записей, остановка в паузе, обрыв Jira), работает так же через папку; HTTP API хаба не меняется. +Non-goals — см. proposal.md. + +## Decisions + +1. **Реле внутри `serve` (вариант A, решение владельца).** Один сервис под launchd, один лог. Поток реле стартует всегда и + спит, пока `relay_dir` пуст; включение и выключение — правкой настройки, без перезапуска. +2. **Реле — HTTP-клиент своего же хаба.** Запрос из файла пересылается на `127.0.0.1:` с заголовком + `Authorization` из файла. Маршрутизация, проверка токенов, лимит тела и коды ошибок остаются в одном месте. +3. **Папка моста** `` с подпапками `req/` (запросы агента), `resp/` (ответы), `work/` (взятые реле запросы). + Обе стороны пишут файл под именем с точкой и переименовывают в `.json`; имена с точкой никто не читает. +4. **Формат.** Запрос: `{id, method, path, body, authorization, deadline}`; `deadline` — момент UTC (ISO 8601), после + которого ответ агенту не нужен. Ответ: `{id, status, body}`. +5. **Взятие запроса — переименование `req/.json` → `work/.json`.** Повторно его никто не возьмёт; каждый взятый + запрос обрабатывается в своём потоке, потому что `claim` держит ответ до 30 с. +6. **Ошибки агента — те же, что у HTTP.** Ответ 2xx → тело; иной код → `HttpError(status, body)`; нет ответа к `deadline` → + агент удаляет свой запрос (если его ещё не взяли) и выбрасывает `ConnectionError`. Runner, журнал и восстановление + не меняются. +7. **Защита `claim` по запасу времени.** Реле пересылает `POST /agent/claim`, только если до `deadline` осталось ≥ 35 с + (хаб держит `claim` ≤ 30 с); иначе запрос выбрасывается без ответа. Агент ставит для `claim` `deadline` = сейчас + + max(timeout, 45) с, как сейчас ставит таймаут HTTP. Это закрывает потерю ответа `claim` из-за медленной папки; общий + случай M-2 остаётся отложенным. +8. **Просроченные запросы выбрасываются без ответа** (агент уже ушёл с `ConnectionError`). Если `deadline` истёк после + пересылки, ответ всё равно пишется — его уберёт уборка. +9. **Хаб недоступен изнутри** (сбой соединения с `127.0.0.1`) — ответ со статусом 502 и текстом ошибки. +10. **Перезапуск реле**: файлы в `work/` удаляются с записью в лог; агент дождётся своего `deadline` и повторит. +11. **Уборка раз в минуту**: ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с, файлы с точкой + старше 10 мин. +12. **Папки нет** (SSD не подключён) — одно предупреждение в лог, реле ждёт и проверяет снова; подпапки `req/`, `resp/`, + `work/` реле создаёт само, когда `relay_dir` существует. +13. **Лог** — одна строка на запрос в лог хаба: `relay <секунды>`. +14. **Агент выбирает транспорт по `hub_url`**: `http://…`/`https://…` — как сейчас, `folder:<путь>` — файловый клиент + с тем же интерфейсом `HubClient`. `check` печатает «хаб: доступен через папку <путь>, настройки vN» или + «НЕДОСТУПЕН» с причиной (нет папки; реле не ответило за 10 с). Версия агента — 2. +15. **Токен агента остаётся в файле запроса.** Общая папка доступна только Mac mini и виртуалке; пароль Jira туда не + попадает (как и в HTTP-транспорте, хаб получает только результаты и состояние). + +## Risks / Trade-offs + +- **Доступ launchd к внешнему диску.** macOS может не пустить процесс из launchd на `/Volumes/externalSSD` без разрешения + «Съёмные тома» или «Полный доступ к диску». Проверяется первым шагом приёмки на настоящей папке; при отказе — выдать + доступ `uv`/python в «Конфиденциальность и безопасность». +- **Кеш VirtioFS** даёт постоянные ~0,75 с на круг. Для команд с паузами 30–120 с несущественно; `refresh` на 140 задач — + около 10 обменов. +- **Токен агента в файлах общей папки** живёт до ответа и уборки. Приемлемо для домашней пары машин. +- **Опоздавший ответ** на `result`/`state`: агент повторит запрос; `result` идемпотентен, повторная загрузка `state` + перезаписывает те же строки зеркала. + +## Migration Plan + +1. Слить PR хаба, перезапустить launchd-задание хаба; `settings_set relay_dir "/Volumes/externalSSD/utm/rshb/jira-bridge"`. +2. Прогон на моке Jira через временную папку (сценарий 12.10.2026), затем короткий прогон через настоящую папку. +3. Слить PR агента; на виртуалке заменить `agent.py`, в `agent.json` — `"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"`. +4. При включённом Континенте: `check` (обе строки «доступен»), запуск агента, `refresh full`, `jira-hub import`, сверка с CSV. +Откат: `relay_dir = null` и прежний `hub_url` (сеть работает только без Континента). + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-10-12 | Имя файла — `uuid4().hex` агента; ответ под тем же именем | уникальность без координации сторон | нет | +| 2026-10-12 | Тело ответа в файле — строка (как пришло от HTTP), агент разбирает JSON сам | реле не трактует ответы хаба; не-JSON ответ даёт ту же ошибку, что по HTTP | нет | +| 2026-10-12 | `deadline` — абсолютное время UTC, а не таймаут | реле на другой машине сравнивает со своими часами; Mac mini и виртуалка синхронизируют время с одного роутера/NTP | расхождение часов > 10 с ломает защиту `claim`; лечится запасом или переходом на относительный срок | +| 2026-10-12 | Реле перечитывает `relay_dir` раз в 5 с, опрос `req/` — каждые 0,1 с, агент опрашивает `resp/` каждые 0,1 с | быстрее не нужно (кеш VirtioFS ~0,75 с), реже — заметная задержка | лишние вызовы `listdir` — дёшево | +| 2026-10-12 | `relay_dir` — абсолютный путь строкой или `null`; относительный путь — 422 | путь читает процесс launchd с неочевидным cwd | нет | +| 2026-10-12 | Нечитаемый JSON запроса или ответа перечитывается до 2 с, затем запрос выбрасывается (реле) / `ConnectionError` (агент) | атомарное переименование делает это маловероятным (проверка: 0 случаев) | нет | +| 2026-10-12 | `relay_dir` не входит в настройки для агента (`AGENT_KEYS`) | агенту путь на стороне Mac mini не нужен | нет | +| 2026-10-13 | В `ARCHITECTURE.md` граница «HTTP живёт только в `api` и `client`» дополнена `relay` (R-1) | реле само ходит в HTTP своего хаба; иначе документ противоречит коду | нет | +| 2026-10-13 | В `tests/test_py39.py` агента в белый список stdlib добавлен `uuid` (R-2, подробно — `jira-agent/openspec/changes/folder-transport/design.md`) | спека требует `uuid4().hex` | нет | diff --git a/openspec/changes/folder-transport/proposal.md b/openspec/changes/folder-transport/proposal.md new file mode 100644 index 0000000..3742c7d --- /dev/null +++ b/openspec/changes/folder-transport/proposal.md @@ -0,0 +1,36 @@ +## Why + +Живой запуск моста 12.10.2026 упёрся в сеть: доступ к Jira банка на виртуалке даёт VPN «Континент ZTN», и его пакетный +фильтр (pf-anchor `continentztn`) закрывает локальную сеть в обе стороны — роутер `192.168.0.1` отвечает, Mac mini +(`192.168.0.108`) нет, `nc` на порт хаба 8790 — таймаут; входящие на виртуалку (`:22`) тоже режутся. Фильтр включается при +первом подключении Континента и остаётся загруженным после отключения VPN, до выхода из клиента или перезагрузки. Агенту +одновременно нужны Jira (только через VPN) и хаб, поэтому сеть для моста не годится. Править правила Континента нельзя — +это средство защиты банка. + +Канал, который фильтр не видит, — общая папка гипервизора UTM (VirtioFS): на Mac mini `/Volumes/externalSSD/utm/rshb`, +на виртуалке `/Volumes/My Shared Files/RSHB`. Проверка 12.10.2026 при включённом Континенте: 30 обменов из 30 через +временный файл и переименование, время круга 0,75–0,82 с независимо от размера (200 Б – 300 КБ), недописанных и +«залипших» удалённых файлов 0 с обеих сторон. + +Решения владельца 12.10.2026 (сессия brainstorming): транспорт через общую папку; вариант A — реле внутри `jira-hub serve` +(не отдельный процесс и не обмен пакетами целиком); HTTP API хаба и обработчики не меняются; у агента транспорт выбирается +по `hub_url`; HTTP-транспорт остаётся; дизайн частей 1–4 (формат обмена, ошибки и уборка, настройки и запуск, тесты) +одобрен. + +## What Changes + +- **jira-hub**: поток реле в `serve`, включаемый настройкой `relay_dir`. Реле забирает файлы-запросы агента из + `/req/`, пересылает их в свой HTTP на `127.0.0.1:` и кладёт ответы в `/resp/`. + Новая настройка `relay_dir` (строка или `null`), проверяется при записи. +- **jira-agent**: транспорт `folder:` — `hub_url = "folder:<путь>"` создаёт файловый клиент хаба с тем же интерфейсом, + ошибки сводятся к тем же `ConnectionError`/`HttpError`; `check` сообщает транспорт; версия агента 2. +- Спеки: `hub-api` (ADDED: реле), `data-model` (MODIFIED: настройки), `agent` (MODIFIED: состав и настройки; ADDED: + транспорт через общую папку). + +## Non-goals + +- Изменения HTTP API, обработчиков, токенов, очереди и контракта команд. +- Обмен пакетами команд целиком через файлы (вариант C), работа агента без хаба. +- Несколько агентов на одной папке, шифрование файлов в общей папке. +- Обход или настройка фильтра Континента; запрос исключения в ИБ банка — вне репозиториев. +- Исправление находки M-2 (потерянный ответ `claim`) в общем виде; здесь только защита по запасу времени для реле. diff --git a/openspec/changes/folder-transport/specs/agent/spec.md b/openspec/changes/folder-transport/specs/agent/spec.md new file mode 100644 index 0000000..527989f --- /dev/null +++ b/openspec/changes/folder-transport/specs/agent/spec.md @@ -0,0 +1,53 @@ +## MODIFIED Requirements + +### Requirement: Состав и настройки агента +Агент SHALL состоять из одного файла `agent.py` (Python ≥ 3.9, только стандартная библиотека) и файла `agent.json`: +`hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` или `bearer` с `token`), `verify_ssl`, +`ca_bundle`, `timeout`, `poll_seconds`. `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида +`folder:<абсолютный путь>` — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей, +доска, темп) агент SHALL получать от хаба при старте и при изменении `settings_version`. Учётные данные Jira MUST NOT +отправляться в хаб и MUST NOT попадать в журнал, логи и общую папку. + +#### Scenario: Проверка без записи +- **WHEN** `python3 agent.py check` +- **THEN** агент печатает доступность хаба (версия настроек) и Jira (`GET /myself`), в Jira ничего не пишет + +#### Scenario: Проверка через папку +- **WHEN** `hub_url = "folder:/Volumes/My Shared Files/RSHB/jira-bridge"` и реле хаба работает +- **THEN** `check` печатает «хаб: доступен через папку /Volumes/My Shared Files/RSHB/jira-bridge, настройки v, проект + »; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1 + +### Requirement: Доставка и запуск +README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json` +(включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist, +`prune`. Версия агента SHALL быть в константе файла (2 — с транспортом через папку) и в каждом отчёте; хаб предупреждает +в `agent_status`, если версия ниже минимальной из настроек. + +#### Scenario: Устаревший агент +- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2` +- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему + +## ADDED Requirements + +### Requirement: Транспорт через общую папку +При `hub_url = "folder:<путь>"` агент SHALL обращаться к хабу теми же запросами, что по HTTP, через файлы в `<путь>`: +на каждый вызов записать `req/.json` = `{id, method, path, body, authorization, deadline}` (`id` — `uuid4().hex`, +`authorization` — `Bearer `, `deadline` — момент UTC = сейчас + таймаут вызова; для `claim` таймаут не меньше +45 с) через временный файл с именем на точку и переименование, затем опрашивать `resp/.json` каждые 0,1 с. +Полученный ответ агент SHALL удалить; `status` 2xx — вернуть разобранное тело, иной — выбросить `HttpError(status, body)` +так же, как HTTP-клиент. Если к `deadline` ответа нет, агент SHALL удалить свой `req/.json` (если он ещё там) и +выбросить `ConnectionError`. Нечитаемый JSON ответа SHALL перечитываться до 2 с, затем `ConnectionError`. Чужие файлы +в `resp/` агент MUST NOT трогать. Цикл выполнения, журнал, повторы отчёта и восстановление SHALL работать без изменений. + +#### Scenario: Ответ хаба через папку +- **WHEN** агент вызвал `settings`, реле положило `resp/.json` со `status = 200` +- **THEN** агент получает настройки, файл ответа удалён + +#### Scenario: Реле не отвечает +- **WHEN** агент вызвал `claim`, а ответа нет 45 с +- **THEN** `req/.json` удалён агентом, выброшен `ConnectionError`; цикл пишет «хаб недоступен» и повторяет `claim` + через `poll_seconds`, как при недоступном хабе по сети + +#### Scenario: Код ошибки хаба через папку +- **WHEN** агент отправил `result` по команде, которой в хабе нет, реле положило ответ со `status = 404` +- **THEN** агент получает `HttpError(404)` и считает команду закрытой, как по HTTP diff --git a/openspec/changes/folder-transport/specs/data-model/spec.md b/openspec/changes/folder-transport/specs/data-model/spec.md new file mode 100644 index 0000000..a57e403 --- /dev/null +++ b/openspec/changes/folder-transport/specs/data-model/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`transitions_map`, `pacing`, `board_id`, `sprint_names`, `tokens` (агентский и клиентский), `settings_version` +(увеличивается при любой правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или +`null`, по умолчанию `null`; относительный путь и не-строка при записи — 422; агенту не передаётся). Начальные значения +SHALL импортироваться из `admin/jira-sync/config.example.json` командой `jira-hub init`; пароль и логин Jira в настройках +MUST NOT присутствовать. + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию + +#### Scenario: Неверный relay_dir +- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` +- **THEN** 422, значение не записано, `settings_version` не изменился diff --git a/openspec/changes/folder-transport/specs/hub-api/spec.md b/openspec/changes/folder-transport/specs/hub-api/spec.md new file mode 100644 index 0000000..352f055 --- /dev/null +++ b/openspec/changes/folder-transport/specs/hub-api/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Реле через общую папку +`jira-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле +бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если +каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует — +записать одно предупреждение в лог и проверять снова. + +Обмен: +- запрос агента — файл `req/.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC + (ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать; +- реле SHALL брать запрос переименованием в `work/.json` и обрабатывать каждый взятый запрос в отдельном потоке; +- запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до + `deadline` осталось не меньше 35 с, иначе выбрасываться без ответа; +- остальные запросы реле SHALL пересылать в свой HTTP на `127.0.0.1:` тем же методом, путём и телом, с заголовком + `Authorization` из файла, и записывать ответ `resp/.json` = `{id, status, body}` (тело — строка ответа HTTP) через + временный файл с именем на точку и переименование; затем удалять `work/.json`; +- если соединение с `127.0.0.1:` не удалось, ответ SHALL иметь `status = 502` и текст ошибки в `body`; +- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог. + +При старте реле SHALL удалить файлы из `work/` с записью в лог. Раз в минуту реле SHALL удалять ответы в `resp/` старше +10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку старше 10 мин. На каждый обработанный запрос +реле SHALL писать в лог хаба строку `relay <секунды>`. HTTP API, обработчики и проверка токенов +при этом не меняются. + +#### Scenario: Запрос через папку +- **WHEN** в `req/` появился `a1.json` с `GET /agent/settings`, агентским токеном и `deadline` через 60 с +- **THEN** в `resp/` появляется `a1.json` со `status = 200` и телом настроек; `work/a1.json` удалён + +#### Scenario: Чужой токен через папку +- **WHEN** в файле запроса `GET /issues` с агентским токеном +- **THEN** ответ со `status = 401`, как по HTTP + +#### Scenario: Просроченный claim +- **WHEN** реле взяло `POST /agent/claim`, до `deadline` которого осталось 20 с +- **THEN** запрос выброшен без ответа и без обращения к хабу; команда в очереди осталась `queued` + +#### Scenario: Долгий claim не задерживает другие запросы +- **WHEN** реле пересылает `claim`, который ждёт команду 30 с, и в это время в `req/` появляется `GET /agent/settings` +- **THEN** ответ на `settings` пишется, не дожидаясь `claim` + +#### Scenario: Папка не подключена +- **WHEN** `relay_dir` задан, но каталога нет; через минуту каталог появился +- **THEN** в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы 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/src/jira_hub/relay.py b/src/jira_hub/relay.py new file mode 100644 index 0000000..a6f93eb --- /dev/null +++ b/src/jira_hub/relay.py @@ -0,0 +1,235 @@ +"""Реле через общую папку (specs/hub-api, «Реле через общую папку»): файлы-запросы агента из /req/ +пересылаются в HTTP этого же хаба, ответы кладутся в /resp/. HTTP API, обработчики и токены не меняются; +реле для них — ещё один клиент. Нужно, когда VPN на виртуалке закрывает сеть до хаба (design.md folder-transport).""" +import json +import os +import threading +import time +import traceback +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from . import settings + +CLAIM_PATH = "/agent/claim" +CLAIM_MIN_LEFT = 35 # с: claim пересылается, только если до deadline осталось не меньше (хаб держит claim ≤ 30 с) +SETTINGS_EVERY = 5 # с: как часто перечитывать relay_dir +CLEANUP_EVERY = 60 # с: как часто убирать мусор +RESP_TTL = 600 # с: ответ, за которым агент не пришёл +REQ_GRACE = 60 # с после deadline: запрос, который никто не взял +TMP_TTL = 600 # с: брошенные временные файлы (имя на точку) +BAD_JSON_WAIT = 2 # с: сколько перечитывать нечитаемый запрос +FORWARD_TIMEOUT = 90 # с: ожидание ответа своего HTTP (claim держит до 30 с) +METHODS = ("GET", "POST", "PUT") + + +def say(msg: str) -> None: + print(f"[{time.strftime('%d/%b/%Y %H:%M:%S')}] {msg}", flush=True) + + +def parse_deadline(value) -> float | None: + """ISO 8601 → секунды эпохи; без зоны считается UTC; мусор → None.""" + if not isinstance(value, str): + return None + try: + dt = datetime.fromisoformat(value) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + + +def write_atomic(directory: Path, name: str, payload: dict) -> None: + """Записать JSON под временным именем на точку и переименовать: читатель видит файл только целиком.""" + tmp = Path(directory) / f".{name}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, Path(directory) / name) + + +class Relay: + def __init__(self, hub, port: int, host: str = "127.0.0.1", poll: float = 0.1): + self.hub, self.port, self.host, self.poll = hub, port, host, poll + self.root: Path | None = None + self._settings_at: float | None = None + self._cleanup_at = 0.0 + self._ready = False + self._warned: str | None = None + self.workers: list[threading.Thread] = [] + + # --- цикл --------------------------------------------------------------------- + def run(self, stop: threading.Event) -> None: + while not stop.is_set(): + try: + self.step() + except Exception: # noqa: BLE001 — сбой одного шага не останавливает реле + traceback.print_exc() + stop.wait(self.poll) + + def step(self) -> None: + now = time.time() + if self._settings_at is None or now - self._settings_at >= SETTINGS_EVERY: + self._settings_at = now + self._reload() + if self.root is None: + return + try: + if not self.root.is_dir(): + self._warn(f"relay: папки {self.root} нет — жду") + self._ready = False + return + if not self._ready: + self._prepare() + for path in sorted((self.root / "req").iterdir()): + if path.name.startswith(".") or not path.name.endswith(".json"): + continue + self._take(path) + except OSError as e: # нет доступа (TCC), подпапку удалили — подготовить заново на следующем шаге + self._ready = False + self._warn(f"relay: нет доступа к папке {self.root} — {e.strerror or e}; жду") + return + if now - self._cleanup_at >= CLEANUP_EVERY: + self._cleanup_at = now + self.cleanup(now) + + def _warn(self, msg: str) -> None: + """Одно предупреждение на причину: без потока одинаковых строк каждые 0,1 с.""" + if msg != self._warned: + say(msg) + self._warned = msg + + def join(self, timeout: float = 5) -> None: + for w in list(self.workers): + w.join(timeout) + + # --- папка ---------------------------------------------------------------------- + def _reload(self) -> None: + conn = self.hub.conn() + try: + value = settings.get(conn, "relay_dir") + finally: + conn.close() + root = Path(value) if value else None + if root != self.root: + self.root, self._ready, self._warned = root, False, None + say(f"relay: папка {root}" if root else "relay: выключено") + + def _prepare(self) -> None: + for sub in ("req", "resp", "work"): + (self.root / sub).mkdir(exist_ok=True) + for stale in (self.root / "work").iterdir(): + stale.unlink(missing_ok=True) + say(f"relay: удалён незавершённый запрос {stale.name} (перезапуск реле)") + self._ready, self._warned = True, None + + def _take(self, path: Path) -> None: + target = self.root / "work" / path.name + try: + os.rename(path, target) + except FileNotFoundError: + if path.exists(): # пропала work/ — step() подготовит папку заново + raise + return # агент успел удалить свой просроченный запрос + worker = threading.Thread(target=self.process, args=(self.root, target), daemon=True) + worker.start() + self.workers = [w for w in self.workers if w.is_alive()] + [worker] + + # --- обработка запроса ------------------------------------------------------------- + def process(self, root: Path, work: Path) -> None: + started = time.monotonic() + try: + req = self._read(work) + if req is None: + say(f"relay: нечитаемый запрос {work.name} — выброшен") + return + method, path = req.get("method"), req.get("path") + deadline = parse_deadline(req.get("deadline")) + left = deadline - time.time() if deadline is not None else -1.0 + if left <= 0 or (method == "POST" and path == CLAIM_PATH and left < CLAIM_MIN_LEFT): + say(f"relay {method} {path} просрочен (осталось {left:.0f} с) — выброшен") + return + (root / "resp").mkdir(exist_ok=True) # до пересылки: выданный claim не должен уйти в никуда + status, body = self.forward(method, path, req.get("body"), req.get("authorization")) + write_atomic(root / "resp", work.name, {"id": req.get("id"), "status": status, "body": body}) + say(f"relay {method} {path} {status} {time.monotonic() - started:.1f} с") + except OSError as e: # папка пропала на ходу — агент дождётся своего срока + say(f"relay: сбой файла {work.name}: {e}") + finally: + work.unlink(missing_ok=True) + + def _read(self, work: Path) -> dict | None: + end = time.monotonic() + BAD_JSON_WAIT + while True: + try: + with open(work, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else None + except (ValueError, OSError): + if time.monotonic() >= end: + return None + time.sleep(0.1) + + def forward(self, method, path, body, authorization) -> tuple[int, str]: + if method not in METHODS or not isinstance(path, str) or not path.startswith("/"): + return 400, _error("bad_request", f"неверный метод или путь: {method} {path}") + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + try: + req = urllib.request.Request(f"http://{self.host}:{self.port}{path}", data=data, method=method) + if authorization: + req.add_header("Authorization", str(authorization)) + if data is not None: + req.add_header("Content-Type", "application/json; charset=utf-8") + with urllib.request.urlopen(req, timeout=FORWARD_TIMEOUT) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + try: + return e.code, e.read().decode("utf-8", "replace") + finally: + e.close() + except ValueError as e: # недопустимый заголовок (перевод строки) или URL + return 400, _error("bad_request", str(e)) + except (urllib.error.URLError, OSError) as e: + return 502, _error("relay", f"хаб недоступен изнутри: {e}") + + # --- уборка ------------------------------------------------------------------------ + def cleanup(self, now: float) -> None: + if self.root is None or not self.root.is_dir(): + return + for p in _entries(self.root / "resp"): + ttl = TMP_TTL if p.name.startswith(".") else RESP_TTL + if _age(p, now) > ttl: + p.unlink(missing_ok=True) + for p in _entries(self.root / "req"): + if p.name.startswith("."): + if _age(p, now) > TMP_TTL: + p.unlink(missing_ok=True) + continue + try: + deadline = parse_deadline(json.loads(p.read_text(encoding="utf-8")).get("deadline")) + except (OSError, ValueError, AttributeError): + deadline = None + if deadline is None or now - deadline > REQ_GRACE: + p.unlink(missing_ok=True) + + +def _error(error: str, detail: str) -> str: + return json.dumps({"error": error, "detail": detail}, ensure_ascii=False) + + +def _entries(directory: Path) -> list[Path]: + try: + return list(directory.iterdir()) + except FileNotFoundError: + return [] + + +def _age(p: Path, now: float) -> float: + try: + return now - p.stat().st_mtime + except FileNotFoundError: + return 0.0 diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index 6a063c0..fc91e75 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -1,6 +1,7 @@ """Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version, агент перечитывает настройки, когда видит новую версию в ответе claim.""" import json +import os import secrets DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, @@ -29,6 +30,7 @@ DEFAULTS = { "transitions_map": {}, "link_types": {}, "board_id": None, + "relay_dir": None, } # Что получает агент (без токенов и служебных ключей хаба) @@ -47,6 +49,7 @@ def validate(key, value) -> None: """Проверить значение настройки до записи; ValueError — на 422 в API. pacing — объект {тип команды: целое >= 0} только с известными типами команд; backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535; + relay_dir — null или абсолютный путь строкой; остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None; неизвестные ключи и ключи с умолчанием None принимаются как есть.""" if key == "pacing": @@ -66,6 +69,10 @@ def validate(key, value) -> None: if not _is_int(value) or value < 0: raise ValueError(f"{key} — целое >= 0") return + if key == "relay_dir": + if value is not None and (not isinstance(value, str) or not os.path.isabs(value)): + raise ValueError("relay_dir — абсолютный путь папки реле или null") + return default = DEFAULTS.get(key) if default is not None and type(value) is not type(default): names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"} diff --git a/tests/test_api.py b/tests/test_api.py index 62d3ec1..652468a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -301,3 +301,15 @@ def test_full_refresh_marks_unseen_issue_as_neighbor(hub): # частичный refresh (по ключам) других задач ничего не помечает _run_refresh(hub, {"keys": ["A-1"]}, [{"key": "A-1", "summary": "т2", "labels": []}]) assert hub["client"].get("/issues/A-1")["neighbor"] is False + + +def test_relay_dir_put_validation(hub): + c = hub["client"] + before = c.get("/settings")["settings_version"] + with pytest.raises(HubError) as e: + c.put("/settings", {"relay_dir": "rshb/jira-bridge"}) + assert e.value.status == 422 + assert c.get("/settings")["settings_version"] == before + assert c.put("/settings", {"relay_dir": "/tmp/jira-bridge"})["settings_version"] == before + 1 + assert c.put("/settings", {"relay_dir": None})["settings_version"] == before + 2 + assert "relay_dir" not in hub["agent"].get("/agent/settings") diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..57c6f66 --- /dev/null +++ b/tests/test_relay.py @@ -0,0 +1,326 @@ +import json +import os +import threading +import time +from datetime import datetime, timedelta, timezone + +import pytest + +from jira_hub import api, relay, settings +from jira_hub.client import HubClient + + +@pytest.fixture +def env(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + root = tmp_path / "bridge" + root.mkdir() + settings.set_(conn, "relay_dir", str(root)) + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + r = relay.Relay(h, srv.server_port) + client = HubClient(f"http://127.0.0.1:{srv.server_port}", tok["client"]) + yield {"hub": h, "root": root, "relay": r, "agent": "Bearer " + tok["agent"], "client": client, + "port": srv.server_port} + srv.shutdown() + srv.server_close() + th.join(5) + + +def deadline(seconds): + return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat() + + +def put_request(root, rid, method, path, auth, body=None, seconds=60, **extra): + req = {"id": rid, "method": method, "path": path, "body": body, "authorization": auth, + "deadline": deadline(seconds), **extra} + relay.write_atomic(root / "req", f"{rid}.json", req) + + +def response(root, rid): + p = root / "resp" / f"{rid}.json" + return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None + + +def run_once(r): + r.step() + r.join() + + +def test_request_roundtrip(env): + r, root = env["relay"], env["root"] + r.step() # подготовка: req/, resp/, work/ + put_request(root, "a1", "GET", "/agent/settings", env["agent"]) + run_once(r) + resp = response(root, "a1") + assert resp["status"] == 200 and resp["id"] == "a1" + assert json.loads(resp["body"])["project_key"] == "AURORA" + assert not list((root / "work").iterdir()) and not list((root / "req").iterdir()) + + +def test_status_passes_through(env): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "u1", "GET", "/issues", env["agent"]) # агентский токен на клиентский маршрут + put_request(root, "u2", "GET", "/agent/commands/999", env["agent"]) + run_once(r) + assert response(root, "u1")["status"] == 401 + assert response(root, "u2")["status"] == 404 + + +def test_expired_and_short_claim_dropped(env): + r, root, c = env["relay"], env["root"], env["client"] + b = c.post("/batches", {"title": "t", "author": "test", + "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + r.step() + put_request(root, "c1", "POST", "/agent/claim", env["agent"], body={}, seconds=20) + put_request(root, "s1", "GET", "/agent/settings", env["agent"], seconds=-1) + run_once(r) + assert response(root, "c1") is None and response(root, "s1") is None + statuses = [x["status"] for x in c.get(f"/batches/{b['id']}")["commands"]] + assert statuses[0] == "queued" + assert not list((root / "work").iterdir()) + + +def test_claim_with_enough_time_is_forwarded(env): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "c2", "POST", "/agent/claim", env["agent"], body={"agent_version": 2}, seconds=50) + run_once(r) + assert response(root, "c2")["status"] == 200 + + +def test_long_claim_does_not_block_other_requests(env): + env["hub"].claim_wait = 3 + r, root = env["relay"], env["root"] + r.step() + put_request(root, "c3", "POST", "/agent/claim", env["agent"], body={}, seconds=60) + r.step() + put_request(root, "s3", "GET", "/agent/settings", env["agent"]) + t0 = time.monotonic() + while response(root, "s3") is None and time.monotonic() - t0 < 2.5: + r.step() + time.sleep(0.05) + assert response(root, "s3")["status"] == 200 + assert response(root, "c3") is None # claim ещё ждёт + r.join() + assert response(root, "c3")["status"] == 200 + + +def test_unreachable_hub_gives_502(env): + r = relay.Relay(env["hub"], 1) # на порту 1 никто не слушает + root = env["root"] + r.step() + put_request(root, "p1", "GET", "/agent/settings", env["agent"]) + run_once(r) + resp = response(root, "p1") + assert resp["status"] == 502 and "хаб недоступен" in resp["body"] + + +def test_dotfiles_ignored_and_bad_json_dropped(env, monkeypatch, capsys): + monkeypatch.setattr(relay, "BAD_JSON_WAIT", 0.2) + r, root = env["relay"], env["root"] + r.step() + (root / "req" / ".x1.json.tmp").write_text("{}", encoding="utf-8") + (root / "req" / "bad.json").write_text("{не json", encoding="utf-8") + run_once(r) + assert (root / "req" / ".x1.json.tmp").exists() + assert response(root, "bad") is None and not list((root / "work").iterdir()) + assert "нечитаемый запрос bad.json" in capsys.readouterr().out + + +def test_bad_request_fields_get_400(env): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "b1", "GET", "agent/settings", env["agent"]) # путь без / + put_request(root, "b2", "DELETE", "/agent/settings", env["agent"]) # метод не из списка + put_request(root, "b3", "GET", "/agent/settings", "Bearer x\r\nX-Evil: 1") # перевод строки в заголовке + run_once(r) + for rid in ("b1", "b2", "b3"): + assert response(root, rid)["status"] == 400, rid + + +def test_missing_deadline_is_dropped(env): + r, root = env["relay"], env["root"] + r.step() + relay.write_atomic(root / "req", "d1.json", {"id": "d1", "method": "GET", "path": "/agent/settings", + "authorization": env["agent"]}) + relay.write_atomic(root / "req", "d2.json", {"id": "d2", "method": "GET", "path": "/agent/settings", + "authorization": env["agent"], "deadline": "завтра"}) + run_once(r) + assert response(root, "d1") is None and response(root, "d2") is None + + +def test_vanished_request_is_skipped(env, capsys): + r, root = env["relay"], env["root"] + r.step() + put_request(root, "v1", "GET", "/agent/settings", env["agent"]) + orig = os.rename + + def vanish(src, dst): + os.remove(src) # агент удалил свой запрос раньше реле + return orig(src, dst) + + os.rename = vanish + try: + run_once(r) + finally: + os.rename = orig + assert response(root, "v1") is None + assert "Traceback" not in capsys.readouterr().err + + +def test_work_cleared_on_prepare(env, capsys): + r, root = env["relay"], env["root"] + (root / "work").mkdir() + (root / "work" / "old.json").write_text("{}", encoding="utf-8") + r.step() + assert not (root / "work" / "old.json").exists() + assert "old.json" in capsys.readouterr().out + + +def test_missing_dir_warns_once_then_works(env, capsys): + r, root = env["relay"], env["root"] + gone = root.parent / "bridge-later" + conn = env["hub"].conn() + settings.set_(conn, "relay_dir", str(gone)) + conn.close() + for _ in range(5): + r.step() + out = capsys.readouterr().out + assert out.count("нет — жду") == 1 + gone.mkdir() + r.step() + assert (gone / "req").is_dir() and (gone / "resp").is_dir() and (gone / "work").is_dir() + put_request(gone, "m1", "GET", "/agent/settings", env["agent"]) + run_once(r) + assert response(gone, "m1")["status"] == 200 + + +def test_disabled_and_reenabled_without_restart(env, monkeypatch): + r, root = env["relay"], env["root"] + conn = env["hub"].conn() + settings.set_(conn, "relay_dir", None) + conn.close() + r.step() + assert r.root is None and not (root / "req").exists() + monkeypatch.setattr(relay, "SETTINGS_EVERY", 0) + conn = env["hub"].conn() + settings.set_(conn, "relay_dir", str(root)) + conn.close() + r.step() + assert r.root == root and (root / "req").is_dir() + + +def test_cleanup_by_age(env): + r, root = env["relay"], env["root"] + r.step() + old, fresh = root / "resp" / "old.json", root / "resp" / "fresh.json" + tmp_old = root / "resp" / ".t.json.tmp" + for p in (old, fresh, tmp_old): + p.write_text("{}", encoding="utf-8") + now = time.time() + os.utime(old, (now - 700, now - 700)) + os.utime(tmp_old, (now - 700, now - 700)) + stale_req = root / "req" / ".q.json.tmp" + stale_req.write_text("{}", encoding="utf-8") + os.utime(stale_req, (now - 700, now - 700)) + r.cleanup(now) + assert not old.exists() and not tmp_old.exists() and not stale_req.exists() + assert fresh.exists() + + +def test_log_line(env, capsys): + r, root = env["relay"], env["root"] + r.step() + 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) + + +def test_permission_denied_warns_once_then_works(env, capsys): + r, root = env["relay"], env["root"] + locked = root.parent / "locked" + target = locked / "bridge" + target.mkdir(parents=True) + conn = env["hub"].conn() + settings.set_(conn, "relay_dir", str(target)) + conn.close() + locked.chmod(0) # так выглядит отказ доступа (TCC) у процесса launchd + try: + for _ in range(5): + r.step() + finally: + locked.chmod(0o755) + out, err = capsys.readouterr() + assert out.count("нет доступа к папке") == 1 + assert "Traceback" not in err + r.step() + put_request(target, "p1", "GET", "/agent/settings", env["agent"]) + run_once(r) + assert response(target, "p1")["status"] == 200 + + +def test_removed_req_dir_recovers(env, capsys): + r, root = env["relay"], env["root"] + r.step() + (root / "req").rmdir() # папку моста почистили при живом реле + for _ in range(3): + r.step() + assert (root / "req").is_dir() + assert "Traceback" not in capsys.readouterr().err + put_request(root, "q1", "GET", "/agent/settings", env["agent"]) + run_once(r) + assert response(root, "q1")["status"] == 200 + + +def test_removed_work_dir_recovers(env, capsys): + r, root = env["relay"], env["root"] + r.step() + (root / "work").rmdir() + put_request(root, "w1", "GET", "/agent/settings", env["agent"]) + for _ in range(3): + r.step() + r.join() + assert response(root, "w1")["status"] == 200 + assert "нет доступа к папке" in capsys.readouterr().out + + +def test_removed_resp_dir_recreated_before_forward(env): + r, root = env["relay"], env["root"] + r.step() + (root / "resp").rmdir() + put_request(root, "r1", "GET", "/agent/settings", env["agent"]) + run_once(r) + assert response(root, "r1")["status"] == 200 diff --git a/tests/test_settings.py b/tests/test_settings.py index 6ec2466..7d689be 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -53,3 +53,16 @@ def test_init_from_config(): with pytest.raises(RuntimeError): settings.init_from_config(conn, cfg) settings.init_from_config(conn, cfg, force=True) + + +def test_relay_dir_default_and_validation(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + settings.ensure_defaults(conn) + assert settings.get(conn, "relay_dir") is None + settings.validate("relay_dir", None) + settings.validate("relay_dir", "/Volumes/externalSSD/utm/rshb/jira-bridge") + for bad in ("rshb/jira-bridge", "", 5, ["/x"]): + with pytest.raises(ValueError): + settings.validate("relay_dir", bad) + assert "relay_dir" not in settings.AGENT_KEYS + conn.close()