From a7fed004192c871c9984c2028cedcb977f2758b7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 12:15:21 +0400 Subject: [PATCH] =?UTF-8?q?docs(openspec):=20change=20bridge-hygiene=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=20C=20=D0=B3=D0=B8?= =?UTF-8?q?=D0=B3=D0=B8=D0=B5=D0=BD=D1=8B=20=D0=BC=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposal, design и дельты спек hub-api, data-model, commands, agent, mcp, migration; Purpose во всех шести спеках (O-4 #8). Co-Authored-By: Claude Opus 5.5 --- .../changes/bridge-hygiene/.openspec.yaml | 2 + openspec/changes/bridge-hygiene/design.md | 139 ++++++++++++++ openspec/changes/bridge-hygiene/proposal.md | 91 ++++++++++ .../bridge-hygiene/specs/agent/spec.md | 90 ++++++++++ .../bridge-hygiene/specs/commands/spec.md | 107 +++++++++++ .../bridge-hygiene/specs/data-model/spec.md | 122 +++++++++++++ .../bridge-hygiene/specs/hub-api/spec.md | 169 ++++++++++++++++++ .../changes/bridge-hygiene/specs/mcp/spec.md | 39 ++++ .../bridge-hygiene/specs/migration/spec.md | 24 +++ openspec/specs/agent/spec.md | 2 +- openspec/specs/commands/spec.md | 2 +- openspec/specs/data-model/spec.md | 2 +- openspec/specs/hub-api/spec.md | 2 +- openspec/specs/mcp/spec.md | 2 +- openspec/specs/migration/spec.md | 2 +- 15 files changed, 789 insertions(+), 6 deletions(-) create mode 100644 openspec/changes/bridge-hygiene/.openspec.yaml create mode 100644 openspec/changes/bridge-hygiene/design.md create mode 100644 openspec/changes/bridge-hygiene/proposal.md create mode 100644 openspec/changes/bridge-hygiene/specs/agent/spec.md create mode 100644 openspec/changes/bridge-hygiene/specs/commands/spec.md create mode 100644 openspec/changes/bridge-hygiene/specs/data-model/spec.md create mode 100644 openspec/changes/bridge-hygiene/specs/hub-api/spec.md create mode 100644 openspec/changes/bridge-hygiene/specs/mcp/spec.md create mode 100644 openspec/changes/bridge-hygiene/specs/migration/spec.md diff --git a/openspec/changes/bridge-hygiene/.openspec.yaml b/openspec/changes/bridge-hygiene/.openspec.yaml new file mode 100644 index 0000000..49ea85b --- /dev/null +++ b/openspec/changes/bridge-hygiene/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-14 diff --git a/openspec/changes/bridge-hygiene/design.md b/openspec/changes/bridge-hygiene/design.md new file mode 100644 index 0000000..5c5101d --- /dev/null +++ b/openspec/changes/bridge-hygiene/design.md @@ -0,0 +1,139 @@ +## Context + +Мост в работе с 13.10.2026: хаб на Mac mini (launchd, `~/.local/share/jira-hub/hub.sqlite`, схема v2, 196 задач, +11 пакетов), агент v3 на виртуалке через общую папку. Код: хаб `main` c6aa2ed, агент `main` d7cd050. + +Источник состава — разбор пакета D 14.10.2026 (комментарии в hub#4 и agent#4) и issues hub#8, hub#12, agent#8, agent#12. +Маркеры находок сохраняются как в исходных issues; одинаковые номера из разных issue различаются по источнику: +«M-3 #4» — `rename_note` (PR #2 bridge-core), «M-3 #8» — 502 в реле, «M-3 #12» — нестроковый `summary`. + +Найдено при чтении кода: +- `paths.home()` и `db.connect` создают каталоги с правами по umask (на Mac mini — `0755`), файлы — `0644`. +- `mirror.rename_note` делает `DELETE … WHERE key = new_key`, затем `UPDATE key = new_key`. +- `queue._status_of` даёт `cancelled` только если все команды `cancelled`. +- `queue.report_result` пишет `error` как пришёл; ветка позднего отчёта есть только для `cancelled`. +- `queue._redeliver` переводит команду в `failed` по пределу, не трогая `sync_run`; повторная выдача `refresh` не + трогает `sync_run`, а `record_state` прибавляет счётчики. +- `db.migrate` делает копию до транзакции миграции; версию перечитывает уже внутри. +- `relay._prepare` вызывается при каждом `_ready = False` (смена `relay_dir`, пропажа подкаталога), а чистит `work/` как + при старте. +- агент: `restore_pace` пишет оценку из журнала и в `last_call_at`, и в `last_call_iso`; второе уходит в `claim`. + +## Goals / Non-Goals + +**Goals:** закрыть пункты пакета C без новых возможностей; `openspec validate --specs --strict` зелёный; хаб — без +миграции схемы; агент — версия 4, совместим с хабом без изменений контракта. + +**Non-Goals:** см. proposal (живые проверки, «на потом», отклонённое в разборе D). + +## Decisions + +1. **Права каталога данных (M-7 #4).** `paths.home()` и `paths.backups_dir()` создают каталог и выставляют `0700` + (`os.chmod` и для уже существующего). `db.connect` для файловой базы — `chmod 0700` на каталог базы. Файлы внутри не + трогаем: закрытого каталога достаточно. Каталог задаётся `JIRA_HUB_HOME` и в тестах лежит в `tmp_path` — chmod там + безвреден. +2. **Слияние заметок (M-3 #4).** `rename_note(old, new)`: если заметки под `new` нет — переименование, как сейчас. Если + есть — в одной транзакции для каждого поля заметки (`fact_status`, `work_kind`, `done_text`, `rest_text`, + `owner_note`) берётся значение временной заметки, если оно непустое, иначе значение под `new`; `updated_at`/`updated_by` + — от более поздней; временная строка удаляется. Так заметка, которую владелец успел завести на настоящий ключ, не + теряется, а заполненные при планировании поля `new:N` побеждают. +3. **Статус отменённого пакета (T5.6).** `_status_of`: если у пакета `cancelled_at` и все команды терминальные — + `cancelled`, даже если часть `done`. Без `cancelled_at` — как сейчас. Видимые счётчики `done`/`failed`/`skipped` в + `batch_view` не меняются. +4. **Обрезка `error` (спека commands).** `report_result` обрезает `error` до 1500 символов. Одна точка — для агента и + любого клиента. +5. **Поздний отчёт по `failed` по пределу (M-4 #12).** Ветка позднего отчёта в `report_result` расширяется: команда + `ensure_issue` в `cancelled` **или** `failed`, отчёт `done` с `result.key`, у команды нет `result` — ключ записывается + в `result`, заметка переносится (`rename_note`), статус не меняется. Для `failed` это означает «хаб сдался, агент + всё-таки создал»: зависимые команды уже `skipped`, повтор — новой командой. При переводе в `failed` по пределу выдач + (`_redeliver`) для `refresh` хаб SHALL проставить `sync_run.finished_at`. Ключ для поздних отчётов по прочим типам не + нужен — они отбрасываются, как сейчас. +6. **Довыдача `refresh` (M-5 #12).** При довыдаче команды `refresh` хаб обнуляет `issues_count`, `links_count` её + `sync_run`; `started_at` не меняется (по нему полный прогон помечает соседей — так безопаснее: прочитанное первым + выполнением не станет соседом). +7. **Пустой полный прогон (M-12 #4).** Пометка соседей по полному прогону делается только при `sync_run.issues_count > 0`. + Пустой результат валидной JQL почти всегда означает сбой выборки (сменили метку, права), а не «все задачи ушли». +8. **Типы параметров (M-3 #12).** `commands.validate` проверяет, что строковые параметры (`summary`, `description`, + `temp_key`, `key`, `marker`, `body`, `source`, `target`, `link_type`, `sprint`, `assignee`, `priority`, даты) — + строки или `null` там, где `null` разрешён; иначе `ValueError` → 422. `target` в `ensure_status` — строка или список + строк; `labels`, `labels_add`, `labels_remove`, `keys` — списки строк; `replace` — bool. +9. **Миграция под блокировкой (M-2 #12).** `migrate`: версия читается вне транзакции; если ниже текущей — `BEGIN + IMMEDIATE` (блокировка записи), повторное чтение версии, и только если всё ещё ниже — копия базы и миграция в той же + транзакции. Копия — `sqlite3.Connection.backup` из того же соединения, работает при открытой транзакции (читает + снимок). Второй `connect`, дождавшись блокировки, видит новую версию и копию не делает. Docstring исправляется. +10. **Реле (M-3 #8, M-4 #8).** `_forward` ловит `http.client.HTTPException` вместе с `URLError`/`OSError` → 502. Чистка + `work/` выносится из `_prepare` в старт реле (первый `_prepare` после запуска потока); повторная подготовка (смена + `relay_dir`, пропала папка) создаёт подкаталоги без чистки и пишет «relay: папка подготовлена заново». Запросы, + взятые до пропажи папки, обрабатываются своими потоками или пропадают вместе с папкой. +11. **Отмена пакета с взятой командой (M-11 #12).** `cancel_batch` возвращает вид пакета с полем `still_claimed` — + список `{id, type}` команд пакета в `claimed`. MCP `batch_cancel` выводит строку «команда #N уже у агента и будет + выполнена; остановить — `POST /commands/N/cancel` с `force: true`». Отдельный инструмент MCP не добавляется. +12. **Подсказка про метки (T3.7).** Описание MCP-инструмента `batch_create`: `ensure_issue` должна + нести `labels` с базовой меткой (`AWPCA`), иначе задача окажется вне `base_jql` и выпадет из зеркала. Код не меняется. +13. **Спеки к коду (разбор D).** Только текст спек: `sprint_names` убирается из перечня настроек (спринты агент читает с + доски `board_id`); коды ответов — 400/401/404/409/413/422/500, реле — 502; прочие методы HTTP — 501 без JSON; + `page`/`last_page` в `POST /agent/state` — информационные; `GET /issues` отдаёт поля зеркала с `fact_status`, + `work_kind`; автообновление — нет `queued`/`claimed` и последний `sync_run.started_at` старше интервала; MCP + `refresh` — id пакета или итог при `wait`; `new:<строка>` — только для `new_tasks.csv`, строки `tasks.csv`/`epics.csv` + без ключа пропускаются (T7.3). +14. **Purpose спек (O-4 #8).** В каждый `openspec/specs/*/spec.md` вписывается назначение capability (2–3 предложения) + прямо в ветке: дельты OpenSpec раздел Purpose не меняют. +15. **Тесты (M-1 #8, M-10 #12).** Тест реле при `serve --host 127.0.0.2` (или другом конкретном адресе) — реле пересылает + на этот адрес. `test_fresh_definer_still_blocks_queue` переименовывается по смыслу довыдачи. Сквозной тест: база v1 + с командой в `claimed` → `connect` (миграция, `deliveries = 1`) → `claim` отдаёт её снова с `deliveries = 2`. + +Агентские решения (подробно — `jira-agent/openspec/changes/bridge-hygiene/design.md`): + +16. **CRLF (T3.9).** Сравнение описания в `ensure_fields` и тела комментария в `ensure_comment` — после + `replace("\r\n", "\n")` с обеих сторон; отправляется текст команды как есть. Поиск маркера комментария не меняется. +17. **Время журнала в будущем (M-6 #12).** `restore_pace` принимает его за «сейчас»: `last_call_at = monotonic()`, пауза + первой команды — полная `pause_seconds`. Лог сохраняется. +18. **`last_call_iso` (M-7 #12).** `restore_pace` заполняет только `last_call_at`; `last_call_iso` — только при настоящем + вызове Jira. До первого вызова в `claim` уходит `null`, хаб (`COALESCE`) сохраняет прошлое значение. +19. **Ответ папки с неверными типами (M-2 #8).** `status` не целое или `body` не строка — как нечитаемый JSON: + перечитать до 2 с, затем `ConnectionError`. +20. **`prune` под блокировкой (T2.3).** `prune` берёт ту же `acquire_lock`; если занята — «агент запущен, prune невозможен», + код 1. README: снять «Блокировки нет». +21. **Версия агента 4.** Поведение сравнения и темпа изменилось; `min_agent_version` не поднимается. + +## Risks / Trade-offs + +- [chmod на чужом `JIRA_HUB_HOME`] → каталог задаёт владелец; при ошибке `chmod` (не владелец) хаб пишет предупреждение + и продолжает. +- [Слияние заметок выбирает значение временной заметки] → поля `new:N` заполняются при планировании и свежее; поля, + пустые у временной, сохраняются под ключом. +- [M-12: настоящий «0 задач» в базовой JQL] → соседи не пометятся до следующего непустого прогона; зеркало это переживает. +- [Миграция в `BEGIN IMMEDIATE` держит блокировку записи на время копии] → база меньше 1 МБ, копия — доли секунды; + миграция бывает раз в версию. +- [CRLF: Jira хранит `\r\n`, команда — `\n`] → после правки агентом в Jira будет `\n`; это и есть текст команды. + +## Migration Plan + +1. Хаб: merge → `git pull` в `jira-hub` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`; проверить + `stat -f %Lp ~/.local/share/jira-hub` = 700, `schema_version` = 2 (миграции нет). +2. Агент: остановить v3 (Ctrl-C), положить `agent.py` v4 в `RSHB/` (корень общей папки), на виртуалке скопировать в + `~/jira-agent-bundle`, `check`, `run`; `agent_status` — версия 4. +3. Откат: `git checkout` предыдущего `main` и перезапуск; агент — вернуть файл v3. + +## Open Questions + +Нет. + +## Decisions made without discussion + +Владелец 14.10.2026 утвердил состав пакета и решения 5 (M-4 #12), 16 (CRLF), 18 (M-7 #12). Остальное — рулинги +при написании спеки. + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 (design #1) | 2026-10-14 | Права `0700` только на каталоги; при сбое `chmod` — предупреждение, не остановка | закрытый каталог закрывает файлы; хаб под launchd не должен падать из-за прав | токены остаются читаемы, если каталог чужой — видно в логе | +| R-2 (design #2) | 2026-10-14 | При слиянии заметок побеждает непустое поле временной заметки | поля `new:N` заполняются при планировании и свежее | владелец потеряет поле под ключом, если временная заметка заполнена тем же полем — маловероятно | +| R-3 (design #3) | 2026-10-14 | Пакет «отменён», если есть `cancelled_at` и всё терминально, даже при части `done` | после отмены остаток не применён — «завершён» вводит в заблуждение | только отображение | +| R-4 (design #6) | 2026-10-14 | Довыдача `refresh` обнуляет счётчики, `started_at` не трогает | `started_at` — граница пометки соседей; сдвиг пометил бы прочитанное первым выполнением | только отображение счётчиков | +| R-5 (design #7) | 2026-10-14 | Соседи по полному прогону помечаются только при `issues_count > 0` | пустая выборка валидной JQL — почти всегда сбой (метка, права) | настоящий «0 задач» не пометит соседей до следующего прогона | +| R-6 (design #8) | 2026-10-14 | Проверка типов всех параметров команд, не только `summary` | M-3 #12 — частный случай; одна проверка дешевле серии 500 | слишком строгая проверка отвергнет допустимый пакет — 422 с именем параметра, видно сразу | +| R-7 (design #9) | 2026-10-14 | Копия перед миграцией — внутри `BEGIN IMMEDIATE`, после повторной проверки версии | одна копия при параллельных `connect` (M-2 #12) | блокировка записи на долю секунды при миграции | +| R-8 (design #10) | 2026-10-14 | `work/` чистится только при первой подготовке после старта потока реле | при повторной подготовке взятые запросы ещё обрабатываются своими потоками (M-4 #8) | после пропажи папки в `work/` может остаться файл до перезапуска — безвредно | +| R-9 (design #11) | 2026-10-14 | Отмена пакета возвращает `still_claimed`; MCP-инструмент отмены команды не добавляется | M-11 #12 — про информирование; force-отмена нужна редко | для остановки взятой команды нужен вызов API, не MCP | +| R-10 (design #13) | 2026-10-14 | Расхождения спеки и кода из разбора D закрываются правкой спеки, не кода | код разумнее текста спеки (спринты с доски, автообновление от `sync_run`), потребитель один | нет | +| R-11 (design #21) | 2026-10-14 | Агент — версия 4, `min_agent_version` не поднимается | поведение сравнения и темпа изменилось; v3 продолжает работать | нет | diff --git a/openspec/changes/bridge-hygiene/proposal.md b/openspec/changes/bridge-hygiene/proposal.md new file mode 100644 index 0000000..94374af --- /dev/null +++ b/openspec/changes/bridge-hygiene/proposal.md @@ -0,0 +1,91 @@ +## Why + +Мост в ежедневной работе с 13.10.2026; пакеты A (`bridge-reliability`, #9) и B (проверки на живой Jira) закрыты. Осталось +три follow-up issue в каждом репозитории (hub#4, #8, #12; agent#4, #8, #12). Пакет D (разбор хвоста hub#4/agent#4, +14.10.2026) разделил их на «в C», «отклонить», «на потом». Этот change — пакет C: мелкие исправления, приведение спек +к коду и гигиена, одним change на оба репозитория. Новых возможностей нет. + +Что чинится по существу: +- **M-7 (hub#4)** — каталог данных хаба `0755`, база `0644`, а в базе токены: читаемы любым локальным пользователем. +- **M-3 (hub#4, PR #2)** — `rename_note` при переносе `new:N` → ключ удаляет заметку, уже стоящую под настоящим ключом. +- **T2.3 (agent#4)** — `prune` перезаписывает журнал без блокировки: запись `finished`, добавленная работающим агентом, + теряется (потерянный отчёт). +- **M-4 (hub#12)** — поздний `done` с ключом по `ensure_issue`, ставшей `failed` по пределу выдач, отбрасывается: заметка + `new:N` не переезжает; у `refresh`, ставшего `failed` по пределу, `sync_run.finished_at` пуст. +- **M-5 (hub#12)** — повторное выполнение `refresh` после довыдачи удваивает счётчики `sync_run`. +- **M-12 (hub#4)** — полный прогон, вернувший 0 задач, помечает всё зеркало соседями. +- **T5.6 (hub#4)** — отменённый пакет с частью выполненных команд показывается «завершён». +- **M-3, M-4 (hub#8)** — реле: `HTTPException` от своего HTTP не превращается в 502; `work/` чистится при каждой + повторной подготовке, а не только при старте. +- **M-2 (hub#12)** — параллельные `connect` к базе старой версии делают по копии на соединение. +- **M-3 (hub#12)** — нестроковый `summary` в `ensure_issue` даёт 500 вместо 422. +- **M-2 (agent#8)** — ответ папки с неверными типами `status`/`body` роняет агента. +- **M-6 (agent#12)** — время последней записи журнала в будущем отключает паузу темпа. +- **M-7 (agent#12)** — после перезапуска в `claim` уходит время записи журнала как «последний вызов Jira». +- **T3.9 (agent#4)** — Jira хранит текст, правленный в UI, с `\r\n`; агент сравнивает с `\n` из команды и делает лишний + `PUT` описания/комментария (проверено на живой Jira 13.10.2026: 71 из 180 задач). + +Решения владельца 14.10.2026 (разбор D и brainstorming): +- состав пакета C — по таблице разбора D (комментарии hub#4 #3323, agent#4 #3326), плюс M-12 и подсказка про `labels`; +- T-n без сохранившегося текста ревью — отклонены пачкой; +- живая проверка потери отчёта, довыдачи и force-cancel — отдельно, не в этом change; +- M-4 (hub#12): поздний отчёт по `failed` по пределу выдач — как по отменённой (ключ в результат, заметка переезжает, + статус остаётся `failed`); `refresh`, ставший `failed` по пределу, получает `sync_run.finished_at`; +- M-7 (agent#12): в `claim` уходит только время настоящего вызова Jira; оценка из журнала — только для паузы; +- CRLF: агент сравнивает описание и тело комментария после замены `\r\n` на `\n`, отправляет как есть; хаб не меняется. + +## What Changes + +- **jira-hub**: + - права `0700` на каталог данных и `backups/` (при создании и при старте); + - `rename_note` сливает заметки: поле под настоящим ключом сохраняется, если у временной заметки оно пустое; + - статус пакета `cancelled`, если пакет отменён и все его команды в терминальных статусах; + - `report_result` обрезает `error` до 1500 символов; + - поздний отчёт `done` с ключом по `ensure_issue` в `failed` без результата — как по `cancelled`; при переводе `refresh` + в `failed` по пределу выдач — `sync_run.finished_at`; + - довыдача `refresh` обнуляет `issues_count`/`links_count` его `sync_run`; + - полный прогон с `issues_count = 0` не помечает задачи соседями; + - `commands.validate`: строковые параметры проверяются на тип (422); + - миграция: копия перед миграцией и сама миграция — под одной блокировкой записи, копия одна; + - реле: `http.client.HTTPException` → 502; `work/` чистится только при старте реле; строка лога при повторной + подготовке не говорит «перезапуск»; + - ответ `POST /batches/{id}/cancel` и MCP `batch_cancel` сообщают о команде пакета, уже взятой агентом; + - MCP `batch_create`: в описании инструмента — передавать базовые метки (`labels`) в `ensure_issue`; + - тесты: `serve --host ` для реле, переименование `test_fresh_definer_still_blocks_queue`, сквозной тест + «база v1 → миграция → довыдача 1 → 2»; + - `.wt/` в `.gitignore`; `ARCHITECTURE.md` — реле и `relay_dir`. +- **jira-agent** (версия 4): + - сравнение описания и тела комментария без различия `\r\n`/`\n`; + - время журнала в будущем — принимается за «сейчас» (полная пауза); + - в `claim` — только время настоящего вызова Jira в этом процессе; + - ответ папки с неверными типами `status`/`body` — `ConnectionError` (как нечитаемый JSON); + - `prune` берёт блокировку журнала и отказывает при работающем агенте; README; + - лог «хаб: хаб не ответил…» без повтора слова. +- Спеки: `hub-api`, `data-model`, `commands`, `agent`, `mcp`, `migration` — MODIFIED. Кроме исправлений, спеки + приводятся к коду (разбор D): нет настройки `sprint_names`; коды ответов 413/500/502 и 501 на прочие методы; + `page`/`last_page` информационные; `GET /issues` — поля зеркала; автообновление — от последнего `sync_run`; + MCP `refresh` — id пакета; `new:<строка>` — только для `new_tasks.csv`. Раздел `## Purpose` всех шести спек + заполняется (сейчас — заглушка `TBD`, из-за неё падает `openspec validate --specs --strict`). + +## Non-goals + +- Живые проверки потери отчёта, довыдачи, force-cancel и `ensure_link` (agent#12 O-1) — отдельно, на тестовой задаче. +- «На потом» из разбора D: ResourceWarning в тестах хаба (M-9/O-4), пробелы тестов (M-10 хаба), точность фейков агента + (M-10 агента). +- Отклонённые в разборе D пункты (M-4, M-8/T6.12, T5.4, T5.11, T6.5, T6.6, T9.1, T1.1, M-6, M-11 хаба; FM-1…FM-3, O-2, + T1.9, N-3, T2.7, T3.2, T3.3, T4.7, T4.11, T5.2, T7.6, «Task 2 minor» агента; T-n без текста). +- Автодобавление базовых меток хабом или агентом (T3.7): только подсказка в описании инструмента. +- MCP-инструмент отмены отдельной команды: `batch_cancel` лишь подсказывает про `force`. +- Подъём `min_agent_version` до 4. + +## Impact + +- `jira-hub`: `paths.py`, `db.py` (миграция под блокировкой, права), `mirror.py` (`rename_note`), `queue.py` (`_status_of`, + `report_result`, `_redeliver`, `claim`, `cancel_batch`), `commands.py` (`validate`), `relay.py`, `api.py` (ответ отмены + пакета), `mcp_server.py` (`batch_cancel`, `batch_create`), тесты, `.gitignore`, `ARCHITECTURE.md`. +- `jira-agent`: `src/agent.py` (`do_ensure_fields`, `do_ensure_comment`, `restore_pace`, `JiraClient.last_call_iso`, + `FolderTransport`, `cmd prune`, лог), `README.md`, тесты, `.gitignore`; версия 4. +- Контракт хаб ↔ агент: тела запросов не меняются; ответ отмены пакета получает поле `still_claimed`. +- Выкатка: хаб — `git pull` + перезапуск launchd (схема не меняется), проверить права каталога; агент v4 — через + общую папку, как v3. +- Issues: aep/jira-hub#13, aep/jira-agent#15; закрывают hub#8, hub#12, agent#8, agent#12, а hub#4/agent#4 — кроме «на потом». diff --git a/openspec/changes/bridge-hygiene/specs/agent/spec.md b/openspec/changes/bridge-hygiene/specs/agent/spec.md new file mode 100644 index 0000000..f0d3dbe --- /dev/null +++ b/openspec/changes/bridge-hygiene/specs/agent/spec.md @@ -0,0 +1,90 @@ +## MODIFIED Requirements + +### Requirement: Журнал на диске и восстановление +Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `claimed {command_id, at}` (сразу после +получения команды, до паузы), `started {command_id, at}`, `finished {command_id, status, result, error, jira_calls, +rate_limited, at}`, `acked {command_id, at}`. При старте агент SHALL: отправить хабу все `finished` без `acked`; для +`claimed`/`started` без `finished` — запросить команду у хаба по id и, если она в хабе всё ещё `claimed`, выполнить её +заново по правилу «проверь, потом сделай» с паузой `pause_seconds` из ответа хаба, не записывая `claimed` повторно. +Команда `python3 agent.py prune` SHALL удалять из журнала записи с `acked` старше 30 дней; она SHALL брать ту же +блокировку `<журнал>.lock`, что `run`, и при занятой блокировке завершаться с кодом 1 и сообщением «агент запущен», +не меняя журнал. + +#### Scenario: Падение после ответа Jira +- **WHEN** агент упал между `POST /issue` и записью `finished`, затем перезапущен +- **THEN** агент находит в журнале `started` без `finished`, запрашивает команду у хаба, находит созданную задачу поиском + дубля и отчитывается `done` с её ключом; второй задачи нет + +#### Scenario: Восстановление без лишней записи +- **WHEN** в журнале `claimed 40` без `finished`, хаб отдаёт команду 40 в `claimed` с `pause_seconds = 30` +- **THEN** в журнал дописываются `started 40`, `finished 40`, `acked 40`; второй строки `claimed 40` нет; перед выполнением + агент выдерживает 30 с от последнего вызова Jira + +#### Scenario: prune при работающем агенте +- **WHEN** `agent.py run` работает, и запущен `agent.py prune` с тем же журналом +- **THEN** `prune` пишет «агент запущен…» и завершается с кодом 1; журнал не изменён + +### Requirement: Ограничение темпа и 429 +Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. При +старте агент SHALL принять за время последнего вызова Jira самое позднее `at` в журнале; если оно в будущем (часы +сдвинулись назад) — текущий момент (пауза перед первой командой — полная `pause_seconds`); при пустом журнале паузы +перед первой командой нет. Оценка из журнала служит только паузе: в `claim` агент SHALL передавать `last_jira_call_at` +только после настоящего вызова Jira в текущем процессе, до него — `null`. На HTTP 429 и 503 агент SHALL ждать +`Retry-After` (иначе 60 с × номер попытки) и повторять тот же запрос до 5 раз, считая попытки в `rate_limited`; после +5 неудач — `failed`. Пауза между записями внутри одной команды — по зазору типа команды. + +#### Scenario: 429 с Retry-After +- **WHEN** Jira ответила 429 с `Retry-After: 90` +- **THEN** агент ждёт 90 с, повторяет тот же запрос; в отчёте `rate_limited = 1` + +#### Scenario: Перезапуск не сбивает темп +- **WHEN** последняя запись журнала `finished` сделана 10 с назад, агент перезапущен и получил команду с `pause_seconds = 30` +- **THEN** агент ждёт около 20 с перед первым вызовом Jira по команде + +#### Scenario: Время журнала в будущем +- **WHEN** последняя запись журнала на 2 часа позже текущего времени, агент перезапущен и получил команду с `pause_seconds = 30` +- **THEN** агент ждёт 30 с перед первым вызовом Jira; в логе — что время журнала в будущем + +#### Scenario: После перезапуска хаб не получает оценку из журнала +- **WHEN** агент перезапущен с непустым журналом и отправляет первый `claim` +- **THEN** `last_jira_call_at` в `claim` — `null`; после первого вызова Jira следующий `claim` несёт его время + +### Requirement: Доставка и запуск +README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json` +(включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist, +`prune` (только при остановленном агенте, иначе откажет). Версия агента SHALL быть в константе файла (4 — со сравнением +текстов без различия переводов строк и уточнённым восстановлением темпа) и в каждом отчёте; хаб предупреждает в +`agent_status`, если версия ниже минимальной из настроек. + +#### Scenario: Устаревший агент +- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2` +- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему + +### Requirement: Транспорт через общую папку +При `hub_url = "folder:<путь>"` агент SHALL обращаться к хабу теми же запросами, что по HTTP, через файлы в `<путь>`: +на каждый вызов записать `req/.json` = `{id, method, path, body, authorization, deadline}` (`id` — `uuid4().hex`, +`authorization` — `Bearer `, `deadline` — момент UTC = сейчас + таймаут вызова; для `claim` таймаут не меньше +45 с) через временный файл с именем на точку и переименование, затем опрашивать `resp/.json` каждые 0,1 с. +Полученный ответ агент SHALL удалить; `status` 2xx — вернуть разобранное тело, иной — выбросить `HttpError(status, body)` +так же, как HTTP-клиент. Если к `deadline` ответа нет, агент SHALL удалить свой `req/.json` (если он ещё там) и +выбросить `ConnectionError`. Нечитаемый ответ — не JSON-объект, `status` не целое число или `body` не строка — SHALL +перечитываться до 2 с, затем `ConnectionError`. Чужие файлы в `resp/` агент MUST NOT трогать. Цикл выполнения, журнал, +повторы отчёта и восстановление SHALL работать без изменений. + +#### Scenario: Ответ хаба через папку +- **WHEN** агент вызвал `settings`, реле положило `resp/.json` со `status = 200` +- **THEN** агент получает настройки, файл ответа удалён + +#### Scenario: Реле не отвечает +- **WHEN** агент вызвал `claim`, а ответа нет 45 с +- **THEN** `req/.json` удалён агентом, выброшен `ConnectionError`; цикл пишет «хаб недоступен» и повторяет `claim` + через `poll_seconds`, как при недоступном хабе по сети + +#### Scenario: Код ошибки хаба через папку +- **WHEN** агент отправил `result` по команде, которой в хабе нет, реле положило ответ со `status = 404` +- **THEN** агент получает `HttpError(404)` и считает команду закрытой, как по HTTP + +#### Scenario: Ответ с неверными типами +- **WHEN** в `resp/.json` лежит `{"id": …, "status": "ok", "body": 5}` +- **THEN** агент выбрасывает `ConnectionError` («нечитаемый ответ»), процесс не падает; цикл повторяет вызов как при + недоступном хабе diff --git a/openspec/changes/bridge-hygiene/specs/commands/spec.md b/openspec/changes/bridge-hygiene/specs/commands/spec.md new file mode 100644 index 0000000..906b429 --- /dev/null +++ b/openspec/changes/bridge-hygiene/specs/commands/spec.md @@ -0,0 +1,107 @@ +## MODIFIED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | `GET /issue/{key}` (поле спринта), список спринтов доски из `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | +| `refresh` | `keys` (список) или `jql` | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks` | ничего в Jira; состояние уходит в хаб | + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). + +Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira +без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный +в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Описание отличается только переводами строк +- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"` +- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +### Requirement: Пакет и порядок выполнения +Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка +и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh`). Хаб SHALL проверять +типы параметров: текстовые параметры и ключи — строки (`null` — только где параметр можно очистить), `target` команды +`ensure_status` — строка или список строк, `labels`, `labels_add`, `labels_remove`, `keys` — списки строк, `replace` — +логическое значение; нарушение — 422 с именем параметра. Пакет с двумя и более `ensure_issue` с одинаковой `summary` +(после обрезки пробелов по краям) SHALL отвергаться (422). Очередь SHALL выдавать команды в порядке +`(batch.created_at, seq)`, одну в полёте. Пакет и его `queued` команды MAY быть отменены; `claimed` команда MUST NOT +отменяться автоматически. + +#### Scenario: Оценка длительности +- **WHEN** создаётся пакет из 3 `ensure_fields` и 2 `ensure_comment` +- **THEN** `estimate_seconds` = сумма пауз по политике для последовательности типов + пауза `refresh` + +#### Scenario: Две задачи с одной темой +- **WHEN** пакет содержит `ensure_issue {temp_key: new:1, summary: "Отчёт"}` и `ensure_issue {temp_key: new:2, summary: " Отчёт "}` +- **THEN** 422 с темой «Отчёт», пакет не создан + +#### Scenario: Параметр не того типа +- **WHEN** пакет содержит `ensure_fields {key: X, labels_add: "AWPCA"}` +- **THEN** 422 с указанием `labels_add`, пакет не создан + +### Requirement: Ошибки и подтверждение по состоянию +Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`, +обрезая присланное. Пакет после ошибки продолжается. + +**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому +команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL +сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры, +`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL +перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed` +или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать +сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался +`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести +заметку, не меняя статус (как для отменённой, см. `hub-api`). + +**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб +SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают; `ensure_status` — статус равен последнему +в пути; `ensure_link` — связь есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment` +и `refresh` по состоянию не подтверждаются: потерянный отчёт по ним закрывается повторной выдачей (агент находит +созданную задачу поиском дубля) или отменой человеком. + +#### Scenario: Отчёт потерян, факт на месте +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2» +- **THEN** команда `confirmed_by_state` + +#### Scenario: Ответ на claim потерян +- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim` +- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40 + +#### Scenario: Предел выдач +- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42 +- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42 + +#### Scenario: Потерянный отчёт о создании задачи +- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim` +- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй + задачи нет, заметка `new:5` перенесена на ключ diff --git a/openspec/changes/bridge-hygiene/specs/data-model/spec.md b/openspec/changes/bridge-hygiene/specs/data-model/spec.md new file mode 100644 index 0000000..11b0cbd --- /dev/null +++ b/openspec/changes/bridge-hygiene/specs/data-model/spec.md @@ -0,0 +1,122 @@ +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_note`, `batch`, `command`, +`sync_run`, `agent_status`, `setting`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру версии +в `setting.schema_version` (текущая — 2). Миграция существующей базы SHALL выполняться в одной транзакции с блокировкой +записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени). + +#### Scenario: Первый запуск +- **WHEN** хаб стартует с несуществующим файлом базы +- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700` + +#### Scenario: Миграция базы v1 +- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` +- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); данные на месте; + `schema_version = 2` + +#### Scenario: Два соединения к базе v1 +- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения +- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` + +#### Scenario: Открытый каталог данных +- **WHEN** хаб стартует, каталог данных существует с правами `0755` +- **THEN** права каталога и `backups/` — `0700` + +### Requirement: Зеркало задачи +Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki), +`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список), +`sprint`, `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link` +SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается +на `target` (у `source` в GET это `outwardIssue`). Обновлять `issue` и `issue_link` SHALL только загрузка состояния от +агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Полный прогон (`refresh` по базовой JQL), завершившийся +`done`, SHALL помечать `neighbor = 1` задачи, не обновлённые им, — только если прогон загрузил хотя бы одну задачу. + +#### Scenario: Загрузка состояния заменяет связи +- **WHEN** агент прислал состояние задачи X с двумя связями +- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные + со стороны X, не трогаются до загрузки их источника + +#### Scenario: Соседняя задача +- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL +- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0` + +#### Scenario: Пустой полный прогон +- **WHEN** полный `refresh` завершился `done`, загрузив 0 задач +- **THEN** признак `neighbor` задач зеркала не меняется + +### Requirement: Служебные заметки +Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:`), +`fact_status`, `work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`. Обновление зеркала +MUST NOT менять `issue_note`. При завершении `ensure_issue` с временным ключом хаб SHALL перенести заметку на настоящий +ключ — в том числе по позднему отчёту `done` с ключом о команде, отменённой или переведённой в `failed` до отчёта. Если +под настоящим ключом заметка уже есть, хаб SHALL слить их: каждое поле берётся из временной заметки, если там оно +непустое, иначе остаётся под настоящим ключом; `updated_at`/`updated_by` — от более поздней; временная строка удаляется. + +#### Scenario: Заметка переживает refresh +- **WHEN** у задачи есть `issue_note.fact_status = "В работе"` и приходит полное обновление зеркала +- **THEN** `fact_status` не изменился + +#### Scenario: Слияние с заметкой под ключом +- **WHEN** у `new:5` заметка `work_kind = "Аналитика"`, `owner_note` пусто; у `AURORA-1640` заметка `owner_note = "спросить ИБ"`; + `ensure_issue` для `new:5` завершилась с `AURORA-1640` +- **THEN** у `AURORA-1640` `work_kind = "Аналитика"` и `owner_note = "спросить ИБ"`; строки `new:5` нет + +### Requirement: Пакеты и команды +Таблица `batch` SHALL хранить `id`, `title`, `author`, `created_at`, `cancelled_at`, `estimate_seconds`. +Таблица `command` SHALL хранить `id`, `batch_id`, `seq`, `type`, `params` (JSON), `status` +(`queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`), `claimed_at` (время последней выдачи), +`deliveries` (сколько раз команда выдана агенту), `finished_at`, `result` (JSON), `error` (до 1500 символов), `jira_calls`, +`rate_limited` (число 429/503), `pause_seconds` (выданная пауза), `depends_on` (id команды, чей результат подставляется). +Статус пакета SHALL вычисляться из команд и `cancelled_at`, не храниться: есть команды не в терминальном статусе — +«выполняется» (или «в очереди», если все `queued`); все терминальные и пакет отменён (`cancelled_at`) — «отменён»; +все терминальные без отмены — «завершён». + +#### Scenario: Статус пакета +- **WHEN** в пакете 3 команды: `done`, `claimed`, `queued` +- **THEN** статус пакета «выполняется»; когда все в терминальных статусах — «завершён», с числом `failed`/`skipped` + +#### Scenario: Отменённый пакет с выполненной командой +- **WHEN** в пакете команда 1 `done`, команда 2 `claimed`, команда 3 `queued`; пакет отменён, затем агент отчитался по 2 +- **THEN** статус пакета «отменён» (`cancelled`), счётчики показывают две `done` и одну `cancelled` + +### Requirement: Журнал синхронизаций и статус агента +Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`, +`issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. При повторной выдаче команды `refresh` хаб +SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started_at` не меняется); при переводе `refresh` в `failed` +по пределу выдач — проставить `finished_at`. Таблица `agent_status` (одна строка) SHALL хранить `last_seen_at`, +`agent_version`, `settings_version`, `current_command_id`, `last_jira_call_at`. Через API и MCP `agent_status` SHALL +отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и списком `redelivered` — команд в +`claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, `deliveries`). + +#### Scenario: Агент давно не выходил на связь +- **WHEN** `last_seen_at` старше 5 минут +- **THEN** `agent_status` через API и MCP отдаётся с `online = false` + +#### Scenario: Довыдача refresh не удваивает счётчики +- **WHEN** `refresh` загрузил 150 задач, отчёт потерян, команда выдана снова и загрузила те же 150 +- **THEN** у её `sync_run` `issues_count = 150` + +#### Scenario: refresh сдался по пределу выдач +- **WHEN** `refresh` переведён в `failed` «агент не отчитался после 3 выдач» +- **THEN** у его `sync_run` проставлен `finished_at` + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`transitions_map`, `pacing`, `board_id`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой +правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; +относительный путь и не-строка при записи — 422; агенту не передаётся). Спринты агент читает с доски `board_id`, +отдельной настройки имён спринтов нет. Начальные значения 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` не изменился diff --git a/openspec/changes/bridge-hygiene/specs/hub-api/spec.md b/openspec/changes/bridge-hygiene/specs/hub-api/spec.md new file mode 100644 index 0000000..22ffef8 --- /dev/null +++ b/openspec/changes/bridge-hygiene/specs/hub-api/spec.md @@ -0,0 +1,169 @@ +## MODIFIED Requirements + +### Requirement: Процесс и доступ +`jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:` (по умолчанию 8790), база и настройки в +`~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты SHALL требовать `Authorization: Bearer `: +агентский токен открывает только `/agent/*`, клиентский — всё остальное. Ответы — JSON, ошибки — `{error, detail}` с +кодами 400 (неверное тело или `Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); +реле общей папки дополнительно отвечает 502. Методы, кроме GET, POST и PUT, отвечают 501 стандартной страницей +`http.server`, не JSON. + +#### Scenario: Чужой токен +- **WHEN** агентским токеном вызван `GET /issues` +- **THEN** 401 + +#### Scenario: Неподдерживаемый метод +- **WHEN** `DELETE /issues/AURORA-1` +- **THEN** 501, тело не JSON; база не меняется + +### Requirement: Маршруты агента +Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): +- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at}` — долгий запрос до 30 с; ответ `{command: + {id, type, params, pause_seconds}, settings_version}` или `{command: null, settings_version}`; обновляет `agent_status` + (`last_jira_call_at = null` не затирает прежнее значение). Если есть команда в `claimed`, `claim` SHALL выдать её снова + (повторная выдача, см. `commands`), а не следующую из очереди. +- `GET /agent/settings` — настройки для агента (без токенов). +- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`. +- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный + отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по + `ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N` + переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до + 1500 символов. +- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния + пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая + пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. +- `GET /agent/known-keys` — ключи зеркала (для определения соседей). + +#### Scenario: Выдача с подстановкой ключа +- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590` +- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах + +#### Scenario: Повторный отчёт +- **WHEN** агент дважды прислал `result` для команды 15 +- **THEN** второй ответ 200, статус и результат не изменились + +#### Scenario: Отчёт по невыданной команде +- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued` +- **THEN** 409 `not_claimed`, команда осталась `queued` + +#### Scenario: Длинная ошибка +- **WHEN** агент прислал `failed` с `error` длиной 5000 символов +- **THEN** в `command.error` первые 1500 символов + +#### Scenario: Поздний отчёт после предела выдач +- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент + прислал `done` с `result.key = AURORA-1630` +- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630` + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, + `q` (подстрока темы), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок; + сокращение до карточки — дело интерфейса. +- `GET /issues/{key}` — задача целиком: поля, связи в обе стороны, заметки, последние 10 команд по ней. +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных + параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с + одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы). +- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды + пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены + агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N». +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`. +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +### Requirement: Автообновление зеркала +Если `auto_refresh_minutes > 0` и агент на связи, хаб SHALL поставить пакет «Автообновление» из `refresh {jql: base_jql}`, +когда в очереди нет команд в `queued` и `claimed` и последний `sync_run` начат раньше, чем `auto_refresh_minutes` назад +(или `sync_run` ещё нет). + +#### Scenario: Очередь занята +- **WHEN** интервал прошёл, но в очереди есть `queued` команды +- **THEN** автообновление не ставится до опустошения очереди + +#### Scenario: Есть выданная команда без отчёта +- **WHEN** интервал прошёл, `queued` нет, одна команда в `claimed` +- **THEN** автообновление не ставится; команда довыдаётся при следующем `claim` + +#### Scenario: Недавнее обновление +- **WHEN** очередь пуста, `auto_refresh_minutes = 60`, последний `sync_run` начат 20 минут назад +- **THEN** автообновление не ставится + +### Requirement: Реле через общую папку +`jira-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле +бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если +каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует — +записать одно предупреждение в лог и проверять снова. + +Обмен: +- запрос агента — файл `req/.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC + (ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать; +- реле SHALL брать запрос переименованием в `work/.json` и обрабатывать каждый взятый запрос в отдельном потоке; +- запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до + `deadline` осталось не меньше 35 с, иначе выбрасываться без ответа; +- остальные запросы реле SHALL пересылать в свой HTTP на `:` процесса `serve` (`127.0.0.1`, если `serve` + слушает `0.0.0.0`) тем же методом, путём и телом, с заголовком `Authorization` из файла, и записывать ответ + `resp/.json` = `{id, status, body}` (тело — строка ответа HTTP) через временный файл с именем на точку и + переименование; затем удалять `work/.json`; +- если соединение со своим HTTP не удалось или ответ нарушает протокол HTTP, ответ SHALL иметь `status = 502` и текст + ошибки в `body`; +- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог. + +При старте потока реле (первая подготовка папки) SHALL удалить файлы из `work/` с записью в лог. Повторная подготовка +(смена `relay_dir`, пропавший подкаталог) SHALL только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту +реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку +старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay +<секунды>`. 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** в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы + +#### Scenario: Сбой протокола своего HTTP +- **WHEN** при пересылке запроса свой HTTP оборвал ответ (`http.client.HTTPException`) +- **THEN** в `resp/` ответ со `status = 502` и текстом ошибки; реле продолжает работу + +#### Scenario: Повторная подготовка не трогает взятые запросы +- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/` +- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «перезапуск реле» diff --git a/openspec/changes/bridge-hygiene/specs/mcp/spec.md b/openspec/changes/bridge-hygiene/specs/mcp/spec.md new file mode 100644 index 0000000..be56907 --- /dev/null +++ b/openspec/changes/bridge-hygiene/specs/mcp/spec.md @@ -0,0 +1,39 @@ +## MODIFIED Requirements + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой +проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала. + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» diff --git a/openspec/changes/bridge-hygiene/specs/migration/spec.md b/openspec/changes/bridge-hygiene/specs/migration/spec.md new file mode 100644 index 0000000..3c689a5 --- /dev/null +++ b/openspec/changes/bridge-hygiene/specs/migration/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Импорт CSV-пакета +`jira-hub import <папка admin/jira-sync>` SHALL прочитать `tasks.csv`, `new_tasks.csv`, `epics.csv` и заполнить +`issue_note` по ключу Jira: статус по факту, вид работы, что сделано, что осталось, внутренние колонки. Строки +`new_tasks.csv` без ключа SHALL получать временный ключ `new:<номер строки>`; строки `tasks.csv` и `epics.csv` без ключа +SHALL пропускаться. `links.csv` SHALL импортироваться как сверочный набор в отчёт импорта (ожидаемые связи против зеркала +после первого `refresh`), не как команды. Связь, которой в зеркале нет как `source→target` того же типа, но есть как +`target→source` того же типа, SHALL попадать в `reversed_links`, а не в `missing_links`. Импорт идемпотентен: повтор +обновляет заметки, не дублирует. + +#### Scenario: Импорт и первая синхронизация +- **WHEN** выполнены `init`, `import`, запущены хаб и агент, `refresh full` завершён +- **THEN** число задач зеркала ≥ числа строк `tasks.csv` + созданных из `new_tasks.csv`; отчёт импорта перечисляет ключи + из CSV, которых нет в зеркале, связи из `links.csv`, которых нет в `issue_link`, и связи, которые есть в обратную сторону + +#### Scenario: Связь в обратную сторону +- **WHEN** в `links.csv` строка «AURORA-1445 → AURORA-1446, связанные», а в зеркале есть только «ASUPR_Связанные» + 1446→1445 +- **THEN** связь в `reversed_links`, в `missing_links` её нет + +#### Scenario: Эпик без ключа +- **WHEN** в `epics.csv` строка без значения в колонке «Ключ» +- **THEN** строка пропущена, заметка `new:*` для неё не создаётся diff --git a/openspec/specs/agent/spec.md b/openspec/specs/agent/spec.md index 8a12c65..2e1caaf 100644 --- a/openspec/specs/agent/spec.md +++ b/openspec/specs/agent/spec.md @@ -1,7 +1,7 @@ # agent Specification ## Purpose -TBD - created by archiving change bridge-core. Update Purpose after archive. +Агент моста `jira-agent`: один файл на Python 3.9 (только stdlib) на виртуалке банка. Получает команды хаба (по HTTP или через общую папку), выполняет их в Jira по правилу «проверь, потом сделай» с политикой темпа, ведёт журнал на диске для восстановления после сбоев и возвращает хабу результаты и состояние задач. Учётные данные Jira есть только у агента. ## Requirements diff --git a/openspec/specs/commands/spec.md b/openspec/specs/commands/spec.md index 177a60e..6396958 100644 --- a/openspec/specs/commands/spec.md +++ b/openspec/specs/commands/spec.md @@ -1,7 +1,7 @@ # commands Specification ## Purpose -TBD - created by archiving change bridge-core. Update Purpose after archive. +Контракт команд моста: каждая команда — желаемое состояние одной задачи Jira (`ensure_*`) или чтение состояния (`refresh`). Здесь типы команд и их параметры, временные ключи внутри пакета, порядок выполнения, политика темпа, обработка ошибок, повторная выдача и подтверждение по состоянию зеркала. ## Requirements diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index 32ef019..1e927e3 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -1,7 +1,7 @@ # data-model Specification ## Purpose -TBD - created by archiving change bridge-core. Update Purpose after archive. +Модель данных хаба: один файл SQLite с зеркалом задач Jira, служебными заметками, пакетами и командами, журналом синхронизаций, статусом агента и настройками. Здесь таблицы, их смысл, правила записи (зеркало пишет только загрузка состояния от агента) и миграции схемы. ## Requirements diff --git a/openspec/specs/hub-api/spec.md b/openspec/specs/hub-api/spec.md index daeeeba..0dd448c 100644 --- a/openspec/specs/hub-api/spec.md +++ b/openspec/specs/hub-api/spec.md @@ -1,7 +1,7 @@ # hub-api Specification ## Purpose -TBD - created by archiving change bridge-core. Update Purpose after archive. +HTTP API хаба `jira-hub serve`: маршруты агента (выдача команд, отчёты, загрузка состояния), маршруты интерфейсов (зеркало, заметки, пакеты, настройки), резервные копии, автообновление зеркала и реле общей папки для агента за VPN. ## Requirements diff --git a/openspec/specs/mcp/spec.md b/openspec/specs/mcp/spec.md index bea0da5..345f350 100644 --- a/openspec/specs/mcp/spec.md +++ b/openspec/specs/mcp/spec.md @@ -1,7 +1,7 @@ # mcp Specification ## Purpose -TBD - created by archiving change bridge-core. Update Purpose after archive. +MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструменты чтения зеркала и заметок, постановки и отслеживания пакетов команд, обновления зеркала, статуса агента и настроек — поверх HTTP API хаба с клиентским токеном. ## Requirements diff --git a/openspec/specs/migration/spec.md b/openspec/specs/migration/spec.md index 176c3cc..d0ba204 100644 --- a/openspec/specs/migration/spec.md +++ b/openspec/specs/migration/spec.md @@ -1,7 +1,7 @@ # migration Specification ## Purpose -TBD - created by archiving change bridge-core. Update Purpose after archive. +Переход с CSV-пакета `admin/jira-sync` на мост: начальные настройки хаба из `config.example.json`, разовый импорт служебных заметок и сверочного набора связей из CSV, и что остаётся в `admin/` как история. ## Requirements