# Реле хаба через общую папку — план реализации (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.