docs(openspec): спека folder-transport — реле хаба и транспорт агента через общую папку (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2e6e4def87
commit
61e2f3b148
2
openspec/changes/folder-transport/.openspec.yaml
Normal file
2
openspec/changes/folder-transport/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-12
|
||||
81
openspec/changes/folder-transport/design.md
Normal file
81
openspec/changes/folder-transport/design.md
Normal file
@ -0,0 +1,81 @@
|
||||
## Context
|
||||
|
||||
Мост из `bridge-core`: хаб (`jira-hub serve`, Mac mini, launchd, `0.0.0.0:8790`) и агент (`agent.py` на виртуалке,
|
||||
Python 3.9, stdlib). Агент обращается к хабу шестью запросами (`claim` с ожиданием до 30 с, `settings`, `commands/{id}`,
|
||||
`result`, `state`, `known-keys`); все идут через `Http.call()` агента, ошибки сведены к `ConnectionError` (хаб
|
||||
недоступен) и `HttpError` (код ответа). Повторы отчёта, журнал `claimed/started/finished/acked` и восстановление опираются
|
||||
только на эти два исключения. В хабе `serve()` уже держит фоновый поток `ticker`.
|
||||
|
||||
При включённом VPN «Континент ZTN» сеть между виртуалкой и Mac mini закрыта (см. proposal.md). Общая папка UTM
|
||||
(VirtioFS) проверена: круг 0,75–0,82 с, атомарное переименование видно целиком, удаления видны с обеих сторон.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
Goals: агент работает с хабом при включённом Континенте; всё, что проверено прогоном на моке Jira 12.10.2026 (пакет всех
|
||||
типов, повтор без записей, остановка в паузе, обрыв Jira), работает так же через папку; HTTP API хаба не меняется.
|
||||
Non-goals — см. proposal.md.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Реле внутри `serve` (вариант A, решение владельца).** Один сервис под launchd, один лог. Поток реле стартует всегда и
|
||||
спит, пока `relay_dir` пуст; включение и выключение — правкой настройки, без перезапуска.
|
||||
2. **Реле — HTTP-клиент своего же хаба.** Запрос из файла пересылается на `127.0.0.1:<port>` с заголовком
|
||||
`Authorization` из файла. Маршрутизация, проверка токенов, лимит тела и коды ошибок остаются в одном месте.
|
||||
3. **Папка моста** `<relay_dir>` с подпапками `req/` (запросы агента), `resp/` (ответы), `work/` (взятые реле запросы).
|
||||
Обе стороны пишут файл под именем с точкой и переименовывают в `<id>.json`; имена с точкой никто не читает.
|
||||
4. **Формат.** Запрос: `{id, method, path, body, authorization, deadline}`; `deadline` — момент UTC (ISO 8601), после
|
||||
которого ответ агенту не нужен. Ответ: `{id, status, body}`.
|
||||
5. **Взятие запроса — переименование `req/<id>.json` → `work/<id>.json`.** Повторно его никто не возьмёт; каждый взятый
|
||||
запрос обрабатывается в своём потоке, потому что `claim` держит ответ до 30 с.
|
||||
6. **Ошибки агента — те же, что у HTTP.** Ответ 2xx → тело; иной код → `HttpError(status, body)`; нет ответа к `deadline` →
|
||||
агент удаляет свой запрос (если его ещё не взяли) и выбрасывает `ConnectionError`. Runner, журнал и восстановление
|
||||
не меняются.
|
||||
7. **Защита `claim` по запасу времени.** Реле пересылает `POST /agent/claim`, только если до `deadline` осталось ≥ 35 с
|
||||
(хаб держит `claim` ≤ 30 с); иначе запрос выбрасывается без ответа. Агент ставит для `claim` `deadline` = сейчас +
|
||||
max(timeout, 45) с, как сейчас ставит таймаут HTTP. Это закрывает потерю ответа `claim` из-за медленной папки; общий
|
||||
случай M-2 остаётся отложенным.
|
||||
8. **Просроченные запросы выбрасываются без ответа** (агент уже ушёл с `ConnectionError`). Если `deadline` истёк после
|
||||
пересылки, ответ всё равно пишется — его уберёт уборка.
|
||||
9. **Хаб недоступен изнутри** (сбой соединения с `127.0.0.1`) — ответ со статусом 502 и текстом ошибки.
|
||||
10. **Перезапуск реле**: файлы в `work/` удаляются с записью в лог; агент дождётся своего `deadline` и повторит.
|
||||
11. **Уборка раз в минуту**: ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с, файлы с точкой
|
||||
старше 10 мин.
|
||||
12. **Папки нет** (SSD не подключён) — одно предупреждение в лог, реле ждёт и проверяет снова; подпапки `req/`, `resp/`,
|
||||
`work/` реле создаёт само, когда `relay_dir` существует.
|
||||
13. **Лог** — одна строка на запрос в лог хаба: `relay <METHOD> <path> <status> <секунды>`.
|
||||
14. **Агент выбирает транспорт по `hub_url`**: `http://…`/`https://…` — как сейчас, `folder:<путь>` — файловый клиент
|
||||
с тем же интерфейсом `HubClient`. `check` печатает «хаб: доступен через папку <путь>, настройки vN» или
|
||||
«НЕДОСТУПЕН» с причиной (нет папки; реле не ответило за 10 с). Версия агента — 2.
|
||||
15. **Токен агента остаётся в файле запроса.** Общая папка доступна только Mac mini и виртуалке; пароль Jira туда не
|
||||
попадает (как и в HTTP-транспорте, хаб получает только результаты и состояние).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Доступ launchd к внешнему диску.** macOS может не пустить процесс из launchd на `/Volumes/externalSSD` без разрешения
|
||||
«Съёмные тома» или «Полный доступ к диску». Проверяется первым шагом приёмки на настоящей папке; при отказе — выдать
|
||||
доступ `uv`/python в «Конфиденциальность и безопасность».
|
||||
- **Кеш VirtioFS** даёт постоянные ~0,75 с на круг. Для команд с паузами 30–120 с несущественно; `refresh` на 140 задач —
|
||||
около 10 обменов.
|
||||
- **Токен агента в файлах общей папки** живёт до ответа и уборки. Приемлемо для домашней пары машин.
|
||||
- **Опоздавший ответ** на `result`/`state`: агент повторит запрос; `result` идемпотентен, повторная загрузка `state`
|
||||
перезаписывает те же строки зеркала.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Слить PR хаба, перезапустить launchd-задание хаба; `settings_set relay_dir "/Volumes/externalSSD/utm/rshb/jira-bridge"`.
|
||||
2. Прогон на моке Jira через временную папку (сценарий 12.10.2026), затем короткий прогон через настоящую папку.
|
||||
3. Слить PR агента; на виртуалке заменить `agent.py`, в `agent.json` — `"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"`.
|
||||
4. При включённом Континенте: `check` (обе строки «доступен»), запуск агента, `refresh full`, `jira-hub import`, сверка с CSV.
|
||||
Откат: `relay_dir = null` и прежний `hub_url` (сеть работает только без Континента).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| 2026-10-12 | Имя файла — `uuid4().hex` агента; ответ под тем же именем | уникальность без координации сторон | нет |
|
||||
| 2026-10-12 | Тело ответа в файле — строка (как пришло от HTTP), агент разбирает JSON сам | реле не трактует ответы хаба; не-JSON ответ даёт ту же ошибку, что по HTTP | нет |
|
||||
| 2026-10-12 | `deadline` — абсолютное время UTC, а не таймаут | реле на другой машине сравнивает со своими часами; Mac mini и виртуалка синхронизируют время с одного роутера/NTP | расхождение часов > 10 с ломает защиту `claim`; лечится запасом или переходом на относительный срок |
|
||||
| 2026-10-12 | Реле перечитывает `relay_dir` раз в 5 с, опрос `req/` — каждые 0,1 с, агент опрашивает `resp/` каждые 0,1 с | быстрее не нужно (кеш VirtioFS ~0,75 с), реже — заметная задержка | лишние вызовы `listdir` — дёшево |
|
||||
| 2026-10-12 | `relay_dir` — абсолютный путь строкой или `null`; относительный путь — 422 | путь читает процесс launchd с неочевидным cwd | нет |
|
||||
| 2026-10-12 | Нечитаемый JSON запроса или ответа перечитывается до 2 с, затем запрос выбрасывается (реле) / `ConnectionError` (агент) | атомарное переименование делает это маловероятным (проверка: 0 случаев) | нет |
|
||||
| 2026-10-12 | `relay_dir` не входит в настройки для агента (`AGENT_KEYS`) | агенту путь на стороне Mac mini не нужен | нет |
|
||||
36
openspec/changes/folder-transport/proposal.md
Normal file
36
openspec/changes/folder-transport/proposal.md
Normal file
@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
Живой запуск моста 12.10.2026 упёрся в сеть: доступ к Jira банка на виртуалке даёт VPN «Континент ZTN», и его пакетный
|
||||
фильтр (pf-anchor `continentztn`) закрывает локальную сеть в обе стороны — роутер `192.168.0.1` отвечает, Mac mini
|
||||
(`192.168.0.108`) нет, `nc` на порт хаба 8790 — таймаут; входящие на виртуалку (`:22`) тоже режутся. Фильтр включается при
|
||||
первом подключении Континента и остаётся загруженным после отключения VPN, до выхода из клиента или перезагрузки. Агенту
|
||||
одновременно нужны Jira (только через VPN) и хаб, поэтому сеть для моста не годится. Править правила Континента нельзя —
|
||||
это средство защиты банка.
|
||||
|
||||
Канал, который фильтр не видит, — общая папка гипервизора UTM (VirtioFS): на Mac mini `/Volumes/externalSSD/utm/rshb`,
|
||||
на виртуалке `/Volumes/My Shared Files/RSHB`. Проверка 12.10.2026 при включённом Континенте: 30 обменов из 30 через
|
||||
временный файл и переименование, время круга 0,75–0,82 с независимо от размера (200 Б – 300 КБ), недописанных и
|
||||
«залипших» удалённых файлов 0 с обеих сторон.
|
||||
|
||||
Решения владельца 12.10.2026 (сессия brainstorming): транспорт через общую папку; вариант A — реле внутри `jira-hub serve`
|
||||
(не отдельный процесс и не обмен пакетами целиком); HTTP API хаба и обработчики не меняются; у агента транспорт выбирается
|
||||
по `hub_url`; HTTP-транспорт остаётся; дизайн частей 1–4 (формат обмена, ошибки и уборка, настройки и запуск, тесты)
|
||||
одобрен.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **jira-hub**: поток реле в `serve`, включаемый настройкой `relay_dir`. Реле забирает файлы-запросы агента из
|
||||
`<relay_dir>/req/`, пересылает их в свой HTTP на `127.0.0.1:<port>` и кладёт ответы в `<relay_dir>/resp/`.
|
||||
Новая настройка `relay_dir` (строка или `null`), проверяется при записи.
|
||||
- **jira-agent**: транспорт `folder:` — `hub_url = "folder:<путь>"` создаёт файловый клиент хаба с тем же интерфейсом,
|
||||
ошибки сводятся к тем же `ConnectionError`/`HttpError`; `check` сообщает транспорт; версия агента 2.
|
||||
- Спеки: `hub-api` (ADDED: реле), `data-model` (MODIFIED: настройки), `agent` (MODIFIED: состав и настройки; ADDED:
|
||||
транспорт через общую папку).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Изменения HTTP API, обработчиков, токенов, очереди и контракта команд.
|
||||
- Обмен пакетами команд целиком через файлы (вариант C), работа агента без хаба.
|
||||
- Несколько агентов на одной папке, шифрование файлов в общей папке.
|
||||
- Обход или настройка фильтра Континента; запрос исключения в ИБ банка — вне репозиториев.
|
||||
- Исправление находки M-2 (потерянный ответ `claim`) в общем виде; здесь только защита по запасу времени для реле.
|
||||
53
openspec/changes/folder-transport/specs/agent/spec.md
Normal file
53
openspec/changes/folder-transport/specs/agent/spec.md
Normal file
@ -0,0 +1,53 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Состав и настройки агента
|
||||
Агент SHALL состоять из одного файла `agent.py` (Python ≥ 3.9, только стандартная библиотека) и файла `agent.json`:
|
||||
`hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` или `bearer` с `token`), `verify_ssl`,
|
||||
`ca_bundle`, `timeout`, `poll_seconds`. `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида
|
||||
`folder:<абсолютный путь>` — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей,
|
||||
доска, темп) агент SHALL получать от хаба при старте и при изменении `settings_version`. Учётные данные Jira MUST NOT
|
||||
отправляться в хаб и MUST NOT попадать в журнал, логи и общую папку.
|
||||
|
||||
#### Scenario: Проверка без записи
|
||||
- **WHEN** `python3 agent.py check`
|
||||
- **THEN** агент печатает доступность хаба (версия настроек) и Jira (`GET /myself`), в Jira ничего не пишет
|
||||
|
||||
#### Scenario: Проверка через папку
|
||||
- **WHEN** `hub_url = "folder:/Volumes/My Shared Files/RSHB/jira-bridge"` и реле хаба работает
|
||||
- **THEN** `check` печатает «хаб: доступен через папку /Volumes/My Shared Files/RSHB/jira-bridge, настройки v<N>, проект
|
||||
<KEY>»; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1
|
||||
|
||||
### Requirement: Доставка и запуск
|
||||
README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json`
|
||||
(включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist,
|
||||
`prune`. Версия агента SHALL быть в константе файла (2 — с транспортом через папку) и в каждом отчёте; хаб предупреждает
|
||||
в `agent_status`, если версия ниже минимальной из настроек.
|
||||
|
||||
#### Scenario: Устаревший агент
|
||||
- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2`
|
||||
- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Транспорт через общую папку
|
||||
При `hub_url = "folder:<путь>"` агент SHALL обращаться к хабу теми же запросами, что по HTTP, через файлы в `<путь>`:
|
||||
на каждый вызов записать `req/<id>.json` = `{id, method, path, body, authorization, deadline}` (`id` — `uuid4().hex`,
|
||||
`authorization` — `Bearer <hub_token>`, `deadline` — момент UTC = сейчас + таймаут вызова; для `claim` таймаут не меньше
|
||||
45 с) через временный файл с именем на точку и переименование, затем опрашивать `resp/<id>.json` каждые 0,1 с.
|
||||
Полученный ответ агент SHALL удалить; `status` 2xx — вернуть разобранное тело, иной — выбросить `HttpError(status, body)`
|
||||
так же, как HTTP-клиент. Если к `deadline` ответа нет, агент SHALL удалить свой `req/<id>.json` (если он ещё там) и
|
||||
выбросить `ConnectionError`. Нечитаемый JSON ответа SHALL перечитываться до 2 с, затем `ConnectionError`. Чужие файлы
|
||||
в `resp/` агент MUST NOT трогать. Цикл выполнения, журнал, повторы отчёта и восстановление SHALL работать без изменений.
|
||||
|
||||
#### Scenario: Ответ хаба через папку
|
||||
- **WHEN** агент вызвал `settings`, реле положило `resp/<id>.json` со `status = 200`
|
||||
- **THEN** агент получает настройки, файл ответа удалён
|
||||
|
||||
#### Scenario: Реле не отвечает
|
||||
- **WHEN** агент вызвал `claim`, а ответа нет 45 с
|
||||
- **THEN** `req/<id>.json` удалён агентом, выброшен `ConnectionError`; цикл пишет «хаб недоступен» и повторяет `claim`
|
||||
через `poll_seconds`, как при недоступном хабе по сети
|
||||
|
||||
#### Scenario: Код ошибки хаба через папку
|
||||
- **WHEN** агент отправил `result` по команде, которой в хабе нет, реле положило ответ со `status = 404`
|
||||
- **THEN** агент получает `HttpError(404)` и считает команду закрытой, как по HTTP
|
||||
17
openspec/changes/folder-transport/specs/data-model/spec.md
Normal file
17
openspec/changes/folder-transport/specs/data-model/spec.md
Normal file
@ -0,0 +1,17 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Настройки
|
||||
Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`,
|
||||
`transitions_map`, `pacing`, `board_id`, `sprint_names`, `tokens` (агентский и клиентский), `settings_version`
|
||||
(увеличивается при любой правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или
|
||||
`null`, по умолчанию `null`; относительный путь и не-строка при записи — 422; агенту не передаётся). Начальные значения
|
||||
SHALL импортироваться из `admin/jira-sync/config.example.json` командой `jira-hub init`; пароль и логин Jira в настройках
|
||||
MUST NOT присутствовать.
|
||||
|
||||
#### Scenario: Правка настроек поднимает версию
|
||||
- **WHEN** через API изменён `pacing`
|
||||
- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию
|
||||
|
||||
#### Scenario: Неверный relay_dir
|
||||
- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"`
|
||||
- **THEN** 422, значение не записано, `settings_version` не изменился
|
||||
44
openspec/changes/folder-transport/specs/hub-api/spec.md
Normal file
44
openspec/changes/folder-transport/specs/hub-api/spec.md
Normal file
@ -0,0 +1,44 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Реле через общую папку
|
||||
`jira-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле
|
||||
бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если
|
||||
каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует —
|
||||
записать одно предупреждение в лог и проверять снова.
|
||||
|
||||
Обмен:
|
||||
- запрос агента — файл `req/<id>.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC
|
||||
(ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать;
|
||||
- реле SHALL брать запрос переименованием в `work/<id>.json` и обрабатывать каждый взятый запрос в отдельном потоке;
|
||||
- запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до
|
||||
`deadline` осталось не меньше 35 с, иначе выбрасываться без ответа;
|
||||
- остальные запросы реле SHALL пересылать в свой HTTP на `127.0.0.1:<port>` тем же методом, путём и телом, с заголовком
|
||||
`Authorization` из файла, и записывать ответ `resp/<id>.json` = `{id, status, body}` (тело — строка ответа HTTP) через
|
||||
временный файл с именем на точку и переименование; затем удалять `work/<id>.json`;
|
||||
- если соединение с `127.0.0.1:<port>` не удалось, ответ SHALL иметь `status = 502` и текст ошибки в `body`;
|
||||
- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог.
|
||||
|
||||
При старте реле SHALL удалить файлы из `work/` с записью в лог. Раз в минуту реле SHALL удалять ответы в `resp/` старше
|
||||
10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку старше 10 мин. На каждый обработанный запрос
|
||||
реле SHALL писать в лог хаба строку `relay <METHOD> <path> <status> <секунды>`. HTTP API, обработчики и проверка токенов
|
||||
при этом не меняются.
|
||||
|
||||
#### Scenario: Запрос через папку
|
||||
- **WHEN** в `req/` появился `a1.json` с `GET /agent/settings`, агентским токеном и `deadline` через 60 с
|
||||
- **THEN** в `resp/` появляется `a1.json` со `status = 200` и телом настроек; `work/a1.json` удалён
|
||||
|
||||
#### Scenario: Чужой токен через папку
|
||||
- **WHEN** в файле запроса `GET /issues` с агентским токеном
|
||||
- **THEN** ответ со `status = 401`, как по HTTP
|
||||
|
||||
#### Scenario: Просроченный claim
|
||||
- **WHEN** реле взяло `POST /agent/claim`, до `deadline` которого осталось 20 с
|
||||
- **THEN** запрос выброшен без ответа и без обращения к хабу; команда в очереди осталась `queued`
|
||||
|
||||
#### Scenario: Долгий claim не задерживает другие запросы
|
||||
- **WHEN** реле пересылает `claim`, который ждёт команду 30 с, и в это время в `req/` появляется `GET /agent/settings`
|
||||
- **THEN** ответ на `settings` пишется, не дожидаясь `claim`
|
||||
|
||||
#### Scenario: Папка не подключена
|
||||
- **WHEN** `relay_dir` задан, но каталога нет; через минуту каталог появился
|
||||
- **THEN** в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы
|
||||
Loading…
x
Reference in New Issue
Block a user