feat(agent): выбор транспорта по hub_url, check через папку, версия 2; README (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f9a2b7df38
commit
b9a2260393
@ -9,6 +9,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
|
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
|
||||||
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `urllib` |
|
| 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` | файловая система |
|
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` | файловая система |
|
||||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
|
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
|
||||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
Шаблон без секретов — `src/agent.example.json`.
|
Шаблон без секретов — `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 → пауза → журнал →
|
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
|
||||||
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `main`).
|
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `main`).
|
||||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
|
- Границы: `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/`.
|
1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`.
|
||||||
2. Переименовать `agent.example.json` в `agent.json` и заполнить:
|
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;
|
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
|
||||||
- `jira.base_url` — адрес Jira банка;
|
- `jira.base_url` — адрес Jira банка;
|
||||||
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
|
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
|
||||||
@ -41,6 +41,19 @@ python3 agent.py check
|
|||||||
Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1.
|
Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1.
|
||||||
Команда ничего не пишет в Jira.
|
Команда ничего не пишет в 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 раз. Счётчики
|
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||||
по сигналу команду подхватит восстановление.
|
по сигналу команду подхватит восстановление.
|
||||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 1) отправляется в каждом `claim`, в каждом отчёте
|
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 2) отправляется в каждом `claim`, в каждом отчёте
|
||||||
(`agent_version`) и в `/agent/state`.
|
(`agent_version`) и в `/agent/state`.
|
||||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||||
при этом выдаются по-прежнему.
|
при этом выдаются по-прежнему.
|
||||||
|
|||||||
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` сторонним модулем | нет |
|
||||||
15
src/agent.py
15
src/agent.py
@ -4,6 +4,7 @@
|
|||||||
Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу
|
Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу
|
||||||
«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека,
|
«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека,
|
||||||
Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md.
|
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 check # доступ к хабу и Jira, без записи
|
||||||
python3 agent.py run # основной цикл (по умолчанию)
|
python3 agent.py run # основной цикл (по умолчанию)
|
||||||
@ -25,7 +26,7 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
AGENT_VERSION = 1
|
AGENT_VERSION = 2
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
API = "/rest/api/2"
|
API = "/rest/api/2"
|
||||||
AGILE = "/rest/agile/1.0"
|
AGILE = "/rest/agile/1.0"
|
||||||
@ -50,6 +51,9 @@ def load_config(path: str) -> dict:
|
|||||||
sys.exit("в %s нет обязательного поля %s" % (path, key))
|
sys.exit("в %s нет обязательного поля %s" % (path, key))
|
||||||
if not cfg["jira"].get("base_url"):
|
if not cfg["jira"].get("base_url"):
|
||||||
sys.exit("в %s нет jira.base_url" % path)
|
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("timeout", 60)
|
||||||
cfg.setdefault("poll_seconds", 5)
|
cfg.setdefault("poll_seconds", 5)
|
||||||
cfg.setdefault("verify_ssl", True)
|
cfg.setdefault("verify_ssl", True)
|
||||||
@ -1111,9 +1115,14 @@ def cmd_run(cfg: dict, journal_path: str) -> int:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def cmd_check(cfg: dict) -> int:
|
def cmd_check(cfg: dict) -> int:
|
||||||
ok = True
|
ok = True
|
||||||
|
hub = HubClient(cfg)
|
||||||
|
via = ""
|
||||||
|
if hub.folder is not None:
|
||||||
|
hub.timeout = 10 # реле молчит дольше 10 с — хаб недоступен
|
||||||
|
via = " через папку %s" % hub.folder.root
|
||||||
try:
|
try:
|
||||||
s = HubClient(cfg).settings()
|
s = hub.settings()
|
||||||
log("хаб: доступен, настройки v%s, проект %s" % (s.get("settings_version"), s.get("project_key")))
|
log("хаб: доступен%s, настройки v%s, проект %s" % (via, s.get("settings_version"), s.get("project_key")))
|
||||||
except (ConnectionError, HttpError) as e:
|
except (ConnectionError, HttpError) as e:
|
||||||
log("хаб: НЕДОСТУПЕН — %s" % e)
|
log("хаб: НЕДОСТУПЕН — %s" % e)
|
||||||
ok = False
|
ok = False
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from _common import SETTINGS, agent
|
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||||
|
from fake_jira import FakeJira
|
||||||
from fake_hub import FakeHub
|
from fake_hub import FakeHub
|
||||||
from fake_relay import FakeRelay
|
from fake_relay import FakeRelay
|
||||||
|
|
||||||
@ -113,5 +116,72 @@ class FolderTransportTest(unittest.TestCase):
|
|||||||
self.assertEqual(hub.settings()["project_key"], "AURORA")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user