Merge pull request 'feat: транспорт folder: — связь с хабом через общую папку (#5)' (#6) from feat/5-folder-transport into main
This commit is contained in:
commit
6342e47230
@ -9,6 +9,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
||||
|---|---|---|---|
|
||||
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
|
||||
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `urllib` |
|
||||
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
|
||||
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` | файловая система |
|
||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
|
||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
Шаблон без секретов — `src/agent.example.json`.
|
||||
|
||||
## Архитектура
|
||||
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`),
|
||||
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `jira-hub/openspec/changes/folder-transport/`),
|
||||
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
|
||||
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `main`).
|
||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
|
||||
|
||||
17
README.md
17
README.md
@ -11,7 +11,7 @@
|
||||
|
||||
1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`.
|
||||
2. Переименовать `agent.example.json` в `agent.json` и заполнить:
|
||||
- `hub_url` — `http://192.168.0.106:8790`;
|
||||
- `hub_url` — `http://<адрес Mac mini>:8790`, или, если сеть до Mac mini закрыта VPN (Континент), `folder:/Volumes/My Shared Files/RSHB/jira-bridge` — обмен через общую папку UTM (на хабе должна быть задана настройка `relay_dir`, см. README хаба);
|
||||
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
|
||||
- `jira.base_url` — адрес Jira банка;
|
||||
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
|
||||
@ -41,6 +41,19 @@ python3 agent.py check
|
||||
Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1.
|
||||
Команда ничего не пишет в Jira.
|
||||
|
||||
## Связь с хабом через общую папку
|
||||
|
||||
Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`:
|
||||
|
||||
```json
|
||||
"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"
|
||||
```
|
||||
|
||||
Агент пишет каждый запрос к хабу файлом `req/<id>.json` в этой папке и ждёт ответ `resp/<id>.json` от реле хаба
|
||||
(круг ≈ 0,8 с). Если ответа нет до срока — то же, что недоступный хаб по сети: агент пишет в лог и повторяет. Пароль Jira
|
||||
в папку не попадает; в файлах запросов есть только токен хаба. `check` в этом режиме печатает
|
||||
`хаб: доступен через папку …` или `НЕДОСТУПЕН` (нет папки; реле не ответило за 10 с).
|
||||
|
||||
## Запуск
|
||||
|
||||
В терминале:
|
||||
@ -137,7 +150,7 @@ python3 agent.py prune
|
||||
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||
по сигналу команду подхватит восстановление.
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 1) отправляется в каждом `claim`, в каждом отчёте
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 2) отправляется в каждом `claim`, в каждом отчёте
|
||||
(`agent_version`) и в `/agent/state`.
|
||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||
при этом выдаются по-прежнему.
|
||||
|
||||
555
docs/superpowers/plans/2026-10-12-folder-transport-agent.md
Normal file
555
docs/superpowers/plans/2026-10-12-folder-transport-agent.md
Normal file
@ -0,0 +1,555 @@
|
||||
# Транспорт агента через общую папку — план реализации (jira-agent)
|
||||
|
||||
> **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:** `hub_url = "folder:<путь>"` переключает агента на обмен с хабом через файлы в общей папке UTM, без изменений цикла, журнала и восстановления.
|
||||
|
||||
**Architecture:** В `src/agent.py` появляется `FolderTransport` — `call(method, path, body, timeout)` пишет `req/<id>.json` атомарно, ждёт `resp/<id>.json`, сводит ответ к тем же исключениям, что `Http.call` (`HttpError`, `ConnectionError`). `HubClient` при `folder:` делегирует `call` транспорту; остальные методы `HubClient` и `Runner` не меняются. `check` сообщает транспорт и ждёт хаб через папку не дольше 10 с. Версия агента 2.
|
||||
|
||||
**Tech Stack:** Python 3.9.6 (системный `/usr/bin/python3`), только stdlib (`uuid`, `os`, `json`, `time`, `datetime`), unittest.
|
||||
|
||||
**Spec:** `../jira-hub/openspec/changes/folder-transport/` (design.md, specs/agent). Реле на стороне хаба — план `jira-hub/docs/superpowers/plans/2026-10-12-folder-transport-hub.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python ≥ 3.9, только стандартная библиотека; `tests/test_py39.py` должен оставаться зелёным.
|
||||
- Формат запроса `req/<id>.json`: `{id, method, path, body, authorization, deadline}`; `id` — `uuid.uuid4().hex`; `authorization` — `"Bearer <hub_token>"`; `deadline` — ISO 8601 UTC = сейчас + таймаут вызова; для `claim` таймаут ≥ 45 с (как сейчас в `HubClient.claim`).
|
||||
- Формат ответа `resp/<id>.json`: `{id, status, body}`, `body` — строка; 2xx → разобранный JSON (`{}` для пустой строки), иначе `HttpError(status, body)`.
|
||||
- Запись — во временный файл с именем на точку и `os.replace`; агент MUST NOT трогать чужие файлы в `resp/`.
|
||||
- Нет ответа к сроку — удалить свой `req/<id>.json`, если он ещё там, и `ConnectionError`; нечитаемый ответ — перечитывать до 2 с, затем `ConnectionError`. Опрос `resp/` — каждые 0,1 с.
|
||||
- Нет каталога `<путь>` — `ConnectionError("нет папки …")` сразу; подкаталоги `req/`, `resp/` агент создаёт сам при их отсутствии.
|
||||
- `hub_url` вида `folder:` с неабсолютным путём — `load_config` завершает процесс с понятной ошибкой.
|
||||
- `check`: «хаб: доступен через папку <путь>, настройки v<N>, проект <KEY>»; ожидание хаба через папку ≤ 10 с.
|
||||
- `AGENT_VERSION = 2`.
|
||||
- Учётные данные Jira не попадают в файлы общей папки.
|
||||
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — весь набор зелёный. Коммиты на русском, трейлер `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Решения, принятые при исполнении без обсуждения, — в `openspec/changes/folder-transport/design.md` этого репозитория (раздел `## Decisions made without discussion`, со ссылкой на спеку хаба), как было в `bridge-core`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Путь общей папки с пробелами (`/Volumes/My Shared Files/RSHB/jira-bridge`) — всё работает, путь не режется и не экранируется (тесты в Task 1 используют временный каталог с пробелом в имени).
|
||||
2. Ответ реле лежит в `resp/`, но ещё недописан (временный файл переименовался, а содержимое кривое) — агент перечитывает до 2 с, потом `ConnectionError`, не падает с `ValueError` (тест `test_bad_json_response_gives_connection_error`).
|
||||
3. Реле ответило после того, как агент сдался, — опоздавший ответ остаётся в `resp/` (его уберёт реле), следующий вызов агента его не подхватывает (тест `test_late_response_is_not_reused`).
|
||||
4. `hub_url = "folder:relative/path"` или `"folder:"` — понятный выход из `load_config`, а не трассировка в цикле (тест `test_relative_folder_url_rejected` в Task 2).
|
||||
5. В `resp/` лежит чужой файл (ответ другому агенту/старый) — агент его не удаляет и не читает (тест `test_foreign_files_untouched`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `FolderTransport` и `HubClient` через папку
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` (импорт `uuid`; новый класс `FolderTransport` перед `class HubClient`; `HubClient.__init__` и новый `HubClient.call`)
|
||||
- Create: `tests/fake_relay.py`
|
||||
- Test: `tests/test_folder.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Http`, `HttpError`, `HubClient` из `src/agent.py`; `FakeHub` из `tests/fake_hub.py`.
|
||||
- Produces:
|
||||
- `agent.FolderTransport(root: str, authorization: str)`; атрибуты `root`, `POLL = 0.1`, `BAD_JSON_WAIT = 2.0`;
|
||||
- `FolderTransport.call(method: str, path: str, body=None, timeout: float = 60) -> Any` — `ConnectionError`/`HttpError` как у `Http.call`;
|
||||
- `HubClient.folder: Optional[FolderTransport]` (`None` для `http://`);
|
||||
- `HubClient.call(method, path, body=None, params=None, timeout=None)` — при `folder` делегирует, иначе `Http.call`;
|
||||
- `tests/fake_relay.FakeRelay(root: str, target_base_url: str, mode: str = "forward")` с `start()`, `stop()`, `requests: list[dict]`; режимы `"forward"` (переслать в HTTP), `"silent"` (забирать и молчать), `"garbage"` (класть нечитаемый ответ), `"late"` (ответ через `delay` с после срока).
|
||||
|
||||
- [ ] **Step 1: Фейковое реле для тестов**
|
||||
|
||||
Создать `tests/fake_relay.py`:
|
||||
|
||||
```python
|
||||
"""Фейковое реле хаба для тестов агента: забирает req/<id>.json из папки и отвечает resp/<id>.json.
|
||||
Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать;
|
||||
garbage — положить нечитаемый ответ; late — ответить через delay секунд."""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def write_atomic(directory, name, payload):
|
||||
tmp = os.path.join(directory, "." + name + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
|
||||
os.replace(tmp, os.path.join(directory, name))
|
||||
|
||||
|
||||
class FakeRelay:
|
||||
def __init__(self, root, target_base_url="", mode="forward", delay=0.0):
|
||||
self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay
|
||||
self.requests = []
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
for sub in ("req", "resp"):
|
||||
os.makedirs(os.path.join(root, sub), exist_ok=True)
|
||||
|
||||
def start(self):
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
self._thread.join(5)
|
||||
|
||||
def _loop(self):
|
||||
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
|
||||
while not self._stop.is_set():
|
||||
for name in sorted(os.listdir(req_dir)):
|
||||
if name.startswith(".") or not name.endswith(".json"):
|
||||
continue
|
||||
path = os.path.join(req_dir, name)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
req = json.load(f)
|
||||
os.remove(path)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
self.requests.append(req)
|
||||
if self.mode == "silent":
|
||||
continue
|
||||
if self.mode == "garbage":
|
||||
write_atomic(resp_dir, name, "{не json")
|
||||
continue
|
||||
if self.mode == "late":
|
||||
time.sleep(self.delay)
|
||||
status, body = self._forward(req)
|
||||
write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body})
|
||||
time.sleep(0.02)
|
||||
|
||||
def _forward(self, req):
|
||||
data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None
|
||||
r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"])
|
||||
r.add_header("Authorization", req["authorization"])
|
||||
if data is not None:
|
||||
r.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=10) as resp:
|
||||
return resp.status, resp.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, e.read().decode("utf-8")
|
||||
finally:
|
||||
e.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Написать падающие тесты**
|
||||
|
||||
Создать `tests/test_folder.py`:
|
||||
|
||||
```python
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from _common import SETTINGS, agent
|
||||
from fake_hub import FakeHub
|
||||
from fake_relay import FakeRelay
|
||||
|
||||
|
||||
class FolderTransportTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") # пробелы в пути
|
||||
os.makedirs(self.root)
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
self.relay = None
|
||||
|
||||
def tearDown(self):
|
||||
if self.relay:
|
||||
self.relay.stop()
|
||||
self.hub.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def client(self, timeout=5):
|
||||
cfg = {"hub_url": "folder:" + self.root, "hub_token": self.hub.token, "timeout": timeout}
|
||||
return agent.HubClient(cfg)
|
||||
|
||||
def run_relay(self, mode="forward", delay=0.0):
|
||||
self.relay = FakeRelay(self.root, self.hub.base_url, mode, delay).start()
|
||||
return self.relay
|
||||
|
||||
def listing(self, sub):
|
||||
return sorted(os.listdir(os.path.join(self.root, sub)))
|
||||
|
||||
def test_settings_through_folder(self):
|
||||
self.run_relay()
|
||||
hub = self.client()
|
||||
self.assertIsNotNone(hub.folder)
|
||||
self.assertEqual(hub.settings()["project_key"], "AURORA")
|
||||
self.assertEqual(self.listing("resp"), [])
|
||||
self.assertEqual(self.listing("req"), [])
|
||||
req = self.relay.requests[0]
|
||||
self.assertEqual(req["method"], "GET")
|
||||
self.assertEqual(req["path"], "/agent/settings")
|
||||
self.assertEqual(req["authorization"], "Bearer " + self.hub.token)
|
||||
self.assertEqual(len(req["id"]), 32)
|
||||
|
||||
def test_error_status_raises_http_error(self):
|
||||
self.run_relay()
|
||||
with self.assertRaises(agent.HttpError) as e:
|
||||
self.client().result(999, {"status": "done"})
|
||||
self.assertEqual(e.exception.code, 404)
|
||||
|
||||
def test_claim_deadline_at_least_45_seconds(self):
|
||||
self.run_relay()
|
||||
before = time.time()
|
||||
self.client(timeout=5).claim(2, 1, None)
|
||||
deadline = datetime.fromisoformat(self.relay.requests[0]["deadline"])
|
||||
self.assertIsNotNone(deadline.tzinfo)
|
||||
self.assertGreaterEqual(deadline.timestamp() - before, 44.5)
|
||||
|
||||
def test_no_answer_gives_connection_error_and_removes_request(self):
|
||||
hub = self.client(timeout=0.5)
|
||||
t0 = time.monotonic()
|
||||
with self.assertRaises(ConnectionError) as e:
|
||||
hub.settings()
|
||||
self.assertLess(time.monotonic() - t0, 2)
|
||||
self.assertIn("не ответил через папку", str(e.exception))
|
||||
self.assertEqual([n for n in self.listing("req") if not n.startswith(".")], [])
|
||||
|
||||
def test_missing_folder_gives_connection_error(self):
|
||||
cfg = {"hub_url": "folder:" + os.path.join(self.tmp.name, "нет"), "hub_token": "t", "timeout": 5}
|
||||
with self.assertRaises(ConnectionError) as e:
|
||||
agent.HubClient(cfg).settings()
|
||||
self.assertIn("нет папки", str(e.exception))
|
||||
|
||||
def test_bad_json_response_gives_connection_error(self):
|
||||
self.run_relay("garbage")
|
||||
hub = self.client()
|
||||
hub.folder.BAD_JSON_WAIT = 0.3
|
||||
with self.assertRaises(ConnectionError) as e:
|
||||
hub.settings()
|
||||
self.assertIn("нечитаемый ответ", str(e.exception))
|
||||
|
||||
def test_late_response_is_not_reused(self):
|
||||
self.run_relay("late", delay=1.0)
|
||||
with self.assertRaises(ConnectionError):
|
||||
self.client(timeout=0.3).settings()
|
||||
time.sleep(1.3) # опоздавший ответ лёг в resp/
|
||||
late = self.listing("resp")
|
||||
self.assertEqual(len(late), 1)
|
||||
self.relay.mode = "forward"
|
||||
self.assertEqual(self.client().settings()["project_key"], "AURORA")
|
||||
self.assertEqual(self.listing("resp"), late) # чужой (опоздавший) ответ не тронут
|
||||
|
||||
def test_foreign_files_untouched(self):
|
||||
resp_dir = os.path.join(self.root, "resp")
|
||||
os.makedirs(resp_dir, exist_ok=True)
|
||||
foreign = os.path.join(resp_dir, "0" * 32 + ".json")
|
||||
with open(foreign, "w", encoding="utf-8") as f:
|
||||
json.dump({"id": "0" * 32, "status": 200, "body": "{}"}, f)
|
||||
self.run_relay()
|
||||
self.client().settings()
|
||||
self.assertTrue(os.path.exists(foreign))
|
||||
|
||||
def test_http_url_keeps_http_transport(self):
|
||||
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
|
||||
hub = agent.HubClient(cfg)
|
||||
self.assertIsNone(hub.folder)
|
||||
self.assertEqual(hub.settings()["project_key"], "AURORA")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Убедиться, что падают**
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_folder -v` (из корня репозитория; если импорт `_common` не находится — `/usr/bin/python3 -m unittest discover -s tests -t . -p test_folder.py -v`)
|
||||
Expected: FAIL — у `HubClient` нет атрибута `folder`.
|
||||
|
||||
- [ ] **Step 4: Реализация**
|
||||
|
||||
В `src/agent.py`:
|
||||
- в импорты добавить `import uuid` (по алфавиту, после `import urllib.request`);
|
||||
- перед `class HubClient(Http):` вставить:
|
||||
|
||||
```python
|
||||
class FolderTransport:
|
||||
"""Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке).
|
||||
Запрос — файл req/<id>.json, ответ кладёт реле хаба в resp/<id>.json. Ошибки те же, что у Http.call:
|
||||
нет ответа к сроку или нет папки — ConnectionError, код не 2xx — HttpError. Спека: specs/agent, folder-transport."""
|
||||
POLL = 0.1
|
||||
BAD_JSON_WAIT = 2.0
|
||||
|
||||
def __init__(self, root: str, authorization: str):
|
||||
self.root, self.authorization = root, authorization
|
||||
|
||||
def __repr__(self):
|
||||
return "<FolderTransport %s>" % self.root
|
||||
|
||||
def call(self, method: str, path: str, body=None, timeout: float = 60) -> Any:
|
||||
if not os.path.isdir(self.root):
|
||||
raise ConnectionError("нет папки %s" % self.root)
|
||||
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
|
||||
rid = uuid.uuid4().hex
|
||||
try:
|
||||
os.makedirs(req_dir, exist_ok=True)
|
||||
os.makedirs(resp_dir, exist_ok=True)
|
||||
deadline = time.time() + timeout
|
||||
self._write(req_dir, rid + ".json", {
|
||||
"id": rid, "method": method, "path": path, "body": body, "authorization": self.authorization,
|
||||
"deadline": datetime.fromtimestamp(deadline, timezone.utc).isoformat()})
|
||||
except OSError as e:
|
||||
raise ConnectionError("папка %s недоступна: %s" % (self.root, e)) from None
|
||||
resp_path = os.path.join(resp_dir, rid + ".json")
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if os.path.exists(resp_path):
|
||||
return self._take(resp_path)
|
||||
time.sleep(self.POLL)
|
||||
try:
|
||||
os.remove(os.path.join(req_dir, rid + ".json"))
|
||||
except OSError:
|
||||
pass
|
||||
raise ConnectionError("хаб не ответил через папку %s за %.0f с" % (self.root, timeout))
|
||||
|
||||
@staticmethod
|
||||
def _write(directory: str, name: str, payload: dict) -> None:
|
||||
tmp = os.path.join(directory, "." + 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, os.path.join(directory, name))
|
||||
|
||||
def _take(self, path: str) -> Any:
|
||||
end = time.monotonic() + self.BAD_JSON_WAIT
|
||||
while True:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
resp = json.load(f)
|
||||
if not isinstance(resp, dict):
|
||||
raise ValueError("ответ не объект")
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
if time.monotonic() >= end:
|
||||
raise ConnectionError("нечитаемый ответ хаба в %s" % path) from None
|
||||
time.sleep(self.POLL)
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
status, text = int(resp.get("status") or 0), resp.get("body") or ""
|
||||
if not 200 <= status < 300:
|
||||
raise HttpError(status, text)
|
||||
try:
|
||||
return json.loads(text) if text else {}
|
||||
except ValueError as e:
|
||||
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, text[:200])) from None
|
||||
```
|
||||
|
||||
- заменить `HubClient.__init__` и добавить `call`:
|
||||
|
||||
```python
|
||||
class HubClient(Http):
|
||||
def __init__(self, cfg: dict):
|
||||
auth = "Bearer " + cfg["hub_token"]
|
||||
super().__init__(cfg["hub_url"], {"Authorization": auth}, cfg.get("timeout", 60))
|
||||
url = cfg["hub_url"]
|
||||
self.folder = FolderTransport(url[len("folder:"):], auth) if url.startswith("folder:") else None
|
||||
|
||||
def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
|
||||
if self.folder is None:
|
||||
return super().call(method, path, body, params, timeout)
|
||||
if params:
|
||||
path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||||
return self.folder.call(method, path, body, timeout or self.timeout)
|
||||
```
|
||||
|
||||
(Методы `claim`, `settings`, `command`, `result`, `state`, `known_keys` не меняются.)
|
||||
|
||||
- [ ] **Step 5: Тесты зелёные**
|
||||
|
||||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
Expected: OK (весь набор, включая `test_py39`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent.py tests/fake_relay.py tests/test_folder.py
|
||||
git commit -m "feat(agent): транспорт folder: — обмен с хабом через общую папку (#5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Выбор транспорта в настройках, `check`, версия 2, документация
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` (`AGENT_VERSION`, `load_config`, `cmd_check`, docstring модуля)
|
||||
- Modify: `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`, `src/agent.example.json` (не меняется по ключам — только README объясняет `folder:`)
|
||||
- Create: `openspec/changes/folder-transport/design.md` (решения агента без обсуждения; если их нет — файл с заголовком, ссылкой на спеку хаба и пустой таблицей)
|
||||
- Test: `tests/test_folder.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `FolderTransport`, `HubClient.folder`, `FakeRelay` из Task 1; `Runner`, `Journal`, `JiraClient`, `FakeJira`, `jira_cfg`, `no_sleep` из существующих тестов.
|
||||
- Produces: `agent.AGENT_VERSION == 2`; `cmd_check` печатает «хаб: доступен через папку <путь>, …»; `load_config` завершает процесс при `folder:` без абсолютного пути.
|
||||
|
||||
- [ ] **Step 1: Падающие тесты**
|
||||
|
||||
Добавить в `tests/test_folder.py` (новые импорты в шапку: `import io`, `from contextlib import redirect_stdout`, `from _common import jira_cfg, no_sleep`, `from fake_jira import FakeJira`):
|
||||
|
||||
```python
|
||||
class FolderIntegrationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge")
|
||||
os.makedirs(self.root)
|
||||
self.fake = FakeJira()
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
self.relay = FakeRelay(self.root, self.hub.base_url).start()
|
||||
|
||||
def tearDown(self):
|
||||
self.relay.stop()
|
||||
self.fake.stop()
|
||||
self.hub.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def cfg(self):
|
||||
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
||||
|
||||
def test_version_is_2(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 2)
|
||||
|
||||
def test_runner_through_folder(self):
|
||||
self.fake.add("AURORA-1", duedate="2026-10-20")
|
||||
cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||||
cfg = self.cfg()
|
||||
journal = agent.Journal(os.path.join(self.tmp.name, "journal.jsonl"))
|
||||
runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), journal)
|
||||
runner.sleep = lambda s: None
|
||||
runner.jira.sleep = no_sleep
|
||||
runner.idle_limit = 1
|
||||
runner.loop()
|
||||
self.assertEqual(self.hub.results[cid]["status"], "done")
|
||||
self.assertEqual(self.hub.results[cid]["agent_version"], 2)
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-23")
|
||||
self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()])
|
||||
|
||||
def test_check_reports_folder(self):
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
code = agent.cmd_check(self.cfg())
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("хаб: доступен через папку %s, настройки v1, проект AURORA" % self.root, out.getvalue())
|
||||
|
||||
def test_check_folder_silent_relay(self):
|
||||
self.relay.stop()
|
||||
self.relay = FakeRelay(self.root, self.hub.base_url, "silent").start()
|
||||
out = io.StringIO()
|
||||
t0 = time.monotonic()
|
||||
with redirect_stdout(out):
|
||||
code = agent.cmd_check(self.cfg())
|
||||
self.assertEqual(code, 1)
|
||||
self.assertLess(time.monotonic() - t0, 13)
|
||||
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
|
||||
self.assertIn("не ответил через папку", out.getvalue())
|
||||
|
||||
def test_relative_folder_url_rejected(self):
|
||||
path = os.path.join(self.tmp.name, "agent.json")
|
||||
for url in ("folder:relative/path", "folder:"):
|
||||
cfg = self.cfg()
|
||||
cfg["hub_url"] = url
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f)
|
||||
with self.assertRaises(SystemExit) as e:
|
||||
agent.load_config(path)
|
||||
self.assertIn("абсолютный путь", str(e.exception.code))
|
||||
```
|
||||
|
||||
Проверить по `tests/test_runner.py`, что `FakeHub.results[cid]` содержит `agent_version` (отчёт агента включает его по рулингу R-12 bridge-core); если ключ называется иначе — взять имя из `Runner` и поправить ассерт.
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest discover -s tests -t . -p test_folder.py -v`
|
||||
Expected: FAIL — `AGENT_VERSION == 1`, `check` без «через папку», относительный путь принимается. `test_runner_through_folder` может пройти уже после Task 1 — это нормально (он страхует цикл целиком).
|
||||
|
||||
- [ ] **Step 2: Реализация**
|
||||
|
||||
В `src/agent.py`:
|
||||
- `AGENT_VERSION = 2`;
|
||||
- в docstring модуля после абзаца об опросе хаба добавить строку: «Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN.»;
|
||||
- в `load_config` после проверки обязательных полей:
|
||||
|
||||
```python
|
||||
if cfg["hub_url"].startswith("folder:") and not os.path.isabs(cfg["hub_url"][len("folder:"):]):
|
||||
sys.exit("hub_url: после folder: нужен абсолютный путь общей папки, например "
|
||||
"folder:/Volumes/My Shared Files/RSHB/jira-bridge")
|
||||
```
|
||||
|
||||
- в `cmd_check` заменить блок хаба:
|
||||
|
||||
```python
|
||||
hub = HubClient(cfg)
|
||||
via = ""
|
||||
if hub.folder is not None:
|
||||
hub.timeout = 10 # реле молчит дольше 10 с — хаб недоступен
|
||||
via = " через папку %s" % hub.folder.root
|
||||
try:
|
||||
s = hub.settings()
|
||||
log("хаб: доступен%s, настройки v%s, проект %s" % (via, s.get("settings_version"), s.get("project_key")))
|
||||
except (ConnectionError, HttpError) as e:
|
||||
log("хаб: НЕДОСТУПЕН — %s" % e)
|
||||
ok = False
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Тесты зелёные**
|
||||
|
||||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
Expected: OK.
|
||||
|
||||
- [ ] **Step 4: Документация**
|
||||
|
||||
`README.md`:
|
||||
- в «Доставка на виртуалку», п. 2, пункт `hub_url` заменить на: «`hub_url` — `http://<адрес Mac mini>:8790`, или, если сеть до Mac mini закрыта VPN (Континент), `folder:/Volumes/My Shared Files/RSHB/jira-bridge` — обмен через общую папку UTM (на хабе должна быть задана настройка `relay_dir`, см. README хаба);»;
|
||||
- добавить раздел после «Проверка»:
|
||||
|
||||
````markdown
|
||||
## Связь с хабом через общую папку
|
||||
|
||||
Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`:
|
||||
|
||||
```json
|
||||
"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"
|
||||
```
|
||||
|
||||
Агент пишет каждый запрос к хабу файлом `req/<id>.json` в этой папке и ждёт ответ `resp/<id>.json` от реле хаба
|
||||
(круг ≈ 0,8 с). Если ответа нет до срока — то же, что недоступный хаб по сети: агент пишет в лог и повторяет. Пароль Jira
|
||||
в папку не попадает; в файлах запросов есть только токен хаба. `check` в этом режиме печатает
|
||||
`хаб: доступен через папку …` или `НЕДОСТУПЕН` (нет папки; реле не ответило за 10 с).
|
||||
````
|
||||
|
||||
- в «Что делает агент с командами» → «Версия»: «(сейчас 2)» вместо «(сейчас 1)».
|
||||
|
||||
`CLAUDE.md` и `ARCHITECTURE.md` — одной строкой: `FolderTransport` (транспорт `folder:` к хабу через общую папку, ошибки как у `Http`), рядом с `HubClient`; ссылка на спеку `jira-hub/openspec/changes/folder-transport/`.
|
||||
|
||||
`openspec/changes/folder-transport/design.md` — создать:
|
||||
|
||||
```markdown
|
||||
# folder-transport — решения агента
|
||||
|
||||
Спека и дизайн: `jira-hub/openspec/changes/folder-transport/`. Здесь — решения, принятые при реализации агента.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| 2026-10-12 | `HubClient` остаётся наследником `Http`, при `folder:` переопределённый `call` делегирует `FolderTransport` | методы `claim/settings/...` и `Runner` не меняются; один класс для обоих транспортов | нет |
|
||||
| 2026-10-12 | Агент создаёт `req/` и `resp/`, если их нет (корень папки должен существовать) | агент может стартовать раньше, чем реле подготовит папку | нет |
|
||||
```
|
||||
|
||||
(дописать сюда решения, принятые по ходу Task 1–2, если появятся).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent.py tests/test_folder.py README.md CLAUDE.md ARCHITECTURE.md openspec/changes/folder-transport/design.md
|
||||
git commit -m "feat(agent): выбор транспорта по hub_url, check через папку, версия 2; README (#5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Приёмка
|
||||
|
||||
См. раздел «Приёмка» плана хаба: прогон на моке Jira через временную папку, настоящая папка под launchd, `check` на виртуалке при включённом Континенте, `refresh full`, `import`, сверка с CSV.
|
||||
11
openspec/changes/folder-transport/design.md
Normal file
11
openspec/changes/folder-transport/design.md
Normal file
@ -0,0 +1,11 @@
|
||||
# folder-transport — решения агента
|
||||
|
||||
Спека и дизайн: `jira-hub/openspec/changes/folder-transport/`. Здесь — решения, принятые при реализации агента.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| 2026-10-12 | `HubClient` остаётся наследником `Http`, при `folder:` переопределённый `call` делегирует `FolderTransport` | методы `claim/settings/...` и `Runner` не меняются; один класс для обоих транспортов | нет |
|
||||
| 2026-10-12 | Агент создаёт `req/` и `resp/`, если их нет (корень папки должен существовать) | агент может стартовать раньше, чем реле подготовит папку | нет |
|
||||
| 2026-10-13 | В белый список stdlib `tests/test_py39.py` добавлен `uuid` (R-2) | спека требует `uuid4().hex`, тест считал `uuid` сторонним модулем | нет |
|
||||
102
src/agent.py
102
src/agent.py
@ -4,6 +4,7 @@
|
||||
Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу
|
||||
«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека,
|
||||
Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md.
|
||||
Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN.
|
||||
|
||||
python3 agent.py check # доступ к хабу и Jira, без записи
|
||||
python3 agent.py run # основной цикл (по умолчанию)
|
||||
@ -21,10 +22,11 @@ import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
AGENT_VERSION = 1
|
||||
AGENT_VERSION = 2
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
API = "/rest/api/2"
|
||||
AGILE = "/rest/agile/1.0"
|
||||
@ -49,6 +51,9 @@ def load_config(path: str) -> dict:
|
||||
sys.exit("в %s нет обязательного поля %s" % (path, key))
|
||||
if not cfg["jira"].get("base_url"):
|
||||
sys.exit("в %s нет jira.base_url" % path)
|
||||
if cfg["hub_url"].startswith("folder:") and not os.path.isabs(cfg["hub_url"][len("folder:"):]):
|
||||
sys.exit("hub_url: после folder: нужен абсолютный путь общей папки, например "
|
||||
"folder:/Volumes/My Shared Files/RSHB/jira-bridge")
|
||||
cfg.setdefault("timeout", 60)
|
||||
cfg.setdefault("poll_seconds", 5)
|
||||
cfg.setdefault("verify_ssl", True)
|
||||
@ -176,9 +181,93 @@ class JiraClient(Http):
|
||||
return self.call("DELETE", path)
|
||||
|
||||
|
||||
class FolderTransport:
|
||||
"""Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке).
|
||||
Запрос — файл req/<id>.json, ответ кладёт реле хаба в resp/<id>.json. Ошибки те же, что у Http.call:
|
||||
нет ответа к сроку или нет папки — ConnectionError, код не 2xx — HttpError. Спека: specs/agent, folder-transport."""
|
||||
POLL = 0.1
|
||||
BAD_JSON_WAIT = 2.0
|
||||
|
||||
def __init__(self, root: str, authorization: str):
|
||||
self.root, self.authorization = root, authorization
|
||||
|
||||
def __repr__(self):
|
||||
return "<FolderTransport %s>" % self.root
|
||||
|
||||
def call(self, method: str, path: str, body=None, timeout: float = 60) -> Any:
|
||||
if not os.path.isdir(self.root):
|
||||
raise ConnectionError("нет папки %s" % self.root)
|
||||
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
|
||||
rid = uuid.uuid4().hex
|
||||
try:
|
||||
os.makedirs(req_dir, exist_ok=True)
|
||||
os.makedirs(resp_dir, exist_ok=True)
|
||||
deadline = time.time() + timeout
|
||||
self._write(req_dir, rid + ".json", {
|
||||
"id": rid, "method": method, "path": path, "body": body, "authorization": self.authorization,
|
||||
"deadline": datetime.fromtimestamp(deadline, timezone.utc).isoformat()})
|
||||
except OSError as e:
|
||||
raise ConnectionError("папка %s недоступна: %s" % (self.root, e)) from None
|
||||
resp_path = os.path.join(resp_dir, rid + ".json")
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if os.path.exists(resp_path):
|
||||
return self._take(resp_path)
|
||||
time.sleep(self.POLL)
|
||||
try:
|
||||
os.remove(os.path.join(req_dir, rid + ".json"))
|
||||
except OSError:
|
||||
pass
|
||||
raise ConnectionError("хаб не ответил через папку %s за %.0f с" % (self.root, timeout))
|
||||
|
||||
@staticmethod
|
||||
def _write(directory: str, name: str, payload: dict) -> None:
|
||||
tmp = os.path.join(directory, "." + 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, os.path.join(directory, name))
|
||||
|
||||
def _take(self, path: str) -> Any:
|
||||
end = time.monotonic() + self.BAD_JSON_WAIT
|
||||
while True:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
resp = json.load(f)
|
||||
if not isinstance(resp, dict):
|
||||
raise ValueError("ответ не объект")
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
if time.monotonic() >= end:
|
||||
raise ConnectionError("нечитаемый ответ хаба в %s" % path) from None
|
||||
time.sleep(self.POLL)
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
status, text = int(resp.get("status") or 0), resp.get("body") or ""
|
||||
if not 200 <= status < 300:
|
||||
raise HttpError(status, text)
|
||||
try:
|
||||
return json.loads(text) if text else {}
|
||||
except ValueError as e:
|
||||
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, text[:200])) from None
|
||||
|
||||
|
||||
class HubClient(Http):
|
||||
def __init__(self, cfg: dict):
|
||||
super().__init__(cfg["hub_url"], {"Authorization": "Bearer " + cfg["hub_token"]}, cfg.get("timeout", 60))
|
||||
auth = "Bearer " + cfg["hub_token"]
|
||||
super().__init__(cfg["hub_url"], {"Authorization": auth}, cfg.get("timeout", 60))
|
||||
url = cfg["hub_url"]
|
||||
self.folder = FolderTransport(url[len("folder:"):], auth) if url.startswith("folder:") else None
|
||||
|
||||
def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
|
||||
if self.folder is None:
|
||||
return super().call(method, path, body, params, timeout)
|
||||
if params:
|
||||
path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||||
return self.folder.call(method, path, body, timeout or self.timeout)
|
||||
|
||||
def claim(self, agent_version, settings_version, last_jira_call_at) -> dict:
|
||||
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
|
||||
@ -1026,9 +1115,14 @@ def cmd_run(cfg: dict, journal_path: str) -> int:
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_check(cfg: dict) -> int:
|
||||
ok = True
|
||||
hub = HubClient(cfg)
|
||||
via = ""
|
||||
if hub.folder is not None:
|
||||
hub.timeout = 10 # реле молчит дольше 10 с — хаб недоступен
|
||||
via = " через папку %s" % hub.folder.root
|
||||
try:
|
||||
s = HubClient(cfg).settings()
|
||||
log("хаб: доступен, настройки v%s, проект %s" % (s.get("settings_version"), s.get("project_key")))
|
||||
s = hub.settings()
|
||||
log("хаб: доступен%s, настройки v%s, проект %s" % (via, s.get("settings_version"), s.get("project_key")))
|
||||
except (ConnectionError, HttpError) as e:
|
||||
log("хаб: НЕДОСТУПЕН — %s" % e)
|
||||
ok = False
|
||||
|
||||
74
tests/fake_relay.py
Normal file
74
tests/fake_relay.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Фейковое реле хаба для тестов агента: забирает req/<id>.json из папки и отвечает resp/<id>.json.
|
||||
Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать;
|
||||
garbage — положить нечитаемый ответ; late — ответить через delay секунд."""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def write_atomic(directory, name, payload):
|
||||
tmp = os.path.join(directory, "." + name + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
|
||||
os.replace(tmp, os.path.join(directory, name))
|
||||
|
||||
|
||||
class FakeRelay:
|
||||
def __init__(self, root, target_base_url="", mode="forward", delay=0.0):
|
||||
self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay
|
||||
self.requests = []
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
for sub in ("req", "resp"):
|
||||
os.makedirs(os.path.join(root, sub), exist_ok=True)
|
||||
|
||||
def start(self):
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
self._thread.join(5)
|
||||
|
||||
def _loop(self):
|
||||
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
|
||||
while not self._stop.is_set():
|
||||
for name in sorted(os.listdir(req_dir)):
|
||||
if name.startswith(".") or not name.endswith(".json"):
|
||||
continue
|
||||
path = os.path.join(req_dir, name)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
req = json.load(f)
|
||||
os.remove(path)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
self.requests.append(req)
|
||||
if self.mode == "silent":
|
||||
continue
|
||||
if self.mode == "garbage":
|
||||
write_atomic(resp_dir, name, "{не json")
|
||||
continue
|
||||
if self.mode == "late":
|
||||
time.sleep(self.delay)
|
||||
status, body = self._forward(req)
|
||||
write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body})
|
||||
time.sleep(0.02)
|
||||
|
||||
def _forward(self, req):
|
||||
data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None
|
||||
r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"])
|
||||
r.add_header("Authorization", req["authorization"])
|
||||
if data is not None:
|
||||
r.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=10) as resp:
|
||||
return resp.status, resp.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, e.read().decode("utf-8")
|
||||
finally:
|
||||
e.close()
|
||||
187
tests/test_folder.py
Normal file
187
tests/test_folder.py
Normal file
@ -0,0 +1,187 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||
from fake_jira import FakeJira
|
||||
from fake_hub import FakeHub
|
||||
from fake_relay import FakeRelay
|
||||
|
||||
|
||||
class FolderTransportTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") # пробелы в пути
|
||||
os.makedirs(self.root)
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
self.relay = None
|
||||
|
||||
def tearDown(self):
|
||||
if self.relay:
|
||||
self.relay.stop()
|
||||
self.hub.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def client(self, timeout=5):
|
||||
cfg = {"hub_url": "folder:" + self.root, "hub_token": self.hub.token, "timeout": timeout}
|
||||
return agent.HubClient(cfg)
|
||||
|
||||
def run_relay(self, mode="forward", delay=0.0):
|
||||
self.relay = FakeRelay(self.root, self.hub.base_url, mode, delay).start()
|
||||
return self.relay
|
||||
|
||||
def listing(self, sub):
|
||||
return sorted(os.listdir(os.path.join(self.root, sub)))
|
||||
|
||||
def test_settings_through_folder(self):
|
||||
self.run_relay()
|
||||
hub = self.client()
|
||||
self.assertIsNotNone(hub.folder)
|
||||
self.assertEqual(hub.settings()["project_key"], "AURORA")
|
||||
self.assertEqual(self.listing("resp"), [])
|
||||
self.assertEqual(self.listing("req"), [])
|
||||
req = self.relay.requests[0]
|
||||
self.assertEqual(req["method"], "GET")
|
||||
self.assertEqual(req["path"], "/agent/settings")
|
||||
self.assertEqual(req["authorization"], "Bearer " + self.hub.token)
|
||||
self.assertEqual(len(req["id"]), 32)
|
||||
|
||||
def test_error_status_raises_http_error(self):
|
||||
self.run_relay()
|
||||
with self.assertRaises(agent.HttpError) as e:
|
||||
self.client().result(999, {"status": "done"})
|
||||
self.assertEqual(e.exception.code, 404)
|
||||
|
||||
def test_claim_deadline_at_least_45_seconds(self):
|
||||
self.run_relay()
|
||||
before = time.time()
|
||||
self.client(timeout=5).claim(2, 1, None)
|
||||
deadline = datetime.fromisoformat(self.relay.requests[0]["deadline"])
|
||||
self.assertIsNotNone(deadline.tzinfo)
|
||||
self.assertGreaterEqual(deadline.timestamp() - before, 44.5)
|
||||
|
||||
def test_no_answer_gives_connection_error_and_removes_request(self):
|
||||
hub = self.client(timeout=0.5)
|
||||
t0 = time.monotonic()
|
||||
with self.assertRaises(ConnectionError) as e:
|
||||
hub.settings()
|
||||
self.assertLess(time.monotonic() - t0, 2)
|
||||
self.assertIn("не ответил через папку", str(e.exception))
|
||||
self.assertEqual([n for n in self.listing("req") if not n.startswith(".")], [])
|
||||
|
||||
def test_missing_folder_gives_connection_error(self):
|
||||
cfg = {"hub_url": "folder:" + os.path.join(self.tmp.name, "нет"), "hub_token": "t", "timeout": 5}
|
||||
with self.assertRaises(ConnectionError) as e:
|
||||
agent.HubClient(cfg).settings()
|
||||
self.assertIn("нет папки", str(e.exception))
|
||||
|
||||
def test_bad_json_response_gives_connection_error(self):
|
||||
self.run_relay("garbage")
|
||||
hub = self.client()
|
||||
hub.folder.BAD_JSON_WAIT = 0.3
|
||||
with self.assertRaises(ConnectionError) as e:
|
||||
hub.settings()
|
||||
self.assertIn("нечитаемый ответ", str(e.exception))
|
||||
|
||||
def test_late_response_is_not_reused(self):
|
||||
self.run_relay("late", delay=1.0)
|
||||
with self.assertRaises(ConnectionError):
|
||||
self.client(timeout=0.3).settings()
|
||||
time.sleep(1.3) # опоздавший ответ лёг в resp/
|
||||
late = self.listing("resp")
|
||||
self.assertEqual(len(late), 1)
|
||||
self.relay.mode = "forward"
|
||||
self.assertEqual(self.client().settings()["project_key"], "AURORA")
|
||||
self.assertEqual(self.listing("resp"), late) # чужой (опоздавший) ответ не тронут
|
||||
|
||||
def test_foreign_files_untouched(self):
|
||||
resp_dir = os.path.join(self.root, "resp")
|
||||
os.makedirs(resp_dir, exist_ok=True)
|
||||
foreign = os.path.join(resp_dir, "0" * 32 + ".json")
|
||||
with open(foreign, "w", encoding="utf-8") as f:
|
||||
json.dump({"id": "0" * 32, "status": 200, "body": "{}"}, f)
|
||||
self.run_relay()
|
||||
self.client().settings()
|
||||
self.assertTrue(os.path.exists(foreign))
|
||||
|
||||
def test_http_url_keeps_http_transport(self):
|
||||
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
|
||||
hub = agent.HubClient(cfg)
|
||||
self.assertIsNone(hub.folder)
|
||||
self.assertEqual(hub.settings()["project_key"], "AURORA")
|
||||
|
||||
|
||||
|
||||
class FolderIntegrationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge")
|
||||
os.makedirs(self.root)
|
||||
self.fake = FakeJira()
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
self.relay = FakeRelay(self.root, self.hub.base_url).start()
|
||||
|
||||
def tearDown(self):
|
||||
self.relay.stop()
|
||||
self.fake.stop()
|
||||
self.hub.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def cfg(self):
|
||||
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
||||
|
||||
def test_version_is_2(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 2)
|
||||
|
||||
def test_runner_through_folder(self):
|
||||
self.fake.add("AURORA-1", duedate="2026-10-20")
|
||||
cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||||
cfg = self.cfg()
|
||||
journal = agent.Journal(os.path.join(self.tmp.name, "journal.jsonl"))
|
||||
runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), journal)
|
||||
runner.sleep = lambda s: None
|
||||
runner.jira.sleep = no_sleep
|
||||
runner.idle_limit = 1
|
||||
runner.loop()
|
||||
self.assertEqual(self.hub.results[cid]["status"], "done")
|
||||
self.assertEqual(self.hub.results[cid]["agent_version"], 2)
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-23")
|
||||
self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()])
|
||||
|
||||
def test_check_reports_folder(self):
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
code = agent.cmd_check(self.cfg())
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("хаб: доступен через папку %s, настройки v1, проект AURORA" % self.root, out.getvalue())
|
||||
|
||||
def test_check_folder_silent_relay(self):
|
||||
self.relay.stop()
|
||||
self.relay = FakeRelay(self.root, self.hub.base_url, "silent").start()
|
||||
out = io.StringIO()
|
||||
t0 = time.monotonic()
|
||||
with redirect_stdout(out):
|
||||
code = agent.cmd_check(self.cfg())
|
||||
self.assertEqual(code, 1)
|
||||
self.assertLess(time.monotonic() - t0, 13)
|
||||
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
|
||||
self.assertIn("не ответил через папку", out.getvalue())
|
||||
|
||||
def test_relative_folder_url_rejected(self):
|
||||
path = os.path.join(self.tmp.name, "agent.json")
|
||||
for url in ("folder:relative/path", "folder:"):
|
||||
cfg = self.cfg()
|
||||
cfg["hub_url"] = url
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f)
|
||||
with self.assertRaises(SystemExit) as e:
|
||||
agent.load_config(path)
|
||||
self.assertIn("абсолютный путь", str(e.exception.code))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase):
|
||||
def test_no_third_party_imports(self):
|
||||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing"}
|
||||
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid"}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user