30 KiB
Транспорт агента через общую папку — план реализации (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, проект »; ожидание хаба через папку ≤ 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
- Путь общей папки с пробелами (
/Volumes/My Shared Files/RSHB/jira-bridge) — всё работает, путь не режется и не экранируется (тесты в Task 1 используют временный каталог с пробелом в имени). - Ответ реле лежит в
resp/, но ещё недописан (временный файл переименовался, а содержимое кривое) — агент перечитывает до 2 с, потомConnectionError, не падает сValueError(тестtest_bad_json_response_gives_connection_error). - Реле ответило после того, как агент сдался, — опоздавший ответ остаётся в
resp/(его уберёт реле), следующий вызов агента его не подхватывает (тестtest_late_response_is_not_reused). hub_url = "folder:relative/path"или"folder:"— понятный выход изload_config, а не трассировка в цикле (тестtest_relative_folder_url_rejectedв Task 2).- В
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:
"""Фейковое реле хаба для тестов агента: забирает 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:
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):вставить:
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:
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
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):
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после проверки обязательных полей:
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заменить блок хаба:
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 хаба);»; - добавить раздел после «Проверка»:
## Связь с хабом через общую папку
Когда 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 — создать:
# 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
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.