Пакет D хаба: публикация агента, статус, настройки, тесты без утечек (#32) #36

Merged
aep merged 12 commits from feat/32-hub-hygiene into main 2026-10-15 10:04:25 +03:00
29 changed files with 2949 additions and 221 deletions

View File

@ -15,7 +15,7 @@
| `backup` | копии файла базы, ротация | `paths` | | `backup` | копии файла базы, ротация | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` |
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio | `client` | | `mcp_server` | инструменты MCP по stdio | `client` |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
@ -34,7 +34,7 @@
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти и отдаёт в `GET /agent`. - Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. - Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
## Границы ## Границы
@ -48,4 +48,3 @@
## Открытые вопросы ## Открытые вопросы
- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. - Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже.
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их. - Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`).

View File

@ -31,6 +31,7 @@
## Тесты ## Тесты
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются. - Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют. - SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют.
- Соединение SQLite в тесте — через фикстуру `open_db` (`tests/conftest.py`) или фикстуру `conn` модуля, иначе с явным `close()`: `pyproject.toml` (`filterwarnings`) делает незакрытое соединение ошибкой набора.
- Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе. - Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе.
## Первая подсистема ## Первая подсистема

View File

@ -116,6 +116,15 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером. запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером.
Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером.
Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и
выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб
не может разобрать (`version` не целое, `sha256` не 64 символа `[0-9a-f]`), означает «публикации нет» — одно предупреждение в
логе хаба на каждую новую ошибку.
Состояние обновления и версию запускателя агент сообщает в каждом `claim`, хаб держит их в памяти: сразу после
перезапуска хаба, пока агент не вышел на связь (до первого `claim` агента, обычно до 30 с), `agent_status` пишет «запускатель: неизвестно (агент ещё не
выходил на связь после перезапуска хаба)», а подсказки «агент запущен без run.py» нет.
## HTTP API ## HTTP API
Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON, Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
@ -139,8 +148,8 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
| `POST /batches/{id}/cancel` | клиент | отмена пакета | | `POST /batches/{id}/cancel` | клиент | отмена пакета |
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `update_hint`), журнал загрузок | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок |
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) |
```bash ```bash
TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p')

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-15

View File

@ -0,0 +1,116 @@
## Context
Хаб `main` 8229bba (web-app и agent-v9-hardening слиты). Issue aep/jira-hub#32. Источники: #25 (M-1…M-3, M-5…M-7),
#16 (T3.1, T3.2, T5.2, M-8, ре-ревью minor-1, minor-3, O-9, O-10, O-11), #4 (M-9/O-4, M-10). Сверка с кодом — 15.10.2026.
Найдено при чтении кода:
- `release._write_atomic` — `tmp.write_bytes` + `os.replace`, без `fsync` и без очистки `.tmp` при ошибке; `publish`
пишет `agent.py`, затем манифест. `cli` ловит только `PublishError`. Если манифест не записался, новый `agent.py`
не сходится со старым манифестом по sha → `load` → `None` → 404.
- `release.current`: `int(m["version"])` (6.9 → 6, `true` → 1), `str(m["sha256"])`; `log.warning` на каждый вызов
(каждый `claim` и `GET /agent`).
- `release.load`: `data.decode("utf-8")` после сверки sha, `UnicodeDecodeError` не ловится → 500.
- `Hub.agent_update = {"update_state": None, "launcher_version": None}` — в памяти; после перезапуска хаба до первого
`claim` `launcher_version = None` неотличимо от «агент без запускателя» (`release.hint`, `mcp_server.fmt_agent:115`).
- `h_settings_put` отвечает `{"settings_version": version}`.
- `queue._next`: при неразрешённом временном ключе — `skipped` «задача new:N не создана», независимо от того,
`failed` или `cancelled` команда-создатель (`_definer_status`).
- `commands.check_confirmed`: `(issue.get(field) or None) != (p[field] or None)` — `\r\n` в зеркале против `\n` в
параметре даёт «не совпало».
- `relay._cleaned: set[Path]`, проверка `self.root not in self._cleaned` — по написанию пути.
- Тесты: 249 passed; 88 `ResourceWarning: unclosed database` (test_queue 37, test_commands 18, test_mirror 15, test_db 6, test_importer 6, test_settings 6; в test_relay их нет).
## Goals / Non-Goals
**Goals:** публикация не оставляет хаб без годной версии при сбое записи и не роняет команду трейсбеком; статус агента
не утверждает неизвестное; подтверждение по состоянию работает на задачах с CRLF; ответы и тексты называют то, что
произошло; тесты закрывают найденные пробелы и чисты от `ResourceWarning`.
**Non-Goals:** см. proposal.
## Decisions
### Публикация агента
1. **Запись файла** (`_write_atomic`): временный файл `.<имя>.tmp` в том же каталоге, `write` + `flush` + `os.fsync`,
`os.replace`; при `OSError` временный файл удаляется (ошибки удаления игнорируются), исключение пробрасывается.
2. **Порядок публикации:** `agent.py` → `agent.py.prev` (`os.replace`, если `agent.py` есть); новый файл через
`_write_atomic` в `agent.py`; манифест через `_write_atomic`. `OSError` после переименования — `agent.py.prev`
возвращается на место `agent.py` (`os.replace`; если и это не удалось — в лог), и `PublishError("не удалось записать
публикацию: <ошибка>")`. После успешной записи манифеста `agent.py.prev` удаляется. Итог: при любом сбое опубликована
прежняя версия (манифест и `agent.py` согласованы), `release/` без временных файлов.
3. **`cli`** печатает текст `PublishError` в stderr и возвращает 1 — как сейчас; новых веток нет, `OSError` до `cli` не
доходит.
4. **Годный манифест** (`current`): объект; `version` — `int` и не `bool`, ≥ 1; `sha256` — строка, `re.fullmatch(r"[0-9a-f]{64}")`.
Иначе — «публикации нет». Совпадает с проверкой агента v9 (design agent-v9-hardening 10).
5. **Предупреждение о битом манифесте** — один раз на каждый новый текст ошибки: модульная переменная с последним
текстом; успешное чтение или отсутствие манифеста её сбрасывает. Отсутствие манифеста — не ошибка, без лога.
6. **`load`**: `UnicodeDecodeError` → `log.error` «agent.py не в UTF-8 — публикации нет» и `None` (404 `no_release`).
### Статус агента
7. **`agent_reported`**: `Hub.agent_update` получает ключ `reported: False`; `note_agent_update` (вызывается на каждом
`claim`) ставит `True`. `GET /agent` отдаёт `agent_reported`. `release.hint(...)` получает признак и не выдаёт
«агент запущен без run.py…», пока `agent_reported = false`; подсказки об откате и отказе — по `update_state` как
сейчас (до первого `claim` он `null`).
8. **MCP `agent_status`**: строка запускателя — «запускатель: v<N>», «без запускателя» (агент сообщил `null`) или
«запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (`agent_reported = false`).
Если поля `agent_reported` в ответе нет (старый хаб) — прежнее поведение.
### Настройки, очередь, подтверждение, реле
9. **`PUT /settings`** отвечает `settings.get_all(conn)` (токены скрыты) после записи — те же поля, что `GET /settings`,
включая `settings_version`. Клиенты, читающие `settings_version` из ответа (`client`, `mcp_server.settings_set`,
web), не меняются.
10. **Пропуск зависимой команды**: если среди неразрешённых ключей есть ключ, чья `ensure_issue` в `cancelled`, текст —
«создание задачи new:N отменено» (по первому такому ключу); иначе прежний «задача new:N не создана».
11. **Сравнение строк в `check_confirmed`**: для полей `ensure_fields` со строковыми значениями сравнение после замены
`\r\n` на `\n` с обеих сторон; `None` и пустая строка по-прежнему равны. `\r\n` нормализуется во всех строковых полях `ensure_fields`. Метки и прочие поля — без изменений.
12. **Реле**: `_cleaned` хранит `Path.resolve()`; проверка — по `self.root.resolve()`. Ошибка `resolve` (папки нет) —
сравнение по исходному пути, как сейчас.
### Тесты и чистота
13. Новые тесты: bool в строковом параметре и `[]` в списочном (`test_commands`); `sync_run.finished_at` не ставится
при `failed` не-refresh по пределу выдач (`test_queue`; проверка T5.2 буквально невозможна, тест проверяет соседний эффект, как в плане); 501 на `DELETE`/`PATCH` (`test_api`); `epics.csv` без
«Ключ» (`test_importer`); «подготовлена заново» в `test_vanished_tree_keeps_work` (`test_relay`); предупреждение
«обновите агента» при `agent_version < min_agent_version`, офлайн-агент (нет контакта дольше порога) в
`GET /agent`, `tick` — проверка эффекта (`test_api`/`test_queue`); публикация — сбой записи манифеста возвращает
прежний `agent.py`, `.tmp` нет; битый манифест (`6.9`, `true`, короткий sha) — «публикации нет», предупреждение один
раз; не-UTF-8 → 404; `agent_reported` до и после `claim`; `PUT /settings` отдаёт `relay_dir`; текст «отменено»;
CRLF-подтверждение; реле с симлинком на ту же папку.
14. **`ResourceWarning`**: фикстуры и хелперы тестов закрывают соединения (`conn.close()` / `contextlib.closing`);
критерий — `uv run pytest -W error::ResourceWarning` без предупреждений `unclosed database` (pytest
`filterwarnings = error::ResourceWarning` в `pyproject.toml`, если не ломает сторонние пакеты; иначе — проверка в
плане командой).
15. **T3.1**: `_type_error` переписывается без тяжёлой строки с `and` и кортежем — отдельные ветки по типу.
## Risks / Trade-offs
- [`agent.py.prev` в `release/`] лишний файл живёт только между переименованием и записью манифеста; при падении
процесса посреди — остаётся; следующая публикация его перезапишет. → при падении процесса между переименованием и записью `agent.py` нет вовсе, и `GET /agent/release` отдаёт 404 до следующей публикации (как и прежде при падении между записью agent.py и манифеста).
- [«неизвестно» до первого claim] до первого `claim` агента (обычно до 30 с) после перезапуска хаба статус неполный. → честнее ложного «без запускателя».
- [`PUT /settings` отдаёт больше полей] клиенты, ожидавшие ровно `{settings_version}`, получают надмножество. → поле на
месте.
- [Конфликт с #29] текстовый в `tests/test_api.py` (одна строка у #29). → кто сливается вторым, разрешает.
## Decisions made without discussion
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-1 (design #7) | 2026-10-15 | Признак `agent_reported` в памяти, а не `launcher_version` в БД | без записи на каждый claim; значение из БД после перезапуска могло устареть | до 30 с «неизвестно» после перезапуска хаба |
| R-2 (design #2) | 2026-10-15 | Откат публикации через `agent.py.prev` (переименования, без копии) | переименование не требует места на диске — откат работает и при полном диске | лишний файл в `release/` при падении процесса посреди публикации |
| R-3 (design #9) | 2026-10-15 | `PUT /settings` отвечает всеми настройками, а не `{settings_version, relay_dir}` | один формат с `GET /settings`; O-11 — «не видно, что записалось» относится к любой настройке | надмножество полей в ответе |
| R-4 (design #10) | 2026-10-15 | «Отменено» — только при `cancelled` создателе; при `failed` текст прежний | O-10 описывает только force-отмену; `failed` — задача действительно не создана | нет |
| R-5 (design #14) | 2026-10-15 | Цель — ноль `ResourceWarning` от sqlite в тестах, проверка `-W error::ResourceWarning` | M-9/O-4: 87 предупреждений прячут настоящие утечки | время на правку фикстур |
| R-6 (design #14) | 2026-10-15 | В `filterwarnings` два фильтра: `error::ResourceWarning` и `error::pytest.PytestUnraisableExceptionWarning` | утечка соединения рождается в `__del__`, одного ResourceWarning pytest не ловит | утечка останется незамеченной |
| R-7 (design #14) | 2026-10-15 | Соединения закрываются фикстурами/хелперами (conftest `open_db`), а не autouse-подменой `db.connect` | подмена спрятала бы будущие утечки в `src/` | больше правок в тестах |
| R-8 (design #2) | 2026-10-15 | При сбое самой первой публикации (прежнего `agent.py` нет) новый `agent.py` удаляется, `release/` остаётся без публикации | `agent.py` без манифеста даёт 404 так же, но оставляет мусор | пустой `release/` вместо непарного файла |
| R-9 (design #2) | 2026-10-15 | `agent.py.prev` удаляется после любой успешной публикации | файл нужен только для отката | нет |
| R-10 (design #2) | 2026-10-15 | `secure_dir` при публикации вызывается внутри `try` | его `OSError` тоже должен стать `PublishError`, а не трейсбеком | трейсбек в CLI |
| R-11 (design #7) | 2026-10-15 | У `release.hint(..., reported=True)` значение по умолчанию | не переписывать старые вызовы и тесты | забытый вызов без признака даёт прежнее поведение |
| R-12 (design #5) | 2026-10-15 | Запоминание последнего текста ошибки манифеста (`_warned`) под `threading.Lock` | `current` вызывается из потоков HTTP-сервера | двойное предупреждение при гонке |
| R-13 (design #14) | 2026-10-15 | Первый абзац `CLAUDE.md` («текущий change — …») не менять | его правит параллельная ветка #29 (`feat/29-web-app-followups`), конфликт гарантирован; строка перепишется при архивации | до архивации `CLAUDE.md` в ветке называет чужой change. Итог: после слияния #29 первый абзац `CLAUDE.md` в main общий («активные change — `openspec/changes/` (кроме `archive/`)»), поэтому при архивации его переписывать не нужно |
| R-14 (merge) | 2026-10-15 | Контроллер main в ветку не сливал; после слияния #29 (main 55886bd) сессия влила main в ветку до PR, конфликт `pyproject.toml` (блок `[tool.uv]` из #29 против `filterwarnings`) разрешён: оставлены обе секции | PR без конфликта, Gitea не разрешает конфликты в интерфейсе; после слияния 318 passed, 0 warnings | merge-коммит в истории ветки |
| R-15 (design #2) | 2026-10-15 | T2-I1 (откат удалял прежний `agent.py` при сбое до переименования в `.prev`) исправлен в рамках задачи 2: `existed = agent.exists()` до `try`, новый `agent.py` удаляется, только если прежнего не было | потеря опубликованной версии недопустима даже между задачами | нет |
| R-16 (план) | 2026-10-15 | Дешёвые minor ревьюеров задач и финального ревью (T1.1, T1.2, T2.3, T4.1, M-1…M-5) исправлены одной волной перед PR, а не отложены | правки малы и в рамках спеки | чуть больше diff в PR |

View File

@ -0,0 +1,57 @@
## Why
У хаба накопились отложенные находки трёх change'ей: самообновления (#25), пакета C (#16) и первого change'а (#4,
«на потом»). Разбор против `main` 8229bba (15.10.2026) показал, что актуальны все пункты, кроме M-4 #25, который исправило
слияние web-app. Вред у одного пользователя небольшой, но заметный:
- при полном диске `jira-hub agent publish` падает с трейсбеком и оставляет публикацию в состоянии «404 до следующей
публикации»;
- после перезапуска хаба `agent_status` пишет «без запускателя», хотя агент под `run.py`;
- потерянный отчёт `ensure_fields` по задаче, у которой описание с `\r\n` (71 из 180 правлены в UI), закрывается
довыдачей вместо подтверждения по состоянию;
- в тестах 87 незакрытых соединений sqlite и пробелы в покрытии.
Решения владельца 15.10.2026: всё актуальное — одним пакетом; M-4 #25 закрыть, minor-2 #16 отклонить; агентский
#26 в пакет не входит; работать параллельно с #29 (web-app follow-ups), она трогает только `h_app`.
## What Changes
- **Публикация агента** (`release.py`, `cli.py`): запись с `fsync`; прежний `agent.py` переименовывается в
`agent.py.prev`, при сбое записи манифеста возвращается; `OSError` → «не удалось записать публикацию: <ошибка>»,
код 1, временные файлы удалены; манифест годен, только если `version` — целое (не bool) и `sha256` — 64 hex-символа;
не-UTF-8 файл при совпавшем sha — 404, а не 500; предупреждение о битом манифесте — один раз на каждую новую ошибку.
- **`GET /agent`**: поле `agent_reported` (был ли `claim` с момента запуска хаба); пока его нет — подсказки про `run.py`
нет; MCP `agent_status` пишет «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)».
- **`PUT /settings`**: ответ — все настройки, как `GET /settings` (включая `settings_version`).
- **Очередь**: зависимая команда, пропущенная из-за force-отменённой `ensure_issue`, получает
«создание задачи new:N отменено».
- **Подтверждение по состоянию**: строки `ensure_fields` сравниваются без различия `\r\n` и `\n`.
- **Реле**: обработанные папки сравниваются по `resolve()`.
- **Тесты**: bool в строковом параметре и пустой список; `finished_at` у `sync_run` только для `refresh`; 501 на прочие
методы; `epics.csv` без ключа; «подготовлена заново» в реле; предупреждение `min_agent_version`; офлайн-агент; `tick` с
проверками; ноль `ResourceWarning`; читаемый `commands._type_error`.
## Capabilities
### New Capabilities
<!-- нет -->
### Modified Capabilities
- `hub-api`: ответ `PUT /settings`; публикация версии агента (сбой записи, проверка манифеста, `agent_reported`).
- `mcp`: строка запускателя в `agent_status`.
- `commands`: текст `skipped` при отменённом создании; подтверждение `ensure_fields` без различия перевода строки.
## Non-Goals
- сохранение `launcher_version` в БД (запись на каждый claim; «неизвестно» честнее устаревшего значения);
- агент (aep/jira-agent#26) — в следующий change агента;
- minor-2 #16 (дубль предупреждения при неудачном `chmod`) — отклонено;
- раздача веб-приложения (`h_app`) — в #29.
## Impact
- jira-hub (issue aep/jira-hub#32): `src/jira_hub/release.py`, `cli.py`, `api.py` (`Hub`, `h_agent_get`,
`h_settings_put`), `queue.py` (`_next`), `commands.py` (`check_confirmed`, `_type_error`), `relay.py` (`_cleaned`),
`mcp_server.py` (`fmt_agent`), `tests/`, ARCHITECTURE.md при необходимости.
- Пересечение с `feat/29-web-app-followups`: `api.py` (другие функции) и одна строка `tests/test_api.py`; дельты спек
трогают разные требования (`hub-api` «Раздача веб-приложения» у #29).
- Выкатка: после merge — pull на Mac mini, перезапуск хаба (владелец), `/mcp` → reconnect в сессиях Claude.

View File

@ -0,0 +1,62 @@
## MODIFIED Requirements
### Requirement: Временные ключи и ссылки внутри пакета
Параметр-ключ любой команды MAY быть временным `new:<n>`. Хаб SHALL выдавать агенту такую команду только после `done`
команды `ensure_issue` с тем же `temp_key` в том же пакете, подставив настоящий ключ. Если `ensure_issue` завершилась
`failed` или `cancelled`, зависимые команды SHALL получить `skipped` с ошибкой «создание задачи new:<n> отменено», если
`ensure_issue` отменена, иначе «задача new:<n> не создана». Временный ключ вне пакета с его `ensure_issue` SHALL
отвергаться при создании пакета (422).
#### Scenario: Создать и привязать
- **WHEN** пакет: `ensure_issue {temp_key: new:60, …}`, `ensure_link {source: new:60, target: AURORA-1537, …}`
- **THEN** вторая команда выдаётся агенту с `source = AURORA-1590` после завершения первой
#### Scenario: Создание отменено
- **WHEN** пакет: `ensure_issue {temp_key: new:901, …}` (отменена с `force: true`), `ensure_comment {key: new:901, …}`
- **THEN** `ensure_comment` — `skipped` с ошибкой «создание задачи new:901 отменено»
#### Scenario: Создание упало
- **WHEN** `ensure_issue {temp_key: new:902, …}` завершилась `failed`, зависимая `ensure_link {source: new:902, …}`
- **THEN** `ensure_link` — `skipped` с ошибкой «задача new:902 не создана»
### 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` — все поля совпадают (строки сравниваются без различия
`\r\n` и `\n`; `null` и пустая строка равны); `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: Описание с CRLF в зеркале
- **WHEN** `ensure_fields {key: X, description: "a\nb"}` в `claimed`, после `refresh` в зеркале у X `description = "a\r\nb"`
- **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` перенесена на ключ

View File

@ -0,0 +1,99 @@
## MODIFIED Requirements
### 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`;
ответ — все настройки после записи, как у `GET /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`
#### Scenario: Ответ на запись настроек
- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}`
- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки;
токенов в ответе нет
### Requirement: Публикация версии агента
`jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка
`AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `<JIRA_HUB_HOME>/release/agent.py`
(каталог 0700), затем `manifest.json` `{version, sha256, size, published_at}`; оба — через временный файл с `fsync` и
переименование, манифест последним. Прежний `agent.py` на время публикации SHALL переименовываться в `agent.py.prev`
и удаляться после записи манифеста; если запись нового файла или манифеста не удалась (`OSError`), хаб SHALL вернуть
`agent.py.prev` на место `agent.py`, удалить временные файлы и завершить команду сообщением «не удалось записать
публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя версия. Команда SHALL работать без запущенного
`serve` и печатать «опубликована v<N>, sha256 <первые 12 символов>…, <size> байт»; при ошибке — сообщение и код 1,
опубликованное не меняется. Хаб SHALL читать манифест при каждом `claim`, `GET /agent/release` и `GET /agent`; манифест
годен, только если это объект, `version` — целое число (не логическое) не меньше 1, `sha256` — строка из 64 символов
`[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и предупреждение в лог — одно на каждый новый
текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем sha256, означает «публикации нет»
(`GET /agent/release` — 404 `no_release`). `GET /agent` SHALL дополняться полями `release` (`{version, sha256,
published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` с момента запуска хаба)
и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии агента, агент
онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v<to> на v<from>: <error>» (`rolled_back`),
«v<to> отвергнута: <error>» (`rejected`), иначе `null`.
#### Scenario: Публикация новой версии
- **WHEN** опубликована v5, `jira-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6`
- **THEN** в `release/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6,
sha256 …»; код 0
#### Scenario: Версия не выше опубликованной
- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6`
- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён
#### Scenario: Нет строки версии
- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками
- **THEN** сообщение об ошибке, код 1; `release/` не изменён
#### Scenario: Диск полон при записи манифеста
- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError`
- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8,
`GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/` нет
#### Scenario: Негодный манифест
- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim`
- **THEN** в `claim` `agent_release = null`, `GET /agent/release` — 404; в логе одно предупреждение о манифесте
#### Scenario: Агент без запускателя
- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null`
- **THEN** `GET /agent` → `agent_reported = true`, `update_hint` = «агент запущен без run.py — обновится только вручную»
#### Scenario: Хаб перезапущен, агент ещё не выходил на связь
- **WHEN** опубликована v10, агент v9 под запускателем, хаб перезапущен, `claim` после запуска ещё не было
- **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого
`claim` — `agent_reported = true`, `launcher_version` из `claim`

View File

@ -0,0 +1,49 @@
## 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 за сутки, версия настроек; публикация агента («публикация: v<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть |
| `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 уже у агента и будет выполнена; остановить — …»
#### Scenario: Статус агента после отката
- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7
- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и
«откат с v7 на v6: …»
#### Scenario: Статус агента сразу после перезапуска хаба
- **WHEN** хаб перезапущен, агент ещё не прислал `claim`
- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска
хаба)» и не содержит «без запускателя» и «агент запущен без run.py»

View File

@ -20,6 +20,9 @@ packages = ["src/jira_hub"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
# незакрытое соединение sqlite — ошибка набора: ResourceWarning из __del__ pytest выдаёт как
# PytestUnraisableExceptionWarning, поэтому нужны оба фильтра (hub-hygiene-d, решение 14)
filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExceptionWarning"]
[tool.uv] [tool.uv]
# Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS # Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS

View File

@ -50,7 +50,9 @@ class Hub:
self.web_dir = Path(web_dir) if web_dir else WEB_APP self.web_dir = Path(web_dir) if web_dir else WEB_APP
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3) # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
# неизвестен, а не null (hub-hygiene-d, решение 7)
self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}
self.claim_wait = 30 self.claim_wait = 30
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
conn = self.conn() conn = self.conn()
@ -74,15 +76,18 @@ class Hub:
def note_agent_update(self, body): def note_agent_update(self, body):
"""update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3).
Значение неверного типа игнорируется — claim из-за него не отвергается.""" Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported)."""
new = dict(self.agent_update)
new["reported"] = True
us = body.get("update_state") us = body.get("update_state")
if us is None or isinstance(us, dict): if us is None or isinstance(us, dict):
if us is not None and isinstance(us.get("error"), str): if us is not None and isinstance(us.get("error"), str):
us = {**us, "error": us["error"][:500]} us = {**us, "error": us["error"][:500]}
self.agent_update["update_state"] = us new["update_state"] = us
lv = body.get("launcher_version") lv = body.get("launcher_version")
if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)): if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)):
self.agent_update["launcher_version"] = lv new["launcher_version"] = lv
self.agent_update = new
def make_backup(self, label): def make_backup(self, label):
conn = self.conn() conn = self.conn()
@ -290,8 +295,10 @@ def h_refresh(hub, conn, m, q, body):
def h_agent_get(hub, conn, m, q, body): def h_agent_get(hub, conn, m, q, body):
view = queue.agent_view(conn) view = queue.agent_view(conn)
rel = release.current(hub.release_dir) rel = release.current(hub.release_dir)
us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"] au = hub.agent_update
view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv)) us, lv, reported = au["update_state"], au["launcher_version"], au["reported"]
view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported,
update_hint=release.hint(rel, view, us, lv, reported=reported))
return 200, view return 200, view
@ -306,7 +313,8 @@ def h_settings_get(hub, conn, m, q, body):
def h_settings_put(hub, conn, m, q, body): def h_settings_put(hub, conn, m, q, body):
"""Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
(settings.validate): ошибка — 422, версия настроек не меняется.""" (settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у
GET /settings (hub-hygiene-d, решение 9)."""
bad = [k for k in body if k in settings.PROTECTED] bad = [k for k in body if k in settings.PROTECTED]
if bad: if bad:
raise ApiError(422, "protected", ", ".join(bad)) raise ApiError(422, "protected", ", ".join(bad))
@ -315,11 +323,10 @@ def h_settings_put(hub, conn, m, q, body):
settings.validate(key, value) settings.validate(key, value)
except ValueError as e: except ValueError as e:
raise ApiError(422, "bad_setting", str(e)) raise ApiError(422, "bad_setting", str(e))
version = settings.get(conn, "settings_version")
with db.transaction(conn): with db.transaction(conn):
for key, value in body.items(): for key, value in body.items():
version = settings.set_(conn, key, value) settings.set_(conn, key, value)
return 200, {"settings_version": version} return 200, settings.get_all(conn)
ROUTES = [ ROUTES = [

View File

@ -57,15 +57,16 @@ def _type_error(ctype, name, value) -> str | None:
if value is None: if value is None:
return None return None
if name in LIST_PARAMS: if name in LIST_PARAMS:
ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк" return None if _is_str_list(value) else f"{name} — список строк"
elif name in BOOL_PARAMS: if name in BOOL_PARAMS:
ok, want = isinstance(value, bool), "true или false" return None if isinstance(value, bool) else f"{name} — true или false"
elif ctype == "ensure_status" and name == "target": if ctype == "ensure_status" and name == "target":
ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value)) return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк"
want = "строка или список строк" return None if isinstance(value, str) else f"{name} — строка"
else:
ok, want = isinstance(value, str), "строка"
return None if ok else f"{name} — {want}" def _is_str_list(value) -> bool:
return isinstance(value, list) and all(isinstance(v, str) for v in value)
def validate(commands) -> list[dict]: def validate(commands) -> list[dict]:
@ -133,6 +134,14 @@ def estimate(types, pacing: dict) -> int:
return sum(int(pacing.get(t, 0)) for t in types) return sum(int(pacing.get(t, 0)) for t in types)
def _norm_text(value):
"""Значение поля для сравнения с зеркалом: переводы строки CRLF и LF не различаются (описание, правленное в UI Jira,
приходит с CRLF, O-9 #16); null и пустая строка равны (решение 11)."""
if isinstance(value, str):
value = value.replace("\r\n", "\n")
return value or None
def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются: """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются:
потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability,
@ -145,7 +154,7 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
return False, None return False, None
if ctype == "ensure_fields": if ctype == "ensure_fields":
for field in FIELD_PARAMS: for field in FIELD_PARAMS:
if field in p and (issue.get(field) or None) != (p[field] or None): if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]):
return False, None return False, None
if any(l not in issue["labels"] for l in p.get("labels_add", [])): if any(l not in issue["labels"] for l in p.get("labels_add", [])):
return False, None return False, None

View File

@ -112,6 +112,9 @@ def fmt_agent(a) -> str:
if us: if us:
out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}") out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}")
lv = a.get("launcher_version") lv = a.get("launcher_version")
if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт
out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)")
else:
out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя")
if a.get("update_hint"): if a.get("update_hint"):
out.append(a["update_hint"]) out.append(a["update_hint"])

View File

@ -114,6 +114,12 @@ def _definer_status(conn, batch_id, temp_key):
return r["status"] if r else "failed" return r["status"] if r else "failed"
def _skip_reason(conn, batch_id, unresolved) -> str:
"""Текст skipped для зависимой команды: отменённое создание называется отменой (O-10 #16, решение 10)."""
cancelled = [k for k in unresolved if _definer_status(conn, batch_id, k) == "cancelled"]
return f"создание задачи {cancelled[0]} отменено" if cancelled else f"задача {unresolved[0]} не создана"
def _next(conn) -> dict | None: def _next(conn) -> dict | None:
rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id " rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id "
"WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall() "WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall()
@ -131,7 +137,7 @@ def _next(conn) -> dict | None:
unresolved = [] if cmd["params"]["keys"] else unresolved unresolved = [] if cmd["params"]["keys"] else unresolved
if unresolved: if unresolved:
conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?", conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?",
(db.now(), f"задача {unresolved[0]} не создана", cmd["id"])) (db.now(), _skip_reason(conn, cmd["batch_id"], unresolved), cmd["id"]))
continue continue
return cmd return cmd
return None return None

View File

@ -61,7 +61,7 @@ class Relay:
self._cleanup_at = 0.0 self._cleanup_at = 0.0
self._ready = False self._ready = False
self._warned: str | None = None self._warned: str | None = None
self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле self._cleaned: set[Path] = set() # папки (по resolve()), чей work/ вычищен после старта реле
self.workers: list[threading.Thread] = [] self.workers: list[threading.Thread] = []
# --- цикл --------------------------------------------------------------------- # --- цикл ---------------------------------------------------------------------
@ -124,12 +124,13 @@ class Relay:
def _prepare(self) -> None: def _prepare(self) -> None:
for sub in ("req", "resp", "work"): for sub in ("req", "resp", "work"):
(self.root / sub).mkdir(exist_ok=True) (self.root / sub).mkdir(exist_ok=True)
if self.root not in self._cleaned: key = _same_dir_key(self.root)
if key not in self._cleaned:
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает # первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
for stale in (self.root / "work").iterdir(): for stale in (self.root / "work").iterdir():
stale.unlink(missing_ok=True) stale.unlink(missing_ok=True)
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)") say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
self._cleaned.add(self.root) self._cleaned.add(key)
else: else:
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем # пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
say(f"relay: папка {self.root} подготовлена заново") say(f"relay: папка {self.root} подготовлена заново")
@ -225,6 +226,15 @@ class Relay:
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
def _same_dir_key(path: Path) -> Path:
"""Ключ папки для «уже вычищена»: resolve() — симлинк и другая запись той же папки совпадают (minor-1 #16,
решение 12); ошибка resolve — путь как записан."""
try:
return path.resolve()
except (OSError, RuntimeError):
return path
def _error(error: str, detail: str) -> str: def _error(error: str, detail: str) -> str:
return json.dumps({"error": error, "detail": detail}, ensure_ascii=False) return json.dumps({"error": error, "detail": detail}, ensure_ascii=False)

View File

@ -1,10 +1,11 @@
"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. """Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД.
Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5.""" Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7."""
import hashlib import hashlib
import json import json
import logging import logging
import os import os
import re import re
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -15,6 +16,11 @@ log = logging.getLogger(__name__)
MAX_SIZE = 1024 * 1024 MAX_SIZE = 1024 * 1024
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)
SHA256_RE = re.compile(r"[0-9a-f]{64}")
_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5)
_warned_lock = threading.Lock()
class PublishError(Exception): class PublishError(Exception):
pass pass
@ -28,13 +34,27 @@ def parse_version(text: str) -> int:
def _write_atomic(path: Path, data: bytes) -> None: def _write_atomic(path: Path, data: bytes) -> None:
"""Файл целиком или никак: .<имя>.tmp в том же каталоге, write + flush + fsync, переименование (решение 1).
При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается."""
tmp = path.with_name("." + path.name + ".tmp") tmp = path.with_name("." + path.name + ".tmp")
tmp.write_bytes(data) try:
with open(tmp, "wb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path) os.replace(tmp, path)
except OSError:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
raise
def publish(release_dir, source) -> dict: def publish(release_dir, source) -> dict:
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).
Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2):
опубликованной остаётся прежняя версия, наружу — только PublishError."""
src = Path(source) src = Path(source)
try: try:
data = src.read_bytes() data = src.read_bytes()
@ -51,25 +71,80 @@ def publish(release_dir, source) -> dict:
prev = current(rd) prev = current(rd)
if prev and version <= prev["version"]: if prev and version <= prev["version"]:
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
secure_dir(rd)
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
_write_atomic(rd / "agent.py", data) agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
existed, moved = agent.exists(), False
try:
secure_dir(rd)
if agent.exists():
os.replace(agent, prev_file)
moved = True
_write_atomic(agent, data)
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
except OSError as e:
_undo(agent, prev_file, moved, existed)
raise PublishError(f"не удалось записать публикацию: {e}") from e
try:
prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design)
except OSError as e:
log.warning("release: %s не удалён: %s", prev_file, e)
return manifest return manifest
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.
Сбой возврата — в лог: команда всё равно завершается сообщением."""
try:
if moved:
os.replace(prev_file, agent)
elif not existed:
agent.unlink(missing_ok=True)
except OSError as e:
log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e)
def _manifest_problem(m) -> str | None:
"""Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9)."""
if not isinstance(m, dict):
return "не объект"
version = m.get("version")
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
return f"version — не целое ≥ 1: {version!r}"
sha = m.get("sha256")
if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
return "sha256 — не 64 символа [0-9a-f]"
return None
def _note_manifest(problem: str | None, path: Path) -> None:
"""Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест
сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков."""
global _warned
text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет"
with _warned_lock:
if text is not None and text != _warned:
log.warning("%s", text)
_warned = text
def current(release_dir) -> dict | None: def current(release_dir) -> dict | None:
"""Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" """Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять)."""
path = Path(release_dir) / "manifest.json" path = Path(release_dir) / "manifest.json"
try: try:
m = json.loads(path.read_text(encoding="utf-8")) m = json.loads(path.read_text(encoding="utf-8"))
return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")}
except (FileNotFoundError, NotADirectoryError): except (FileNotFoundError, NotADirectoryError):
_note_manifest(None, path)
return None return None
except (OSError, ValueError, KeyError, TypeError) as e: # нет прав, битый JSON: публикации нет, мост работает except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает
log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) _note_manifest(f"не читается ({e})", path)
return None return None
problem = _manifest_problem(m)
_note_manifest(problem, path)
if problem:
return None
return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")}
def load(release_dir) -> dict | None: def load(release_dir) -> dict | None:
@ -85,16 +160,23 @@ def load(release_dir) -> dict | None:
if hashlib.sha256(data).hexdigest() != m["sha256"]: if hashlib.sha256(data).hexdigest() != m["sha256"]:
log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет") log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет")
return None return None
return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")} try:
content = data.decode("utf-8")
except UnicodeDecodeError: # решение 6: 404, а не 500
log.error("release: agent.py не в UTF-8 — публикации нет")
return None
return {"version": m["version"], "sha256": m["sha256"], "content": content}
def hint(rel, agent, update_state, launcher_version) -> str | None: def hint(rel, agent, update_state, launcher_version, reported=True) -> str | None:
"""Подсказка для agent_status (design решение 5).""" """Подсказка для agent_status (design agent-self-update решение 5). reported=False — после запуска хаба claim
ещё не было: launcher_version неизвестен, подсказки «без run.py» нет (hub-hygiene-d, решение 7)."""
try: try:
agent_version = int(agent.get("agent_version") or 0) agent_version = int(agent.get("agent_version") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
agent_version = 0 agent_version = 0
if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None: if (reported and rel and agent.get("online") and agent_version and rel["version"] > agent_version
and launcher_version is None):
return "агент запущен без run.py — обновится только вручную" return "агент запущен без run.py — обновится только вручную"
us = update_state or {} us = update_state or {}
if us.get("state") == "rolled_back": if us.get("state") == "rolled_back":

20
tests/conftest.py Normal file
View File

@ -0,0 +1,20 @@
"""Общие фикстуры тестов. Соединения SQLite, открытые тестом, закрываются после него — в тестах нет
ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4)."""
import pytest
from jira_hub import db
@pytest.fixture
def open_db():
"""Фабрика соединений: open_db() — база в памяти, open_db(path) — файл. Все закрываются после теста."""
opened = []
def make(path=":memory:"):
conn = db.connect(path)
opened.append(conn)
return conn
yield make
for conn in opened:
conn.close()

View File

@ -3,10 +3,11 @@ import os
import threading import threading
import urllib.error import urllib.error
import urllib.request import urllib.request
from datetime import timedelta
import pytest import pytest
from jira_hub import api, db, release, settings from jira_hub import api, db, queue, release, settings
from jira_hub.client import HubClient, HubError from jira_hub.client import HubClient, HubError
@ -208,13 +209,15 @@ def test_numeric_param_400_internal_bug_500(hub, monkeypatch):
assert e.value.status == 500 assert e.value.status == 500
def test_tick_survives_connection_failure(hub, monkeypatch): def test_tick_survives_connection_failure(hub, monkeypatch, caplog):
def broken(): def broken():
raise OSError("диск недоступен") raise OSError("диск недоступен")
monkeypatch.setattr(hub["hub"], "conn", broken) monkeypatch.setattr(hub["hub"], "conn", broken)
hub["hub"].tick() # не бросает hub["hub"].tick() # не бросает
assert "автообновление: сбой тика" in caplog.text
monkeypatch.undo() monkeypatch.undo()
hub["hub"].tick() hub["hub"].tick()
assert hub["client"].get("/batches")["batches"] == [] # агент не на связи — автообновления нет
def test_claim_long_poll_returns_new_command(hub): def test_claim_long_poll_returns_new_command(hub):
@ -428,11 +431,13 @@ def test_hint_without_launcher(hub, tmp_path):
hub["agent"].post("/agent/claim", {"agent_version": 5}) hub["agent"].post("/agent/claim", {"agent_version": 5})
v = hub["client"].get("/agent") v = hub["client"].get("/agent")
assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную" assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную"
assert v["agent_reported"] is True
def test_agent_view_without_release(hub): def test_agent_view_without_release(hub):
v = hub["client"].get("/agent") v = hub["client"].get("/agent")
assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None
assert v["agent_reported"] is False
def test_claim_survives_unreadable_release(hub): def test_claim_survives_unreadable_release(hub):
@ -452,3 +457,118 @@ def test_claim_survives_unreadable_release(hub):
assert b["id"] assert b["id"]
finally: finally:
os.chmod(rd, 0o700) os.chmod(rd, 0o700)
# --- hub-hygiene-d ---------------------------------------------------------------------------------------------
def test_invalid_manifest_one_warning_for_many_claims(hub, monkeypatch, caplog):
"""M-3/M-7 #25, спека «Негодный манифест»: version = 6.9 — публикации нет; 10 claim и GET /agent — одно предупреждение."""
monkeypatch.setattr(release, "_warned", None)
rd = hub["hub"].release_dir
rd.mkdir()
(rd / "manifest.json").write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8")
for _ in range(10):
assert hub["agent"].post("/agent/claim", {"agent_version": 5})["agent_release"] is None
assert hub["client"].get("/agent")["release"] is None
with pytest.raises(HubError) as e:
hub["agent"].get("/agent/release")
assert e.value.status == 404 and e.value.error == "no_release"
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1
def test_agent_release_non_utf8_is_404(hub, tmp_path):
"""M-5 #25: agent.py не в UTF-8 при совпавшем sha256 — 404 no_release, а не 500."""
import hashlib
_publish(hub, tmp_path, 6)
rd = hub["hub"].release_dir
data = b"AGENT_VERSION = 6\n\xff\n"
(rd / "agent.py").write_bytes(data)
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
(rd / "manifest.json").write_text(json.dumps({**m, "sha256": hashlib.sha256(data).hexdigest()}), encoding="utf-8")
with pytest.raises(HubError) as e:
hub["agent"].get("/agent/release")
assert e.value.status == 404 and e.value.error == "no_release"
def test_agent_reported_after_hub_restart(hub, tmp_path):
"""M-2 #25, спека «Хаб перезапущен, агент ещё не выходил на связь»: в базе агент онлайн, claim с запуска не было."""
_publish(hub, tmp_path, 10)
conn = hub["hub"].conn()
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE id = 1", (db.now(),))
conn.close()
v = hub["client"].get("/agent")
assert v["online"] is True and v["agent_reported"] is False
assert v["launcher_version"] is None and v["update_hint"] is None
hub["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
v = hub["client"].get("/agent")
assert v["agent_reported"] is True and v["launcher_version"] == 2 and v["update_hint"] is None
def test_agent_reported_even_with_bad_update_fields(hub, tmp_path):
"""Review Focus 5: claim с полями неверного типа — claim всё равно был: agent_reported = true."""
_publish(hub, tmp_path, 6)
hub["agent"].post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"})
v = hub["client"].get("/agent")
assert v["agent_reported"] is True and v["launcher_version"] is None
assert v["update_hint"] == "агент запущен без run.py — обновится только вручную"
def test_settings_put_returns_all_settings(hub):
"""O-11 #16, решение 9, спека «Ответ на запись настроек»: все настройки после записи, как GET /settings, без токенов."""
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
out = c.put("/settings", {"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"})
assert out["relay_dir"] == "/Volumes/externalSSD/utm/rshb/jira-bridge" and out["settings_version"] == v0 + 1
assert "tokens" not in out and out["base_jql"] == "project = AURORA AND labels = AWPCA"
assert out == c.get("/settings")
def test_settings_put_empty_body_returns_settings_unchanged(hub):
"""Пустое тело — 200, все настройки, версия не меняется."""
c = hub["client"]
before = c.get("/settings")
assert c.put("/settings", {}) == before
@pytest.mark.parametrize("method", ["DELETE", "PATCH"])
def test_other_methods_are_501_not_json(hub, method):
"""M-8 #16, спека «Неподдерживаемый метод»: 501 стандартной страницей http.server, не JSON; база не меняется."""
import http.client
hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields",
"params": {"key": "A-1", "duedate": "2026-10-23"}}]})
host, port = hub["base"].removeprefix("http://").split(":")
conn = http.client.HTTPConnection(host, int(port), timeout=10)
try:
conn.request(method, "/batches/1", headers={"Authorization": "Bearer " + hub["client"].token})
r = conn.getresponse()
r.read()
assert r.status == 501 and not (r.getheader("Content-Type") or "").startswith("application/json")
finally:
conn.close()
assert hub["client"].get("/batches/1")["status"] == "queued"
def test_agent_warning_and_offline(hub):
"""M-10 #4: «обновите агента» при agent_version < min_agent_version; нет контакта дольше порога — офлайн."""
c = hub["client"]
c.put("/settings", {"min_agent_version": 3})
hub["agent"].post("/agent/claim", {"agent_version": 2})
v = c.get("/agent")
assert v["online"] is True and v["warning"] == "обновите агента"
hub["agent"].post("/agent/claim", {"agent_version": 3})
assert c.get("/agent")["warning"] is None
old = (db.utcnow() - timedelta(minutes=queue.ONLINE_MINUTES + 1)).isoformat()
conn = hub["hub"].conn()
conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE id = 1", (old,))
conn.close()
assert c.get("/agent")["online"] is False
def test_tick_creates_auto_refresh_once(hub):
"""M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята)."""
hub["agent"].post("/agent/claim", {"agent_version": 9})
hub["hub"].tick()
batches = hub["client"].get("/batches", limit=5)["batches"]
assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub"
hub["hub"].tick()
assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1

View File

@ -1,19 +1,20 @@
import json import json
import os
from pathlib import Path from pathlib import Path
from jira_hub import cli, db, paths, settings from jira_hub import cli, paths, release, settings
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
def test_init_import_tokens(tmp_path, monkeypatch, capsys): def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
assert cli.main(["init", "--config", str(FIXTURE)]) == 0 assert cli.main(["init", "--config", str(FIXTURE)]) == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert "transitions_map" in out and "агентский токен" in out assert "transitions_map" in out and "агентский токен" in out
assert cli.main(["init", "--config", str(FIXTURE)]) == 1 assert cli.main(["init", "--config", str(FIXTURE)]) == 1
assert "уже инициализирован" in capsys.readouterr().err assert "уже инициализирован" in capsys.readouterr().err
conn = db.connect(paths.db_path()) conn = open_db(paths.db_path())
assert settings.get(conn, "board_id") == 4216 assert settings.get(conn, "board_id") == 4216
(tmp_path / "pkg").mkdir() (tmp_path / "pkg").mkdir()
(tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8") (tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8")
@ -22,7 +23,6 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys):
assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"] assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"]
assert cli.main(["tokens"]) == 0 assert cli.main(["tokens"]) == 0
assert settings.tokens(conn)["client"] in capsys.readouterr().out assert settings.tokens(conn)["client"] in capsys.readouterr().out
conn.close()
def test_agent_publish(tmp_path, monkeypatch, capsys): def test_agent_publish(tmp_path, monkeypatch, capsys):
@ -35,3 +35,27 @@ def test_agent_publish(tmp_path, monkeypatch, capsys):
assert (paths.release_dir() / "manifest.json").exists() assert (paths.release_dir() / "manifest.json").exists()
assert cli.main(["agent", "publish", str(src)]) == 1 assert cli.main(["agent", "publish", str(src)]) == 1
assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err
def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
"""Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25)."""
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
src = tmp_path / "agent.py"
src.write_text("AGENT_VERSION = 8\n", encoding="utf-8")
assert cli.main(["agent", "publish", str(src)]) == 0
capsys.readouterr()
real = os.replace
def fake(s, d, *a, **kw):
if Path(d).name == "manifest.json":
raise OSError(28, "No space left on device")
return real(s, d, *a, **kw)
monkeypatch.setattr(os, "replace", fake)
src.write_text("AGENT_VERSION = 9\n", encoding="utf-8")
assert cli.main(["agent", "publish", str(src)]) == 1
err = capsys.readouterr().err
assert err.startswith("не удалось записать публикацию: ") and "Traceback" not in err
monkeypatch.setattr(os, "replace", real)
assert release.load(paths.release_dir())["version"] == 8
assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"]

View File

@ -1,6 +1,6 @@
import pytest import pytest
from jira_hub import commands, db, mirror from jira_hub import commands, mirror
from jira_hub.commands import CommandError from jira_hub.commands import CommandError
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')" NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"
@ -48,15 +48,17 @@ def test_substitute_and_estimate():
assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152 assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152
def _mirror(): @pytest.fixture
conn = db.connect(":memory:") def conn(open_db):
mirror.apply_state(conn, [ """Зеркало в памяти: A-1 (Готово, Спринт 2, метки AWPCA и kind-dev) и A-2, связь A-1 → A-2; закрывается после теста."""
c = open_db()
mirror.apply_state(c, [
{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий", {"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2", "duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"}, "created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"},
{"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"}, {"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"},
], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) ], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
return conn return c
LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"} LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"}
@ -78,27 +80,24 @@ LT = {"связанные": "ASUPR_Связанные", "блокирует на
({"type": "refresh", "params": {"keys": ["A-1"]}}, False), ({"type": "refresh", "params": {"keys": ["A-1"]}}, False),
({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False), ({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False),
]) ])
def test_check_confirmed(cmd, expected): def test_check_confirmed(conn, cmd, expected):
ok, _ = commands.check_confirmed(_mirror(), cmd, LT) ok, _ = commands.check_confirmed(conn, cmd, LT)
assert ok is expected assert ok is expected
def test_ensure_link_reverse_direction_with_both_keys_in_mirror(): def test_ensure_link_reverse_direction_with_both_keys_in_mirror(conn):
# A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт # A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт
conn = _mirror()
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT) ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT)
assert ok is False assert ok is False
def test_ensure_link_conflict_other_type_blocks_confirmation(): def test_ensure_link_conflict_other_type_blocks_confirmation(conn):
conn = _mirror()
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')") conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT) ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False assert ok is False
def test_ensure_link_conflict_reverse_blocks_confirmation(): def test_ensure_link_conflict_reverse_blocks_confirmation(conn):
conn = _mirror()
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')") conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT) ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False assert ok is False
@ -108,9 +107,8 @@ def _ensure_issue(summary):
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}} return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
def test_ensure_issue_never_confirmed_by_state(): def test_ensure_issue_never_confirmed_by_state(conn):
"""bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом.""" """bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом."""
conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None) assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)
@ -149,3 +147,62 @@ def test_validate_accepts_null_for_clearable_and_path_target():
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}}, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
]) ])
assert len(out) == 3 assert len(out) == 3
@pytest.mark.parametrize("mirror_value, param, expected", [
("a\r\nb", "a\nb", True), ("a\nb", "a\r\nb", True), ("a\r\nb\r\n", "a\nb\n", True),
("a\r\nb", "a b", False), (None, "", True), ("", None, True), ("a\rb", "a\nb", False),
])
def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expected):
"""O-9 #16, решение 11: CRLF и LF не различаются, null и пустая строка равны; одиночный CR — другой текст.
summary с CRLF в зеркале и LF в команде — в каждом случае: сравнение одно для всех строковых полей."""
conn.execute("UPDATE issue SET description = ?, summary = ? WHERE key = 'A-1'", (mirror_value, "x\r\ny"))
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
"params": {"key": "A-1", "description": param, "summary": "x\ny"}}, LT)
assert ok is expected
def test_ensure_fields_summary_difference_is_detected(conn):
"""T4.1 #32: CRLF в summary игнорируется, но реальное расхождение текста — False."""
conn.execute("UPDATE issue SET summary = ? WHERE key = 'A-1'", ("x\r\ny",))
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
"params": {"key": "A-1", "summary": "x\nz"}}, LT)
assert ok is False
@pytest.mark.parametrize("ctype, name, value, msg", [
("ensure_fields", "duedate", True, "duedate — строка"),
("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"),
("ensure_fields", "labels_add", ["x", 1], "labels_add — список строк"),
("ensure_link", "replace", "да", "replace — true или false"),
("ensure_status", "target", 5, "target — строка или список строк"),
("ensure_status", "target", ["Разработка", "Готово"], None),
("ensure_status", "target", "Готово", None),
("ensure_fields", "labels_add", [], None),
("ensure_fields", "assignee", None, None),
("ensure_link", "replace", False, None),
])
def test_type_error_messages(ctype, name, value, msg):
"""T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один)."""
assert commands._type_error(ctype, name, value) == msg
@pytest.mark.parametrize("cmd, name", [
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": True}}, "duedate"),
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": False}}, "body"),
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": True}}, "summary"),
])
def test_validate_rejects_bool_in_string_param(cmd, name):
"""T3.2 #16: true/false в строковом параметре — 422 с именем параметра, а не запись «True» в Jira."""
with pytest.raises(CommandError, match=f"{name} — строка"):
commands.validate([cmd])
def test_validate_empty_list_params():
"""T3.2 #16: [] в необязательном списке допустим; [] в обязательном или единственном — как отсутствие."""
out = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": [], "labels_remove": []}}])
assert out[0]["params"] == {"key": "A-1", "labels_add": [], "labels_remove": []}
with pytest.raises(CommandError, match="нет target"):
commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "target": []}}])
with pytest.raises(CommandError, match="refresh без keys и jql"):
commands.validate([{"type": "refresh", "params": {"keys": []}}])

View File

@ -7,8 +7,8 @@ import threading
from jira_hub import backup, db, paths, queue, settings from jira_hub import backup, db, paths, queue, settings
def test_connect_creates_schema(): def test_connect_creates_schema(open_db):
conn = db.connect(":memory:") conn = open_db()
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
@ -59,10 +59,11 @@ def test_connect_is_idempotent(tmp_path):
db.connect(path).close() db.connect(path).close()
conn = db.connect(path) conn = db.connect(path)
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
conn.close()
def test_transaction_rolls_back_on_error(tmp_path): def test_transaction_rolls_back_on_error(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
try: try:
with db.transaction(conn): with db.transaction(conn):
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')") conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')")
@ -72,8 +73,8 @@ def test_transaction_rolls_back_on_error(tmp_path):
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
def test_nested_transaction_joins_outer(tmp_path): def test_nested_transaction_joins_outer(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
try: try:
with db.transaction(conn): with db.transaction(conn):
with db.transaction(conn): with db.transaction(conn):
@ -86,8 +87,8 @@ def test_nested_transaction_joins_outer(tmp_path):
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную
def test_nested_transaction_error_propagates_to_outer(tmp_path): def test_nested_transaction_error_propagates_to_outer(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
try: try:
with db.transaction(conn): with db.transaction(conn):
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')") conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')")
@ -103,8 +104,8 @@ def test_parse_and_utcnow():
assert db.parse(db.now()) <= db.utcnow() assert db.parse(db.now()) <= db.utcnow()
def test_nested_transaction_rollback_is_local(tmp_path): def test_nested_transaction_rollback_is_local(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
with db.transaction(conn): with db.transaction(conn):
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')") conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')")
try: try:

View File

@ -1,6 +1,16 @@
import csv import csv
from jira_hub import db, importer, mirror, settings import pytest
from jira_hub import importer, mirror, settings
@pytest.fixture
def conn(open_db):
"""База в памяти с настройками по умолчанию; закрывается после теста."""
c = open_db()
settings.ensure_defaults(c)
return c
def write_csv(path, header, rows): def write_csv(path, header, rows):
@ -29,10 +39,8 @@ def make_package(folder):
["new:3", "связанные", "AURORA-1533", "z"]]) ["new:3", "связанные", "AURORA-1533", "z"]])
def test_import_notes_and_report(tmp_path): def test_import_notes_and_report(conn, tmp_path):
make_package(tmp_path) make_package(tmp_path)
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
@ -60,10 +68,8 @@ def write_tasks(folder, comment):
[["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]]) [["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]])
def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path): def test_reimport_keeps_hub_edit_when_cell_empty(conn, tmp_path):
write_tasks(tmp_path, "Актуализация 10.10.2026") write_tasks(tmp_path, "Актуализация 10.10.2026")
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
importer.import_package(conn, tmp_path) importer.import_package(conn, tmp_path)
mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub") mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub")
write_tasks(tmp_path, "") # пустая ячейка в CSV write_tasks(tmp_path, "") # пустая ячейка в CSV
@ -73,20 +79,16 @@ def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path):
assert n["rest_text"] == "Прогнать" assert n["rest_text"] == "Прогнать"
def test_reimport_updates_nonempty_cell(tmp_path): def test_reimport_updates_nonempty_cell(conn, tmp_path):
write_tasks(tmp_path, "Первая версия") write_tasks(tmp_path, "Первая версия")
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
importer.import_package(conn, tmp_path) importer.import_package(conn, tmp_path)
write_tasks(tmp_path, "Вторая версия") write_tasks(tmp_path, "Вторая версия")
importer.import_package(conn, tmp_path) importer.import_package(conn, tmp_path)
assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия" assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия"
def test_import_does_not_touch_issue_tables(tmp_path): def test_import_does_not_touch_issue_tables(conn, tmp_path):
make_package(tmp_path) make_package(tmp_path)
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}], mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}],
[{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}]) [{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}])
count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"]
@ -95,23 +97,28 @@ def test_import_does_not_touch_issue_tables(tmp_path):
assert (count("issue"), count("issue_link")) == before assert (count("issue"), count("issue_link")) == before
def test_missing_csv_files_give_empty_report(tmp_path): def test_missing_csv_files_give_empty_report(conn, tmp_path):
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
rep = importer.import_package(conn, tmp_path) rep = importer.import_package(conn, tmp_path)
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []} assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []}
def test_reversed_link_reported_separately(tmp_path): def test_reversed_link_reported_separately(conn, tmp_path):
make_package(tmp_path) make_package(tmp_path)
write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"],
[["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"], [["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"],
["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]]) ["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]])
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
rep = importer.import_package(conn, tmp_path) rep = importer.import_package(conn, tmp_path)
assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}] assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}]
assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}]
def test_epic_without_key_is_skipped(tmp_path, conn):
"""M-8 #16 (T7.3 #4), спека migration «Эпик без ключа»: строка без «Ключ» пропущена, заметки new:* нет."""
write_csv(tmp_path / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"],
[["NEW-2", "", "Без ключа", "ещё не создан"], ["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"]])
rep = importer.import_package(conn, tmp_path)
assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1350"]
assert [r["key"] for r in conn.execute("SELECT key FROM issue_note ORDER BY key")] == ["AURORA-1350"]

View File

@ -215,3 +215,24 @@ def test_fmt_agent_update_lines():
assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text
text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None})
assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text
def test_fmt_agent_launcher_unknown_after_restart():
"""M-2 #25, решение 8: agent_reported = false — «неизвестно», а не «без запускателя»; поля нет — прежний текст."""
base = {"online": True, "agent_version": "9", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [],
"warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3,
"release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"},
"update_state": None, "launcher_version": None, "update_hint": None}
text = mcp_server.fmt_agent({**base, "agent_reported": False})
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text
assert "без запускателя" not in text and "агент запущен без run.py" not in text
assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True})
assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет
def test_agent_status_right_after_hub_start(live):
"""Спека mcp «Статус агента сразу после перезапуска хаба»."""
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status()
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
text = mcp_server.agent_status()
assert "запускатель: v2" in text and "неизвестно" not in text

View File

@ -3,6 +3,12 @@ import pytest
from jira_hub import db, mirror from jira_hub import db, mirror
@pytest.fixture
def conn(open_db):
"""Пустое зеркало в памяти; закрывается после теста."""
return open_db()
def issue(key, **over): def issue(key, **over):
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст", base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me", "status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
@ -13,8 +19,7 @@ def issue(key, **over):
return base return base
def test_apply_state_upserts_and_replaces_links(): def test_apply_state_upserts_and_replaces_links(conn):
conn = db.connect(":memory:")
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}] links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links) out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
assert out == {"issues": 2, "links": 1} assert out == {"issues": 2, "links": 1}
@ -26,16 +31,14 @@ def test_apply_state_upserts_and_replaces_links():
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}] assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
def test_links_from_other_source_survive(): def test_links_from_other_source_survive(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1"), issue("A-2")], mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]) [{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}] assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
def test_apply_state_marks_neighbors(): def test_apply_state_marks_neighbors(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"}) mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
assert mirror.get_issue(conn, "B-9")["neighbor"] is True assert mirror.get_issue(conn, "B-9")["neighbor"] is True
assert mirror.get_issue(conn, "A-1")["neighbor"] is False assert mirror.get_issue(conn, "A-1")["neighbor"] is False
@ -44,8 +47,7 @@ def test_apply_state_marks_neighbors():
assert mirror.known_keys(conn) == ["A-1", "B-9"] assert mirror.known_keys(conn) == ["A-1", "B-9"]
def test_partial_load_never_lowers_neighbor(): def test_partial_load_never_lowers_neighbor(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"}) mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"})
mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys} mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys}
assert mirror.get_issue(conn, "B-9")["neighbor"] is True assert mirror.get_issue(conn, "B-9")["neighbor"] is True
@ -53,8 +55,7 @@ def test_partial_load_never_lowers_neighbor():
assert mirror.get_issue(conn, "B-9")["neighbor"] is False assert mirror.get_issue(conn, "B-9")["neighbor"] is False
def test_note_survives_refresh_and_renames(): def test_note_survives_refresh_and_renames(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import") mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
mirror.rename_note(conn, "new:7", "A-5") mirror.rename_note(conn, "new:7", "A-5")
mirror.apply_state(conn, [issue("A-5")], []) mirror.apply_state(conn, [issue("A-5")], [])
@ -65,8 +66,7 @@ def test_note_survives_refresh_and_renames():
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x") mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
def test_list_issues_filters(): def test_list_issues_filters(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"), mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]), issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
issue("A-3", sprint="Спринт 2", duedate=None)], []) issue("A-3", sprint="Спринт 2", duedate=None)], [])
@ -80,31 +80,27 @@ def test_list_issues_filters():
assert len(mirror.list_issues(conn, None, limit=1)) == 1 assert len(mirror.list_issues(conn, None, limit=1)) == 1
def test_rename_note_same_key_keeps_note(): def test_rename_note_same_key_keeps_note(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1")], []) mirror.apply_state(conn, [issue("A-1")], [])
mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x") mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x")
mirror.rename_note(conn, "A-1", "A-1") mirror.rename_note(conn, "A-1", "A-1")
assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе" assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе"
def test_rename_note_missing_is_noop(): def test_rename_note_missing_is_noop(conn):
conn = db.connect(":memory:")
mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает
assert mirror.get_note(conn, "new:404") is None assert mirror.get_note(conn, "new:404") is None
assert mirror.get_note(conn, "A-9") is None assert mirror.get_note(conn, "A-9") is None
def test_set_note_partial_keeps_other_fields(): def test_set_note_partial_keeps_other_fields(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x") mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y") got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано" assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
assert got["fact_status"] == "Готово" and got["updated_by"] == "y" assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
def test_rename_note_merges_with_existing(): def test_rename_note_merges_with_existing(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human") mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import") mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
mirror.rename_note(conn, "new:5", "A-5") mirror.rename_note(conn, "new:5", "A-5")
@ -116,8 +112,7 @@ def test_rename_note_merges_with_existing():
assert mirror.get_note(conn, "new:5") is None assert mirror.get_note(conn, "new:5") is None
def test_rename_note_keeps_newer_real_key_stamp(): def test_rename_note_keeps_newer_real_key_stamp(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import") mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import")
mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human") mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human")
conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'") conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'")
@ -128,8 +123,7 @@ def test_rename_note_keeps_newer_real_key_stamp():
assert note["fact_status"] == "В работе" and note["owner_note"] == "позже" assert note["fact_status"] == "В работе" and note["owner_note"] == "позже"
def test_rename_note_empty_string_does_not_overwrite(): def test_rename_note_empty_string_does_not_overwrite(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human") mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import") mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
mirror.rename_note(conn, "new:6", "A-6") mirror.rename_note(conn, "new:6", "A-6")
@ -151,8 +145,7 @@ def _sync_run(conn, started, finished, kind, query, status="done", count=5):
(started, finished, kind, query, count, command_id)) (started, finished, kind, query, count, command_id))
def test_board_snapshot_raw_mirror_data(): def test_board_snapshot_raw_mirror_data(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None), mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"), issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
issue("AURORA-1621", epic_key="AURORA-1351")], issue("AURORA-1621", epic_key="AURORA-1351")],
@ -171,8 +164,7 @@ def test_board_snapshot_raw_mirror_data():
assert snap["last_sync"] is None and snap["generated_at"] assert snap["last_sync"] is None and snap["generated_at"]
def test_board_last_sync_is_last_successful_full_refresh(): def test_board_last_sync_is_last_successful_full_refresh(conn):
conn = db.connect(":memory:")
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198) _sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1) _sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1) _sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
@ -182,9 +174,8 @@ def test_board_last_sync_is_last_successful_full_refresh():
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198} "finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
def test_board_inside_open_transaction_keeps_it_open(): def test_board_inside_open_transaction_keeps_it_open(conn):
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою.""" """M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("AURORA-1620")], []) mirror.apply_state(conn, [issue("AURORA-1620")], [])
with db.transaction(conn): with db.transaction(conn):
assert conn.in_transaction assert conn.in_transaction

View File

@ -7,19 +7,20 @@ from jira_hub.queue import ClaimedError
LT = {"связанные": "ASUPR_Связанные"} LT = {"связанные": "ASUPR_Связанные"}
def fresh(): @pytest.fixture
conn = db.connect(":memory:") def conn(open_db):
settings.ensure_defaults(conn) """База очереди в памяти с настройками по умолчанию и типами связей LT; закрывается после теста."""
settings.set_(conn, "link_types", LT) c = open_db()
return conn settings.ensure_defaults(c)
settings.set_(c, "link_types", LT)
return c
def fields(key, **extra): def fields(key, **extra):
return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}} return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}}
def test_create_batch_adds_tail_refresh_and_estimate(): def test_create_batch_adds_tail_refresh_and_estimate(conn):
conn = fresh()
b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}]) b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}])
view = queue.batch_view(conn, b["id"]) view = queue.batch_view(conn, b["id"])
assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"] assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"]
@ -28,16 +29,14 @@ def test_create_batch_adds_tail_refresh_and_estimate():
assert view["status"] == "queued" and view["author"] == "claude" assert view["status"] == "queued" and view["author"] == "claude"
def test_refresh_only_batch_has_no_tail(): def test_refresh_only_batch_has_no_tail(conn):
conn = fresh()
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
assert b["count"] == 1 assert b["count"] == 1
with pytest.raises(CommandError): with pytest.raises(CommandError):
queue.create_batch(conn, "x", "claude", [{"type": "nope"}]) queue.create_batch(conn, "x", "claude", [{"type": "nope"}])
def test_claim_order_pause_and_report(): def test_claim_order_pause_and_report(conn):
conn = fresh()
b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")]) b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")])
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")]) b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
c = queue.claim(conn, agent_version=1, settings_version=1) c = queue.claim(conn, agent_version=1, settings_version=1)
@ -61,13 +60,13 @@ def test_claim_order_pause_and_report():
assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running" assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
def test_claim_is_exclusive(tmp_path): def test_claim_is_exclusive(tmp_path, open_db):
import threading import threading
path = tmp_path / "hub.sqlite" path = tmp_path / "hub.sqlite"
setup = db.connect(path) setup = open_db(path)
settings.ensure_defaults(setup) settings.ensure_defaults(setup)
queue.create_batch(setup, "x", "claude", [fields("A-1")]) queue.create_batch(setup, "x", "claude", [fields("A-1")])
conns = [db.connect(path), db.connect(path)] conns = [open_db(path), open_db(path)]
barrier, results = threading.Barrier(2), [] barrier, results = threading.Barrier(2), []
def worker(c): def worker(c):
@ -83,8 +82,7 @@ def test_claim_is_exclusive(tmp_path):
assert row["n"] == 1 and row["d"] == len(got) assert row["n"] == 1 and row["d"] == len(got)
def test_backup_failure_does_not_block_claim(caplog): def test_backup_failure_does_not_block_claim(conn, caplog):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
def broken(label): def broken(label):
@ -96,8 +94,7 @@ def test_backup_failure_does_not_block_claim(caplog):
assert "не создана" in caplog.text assert "не создана" in caplog.text
def test_temp_key_substitution_and_skip(): def test_temp_key_substitution_and_skip(conn):
conn = fresh()
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import") mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
queue.create_batch(conn, "создать", "claude", [ queue.create_batch(conn, "создать", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}}, {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
@ -120,8 +117,7 @@ def test_temp_key_substitution_and_skip():
assert view["commands"][3]["error"] == "задача new:61 не создана" assert view["commands"][3]["error"] == "задача new:61 не создана"
def test_cancel_and_retry(): def test_cancel_and_retry(conn):
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn) c = queue.claim(conn)
with pytest.raises(ClaimedError): with pytest.raises(ClaimedError):
@ -139,16 +135,14 @@ def test_cancel_and_retry():
assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"] assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"]
def test_report_after_force_cancel_keeps_cancelled(): def test_report_after_force_cancel_keeps_cancelled(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn) c = queue.claim(conn)
queue.cancel_command(conn, c["id"], force=True) queue.cancel_command(conn, c["id"], force=True)
assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled" assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled"
def test_reconcile_confirms_by_state(): def test_reconcile_confirms_by_state(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
c = queue.claim(conn) c = queue.claim(conn)
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
@ -160,8 +154,7 @@ def test_reconcile_confirms_by_state():
assert queue.batch_view(conn, b["id"])["status"] == "done" assert queue.batch_view(conn, b["id"])["status"] == "done"
def test_auto_refresh_conditions(): def test_auto_refresh_conditions(conn):
conn = fresh()
assert queue.maybe_auto_refresh(conn) is None # агент не на связи assert queue.maybe_auto_refresh(conn) is None # агент не на связи
queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста
b = queue.maybe_auto_refresh(conn) b = queue.maybe_auto_refresh(conn)
@ -177,8 +170,7 @@ def test_auto_refresh_conditions():
assert queue.maybe_auto_refresh(conn) is None assert queue.maybe_auto_refresh(conn) is None
def test_backup_called_for_first_write_only(tmp_path): def test_backup_called_for_first_write_only(conn, tmp_path):
conn = fresh()
calls = [] calls = []
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c1 = queue.claim(conn, backup=calls.append) c1 = queue.claim(conn, backup=calls.append)
@ -187,28 +179,27 @@ def test_backup_called_for_first_write_only(tmp_path):
assert calls == [f"batch-{c1['batch_id']}"] assert calls == [f"batch-{c1['batch_id']}"]
def test_no_backup_for_refresh_batch(): def test_no_backup_for_refresh_batch(conn):
conn = fresh()
calls = [] calls = []
queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
queue.claim(conn, backup=calls.append) queue.claim(conn, backup=calls.append)
assert calls == [] assert calls == []
def test_make_backup_rotates(tmp_path): def test_make_backup_rotates(tmp_path, open_db):
from jira_hub import backup from jira_hub import backup
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
for i in range(4): for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite")) names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
assert len(names) == 2 and names[-1].endswith("-b3.sqlite") assert len(names) == 2 and names[-1].endswith("-b3.sqlite")
assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
def test_rotation_keeps_schema_migration_copy(tmp_path): def test_rotation_keeps_schema_migration_copy(tmp_path, open_db):
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью).""" """Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
from jira_hub import backup from jira_hub import backup
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0) backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
for i in range(4): for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2) backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2)
@ -224,9 +215,8 @@ def _claimed_ensure_issue(conn, summary="Тема"):
return c return c
def test_reconcile_does_not_confirm_ensure_issue_without_reporter(): def test_reconcile_does_not_confirm_ensure_issue_without_reporter(conn):
"""ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает.""" """ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""
conn = fresh()
c = _claimed_ensure_issue(conn) c = _claimed_ensure_issue(conn)
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t") mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
created = db.utcnow().isoformat() created = db.utcnow().isoformat()
@ -236,9 +226,8 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None
def test_reconcile_does_not_overwrite_finished_command(monkeypatch): def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch):
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
conn = fresh()
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
c = queue.claim(conn) c = queue.claim(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
@ -264,8 +253,7 @@ def _two_batches_with_dependent(conn):
return a, b, first return a, b, first
def test_redelivered_definer_blocks_dependents(): def test_redelivered_definer_blocks_dependents(conn):
conn = fresh()
a, b, first = _two_batches_with_dependent(conn) a, b, first = _two_batches_with_dependent(conn)
assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут
conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],)) conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],))
@ -273,8 +261,7 @@ def test_redelivered_definer_blocks_dependents():
assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9" assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9"
def test_auto_refresh_waits_for_claimed_without_report(): def test_auto_refresh_waits_for_claimed_without_report(conn):
conn = fresh()
queue.claim(conn, agent_version=1, settings_version=1) # агент на связи queue.claim(conn, agent_version=1, settings_version=1) # агент на связи
queue.create_batch(conn, "y", "claude", [fields("A-3")]) queue.create_batch(conn, "y", "claude", [fields("A-3")])
queue.claim(conn) # выдана, отчёта нет queue.claim(conn) # выдана, отчёта нет
@ -283,8 +270,7 @@ def test_auto_refresh_waits_for_claimed_without_report():
assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята
def test_redelivery_of_lost_claim_before_queue(): def test_redelivery_of_lost_claim_before_queue(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn) c = queue.claim(conn)
again = queue.claim(conn) again = queue.claim(conn)
@ -297,8 +283,7 @@ def test_redelivery_of_lost_claim_before_queue():
assert queue.agent_view(conn)["redelivered"] == [] assert queue.agent_view(conn)["redelivered"] == []
def test_redelivery_limit_fails_command(): def test_redelivery_limit_fails_command(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn) c = queue.claim(conn)
queue.claim(conn) queue.claim(conn)
@ -310,8 +295,7 @@ def test_redelivery_limit_fails_command():
assert queue.MAX_DELIVERIES == 3 assert queue.MAX_DELIVERIES == 3
def test_redelivery_oldest_first(): def test_redelivery_oldest_first(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
queue.create_batch(conn, "y", "claude", [fields("A-2")]) queue.create_batch(conn, "y", "claude", [fields("A-2")])
rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall() rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall()
@ -320,16 +304,14 @@ def test_redelivery_oldest_first():
assert queue.claim(conn)["id"] == rows[1]["id"] assert queue.claim(conn)["id"] == rows[1]["id"]
def test_redelivered_refresh_keeps_one_sync_run(): def test_redelivered_refresh_keeps_one_sync_run(conn):
conn = fresh()
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn) r = queue.claim(conn)
assert queue.claim(conn)["id"] == r["id"] assert queue.claim(conn)["id"] == r["id"]
assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1 assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1
def test_redelivery_does_not_repeat_backup(): def test_redelivery_does_not_repeat_backup(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
labels = [] labels = []
queue.claim(conn, backup=labels.append) queue.claim(conn, backup=labels.append)
@ -337,9 +319,8 @@ def test_redelivery_does_not_repeat_backup():
assert len(labels) == 1 assert len(labels) == 1
def test_migrated_claimed_is_redelivered(): def test_migrated_claimed_is_redelivered(conn):
"""Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2.""" """Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2."""
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,)) conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,))
@ -347,8 +328,7 @@ def test_migrated_claimed_is_redelivered():
assert queue.get_command(conn, cid)["deliveries"] == 2 assert queue.get_command(conn, cid)["deliveries"] == 2
def test_report_for_queued_command_rejected(): def test_report_for_queued_command_rejected(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
with pytest.raises(queue.NotClaimedError): with pytest.raises(queue.NotClaimedError):
@ -356,8 +336,7 @@ def test_report_for_queued_command_rejected():
assert queue.get_command(conn, cid)["status"] == "queued" assert queue.get_command(conn, cid)["status"] == "queued"
def test_late_report_after_force_cancel_keeps_issue_key(): def test_late_report_after_force_cancel_keeps_issue_key(conn):
conn = fresh()
mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import") mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import")
queue.create_batch(conn, "x", "claude", [ queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
@ -371,8 +350,7 @@ def test_late_report_after_force_cancel_keeps_issue_key():
assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет
def test_late_failed_report_after_force_cancel_changes_nothing(): def test_late_failed_report_after_force_cancel_changes_nothing(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [ queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
c = queue.claim(conn) c = queue.claim(conn)
@ -388,16 +366,14 @@ def _fail_by_limit(conn, cid):
assert queue.get_command(conn, cid)["status"] == "failed" assert queue.get_command(conn, cid)["status"] == "failed"
def test_error_is_trimmed_to_1500(): def test_error_is_trimmed_to_1500(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn) c = queue.claim(conn)
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000) out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500 assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
def test_late_done_after_delivery_limit_keeps_issue_key(): def test_late_done_after_delivery_limit_keeps_issue_key(conn):
conn = fresh()
mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import") mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import")
queue.create_batch(conn, "x", "claude", [ queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}]) {"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
@ -411,8 +387,7 @@ def test_late_done_after_delivery_limit_keeps_issue_key():
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
def test_cancelled_batch_with_done_command_is_cancelled(): def test_cancelled_batch_with_done_command_is_cancelled(conn):
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")]) b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
first = queue.claim(conn) first = queue.claim(conn)
queue.report_result(conn, first["id"], "done", result={"changed": []}) queue.report_result(conn, first["id"], "done", result={"changed": []})
@ -424,8 +399,7 @@ def test_cancelled_batch_with_done_command_is_cancelled():
assert view["counts"]["done"] == 2 assert view["counts"]["done"] == 2
def test_empty_full_refresh_keeps_neighbors(): def test_empty_full_refresh_keeps_neighbors(conn):
conn = fresh()
jql = settings.get(conn, "base_jql") jql = settings.get(conn, "base_jql")
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [], mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
@ -435,8 +409,7 @@ def test_empty_full_refresh_keeps_neighbors():
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0 assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
def test_cancel_finished_batch_stays_done(): def test_cancel_finished_batch_stays_done(conn):
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1")]) b = queue.create_batch(conn, "x", "claude", [fields("A-1")])
while (c := queue.claim(conn)): while (c := queue.claim(conn)):
queue.report_result(conn, c["id"], "done", result={"changed": []}) queue.report_result(conn, c["id"], "done", result={"changed": []})
@ -445,8 +418,7 @@ def test_cancel_finished_batch_stays_done():
assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None
def test_redelivered_full_refresh_marks_neighbors_once(): def test_redelivered_full_refresh_marks_neighbors_once(conn):
conn = fresh()
jql = settings.get(conn, "base_jql") jql = settings.get(conn, "base_jql")
mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00") mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00")
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}]) queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
@ -463,8 +435,7 @@ def test_redelivered_full_refresh_marks_neighbors_once():
assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1 assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1
def test_redelivered_refresh_resets_counts(): def test_redelivered_refresh_resets_counts(conn):
conn = fresh()
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn) r = queue.claim(conn)
queue.record_state(conn, r["id"], 150, 40) queue.record_state(conn, r["id"], 150, 40)
@ -476,8 +447,7 @@ def test_redelivered_refresh_resets_counts():
assert run["started_at"] == started assert run["started_at"] == started
def test_refresh_failed_by_limit_finishes_sync_run(): def test_refresh_failed_by_limit_finishes_sync_run(conn):
conn = fresh()
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn) r = queue.claim(conn)
for _ in range(queue.MAX_DELIVERIES): for _ in range(queue.MAX_DELIVERIES):
@ -486,10 +456,62 @@ def test_refresh_failed_by_limit_finishes_sync_run():
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"] assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
def test_cancel_batch_reports_still_claimed(): def test_cancel_batch_reports_still_claimed(conn):
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn) c = queue.claim(conn)
view = queue.cancel_batch(conn, b["id"]) view = queue.cancel_batch(conn, b["id"])
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}] assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
assert queue.get_command(conn, c["id"])["status"] == "claimed" assert queue.get_command(conn, c["id"])["status"] == "claimed"
def test_dependent_skipped_when_creation_cancelled(conn):
"""O-10 #16, решение 10, спека «Создание отменено»: зависимая команда — «создание задачи new:N отменено»."""
b = queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:901", "issue_type": "task", "summary": "Отменённая"}},
{"type": "ensure_comment", "params": {"key": "new:901", "marker": "m", "body": "b"}}])
c = queue.claim(conn)
queue.cancel_command(conn, c["id"], force=True)
assert queue.claim(conn) is None # комментарий и хвостовой refresh пропущены
cmds = queue.batch_view(conn, b["id"])["commands"]
assert [x["status"] for x in cmds] == ["cancelled", "skipped", "skipped"]
assert cmds[1]["error"] == "создание задачи new:901 отменено"
def test_dependent_on_failed_and_cancelled_names_cancelled(conn):
"""Review Focus 4: два временных ключа — создатель первого failed, второго cancelled: текст по отменённому."""
b = queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Упавшая"}},
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Отменённая"}},
{"type": "ensure_link", "params": {"source": "new:1", "target": "new:2", "link_type": "связанные"}}])
first = queue.claim(conn)
queue.report_result(conn, first["id"], "failed", error="HTTP 400")
second = queue.claim(conn)
queue.cancel_command(conn, second["id"], force=True)
queue.claim(conn)
link = queue.batch_view(conn, b["id"])["commands"][2]
assert link["status"] == "skipped" and link["error"] == "создание задачи new:2 отменено"
def test_reconcile_confirms_fields_with_crlf(conn):
"""O-9 #16, решение 11, спека «Описание с CRLF в зеркале»: потерянный отчёт закрывается по состоянию."""
queue.create_batch(conn, "x", "claude", [{"type": "ensure_fields", "params": {"key": "A-1", "description": "a\nb"}}])
c = queue.claim(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], [])
assert queue.reconcile(conn) == [c["id"]]
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"
def test_delivery_limit_of_write_command_keeps_open_sync_run(conn):
"""T5.2 #16: предел выдач у не-refresh команды не закрывает журнал чужой загрузки (finished_at — только для refresh)."""
queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn)
r = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-9"]}}])
rid = queue.batch_view(conn, r["id"])["commands"][0]["id"]
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2099-01-01T00:00:00+00:00', deliveries = 1 "
"WHERE id = ?", (rid,))
conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, 'keys', 'A-9', ?)", (db.now(), rid))
for _ in range(queue.MAX_DELIVERIES - 1):
assert queue.claim(conn)["id"] == c["id"]
assert queue.claim(conn)["id"] == rid # c — failed по пределу, довыдаётся refresh
assert queue.get_command(conn, c["id"])["status"] == "failed"
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None

View File

@ -365,7 +365,8 @@ def test_vanished_tree_keeps_work(env, capsys):
for _ in range(3): for _ in range(3):
r.step() r.step()
assert (root / "work" / "busy.json").exists() and (root / "req").is_dir() assert (root / "work" / "busy.json").exists() and (root / "req").is_dir()
assert "удалён незавершённый запрос" not in capsys.readouterr().out out = capsys.readouterr().out
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out # minor-3 #16
def test_each_dir_cleaned_once(env, tmp_path, capsys): def test_each_dir_cleaned_once(env, tmp_path, capsys):
@ -402,3 +403,21 @@ def test_relay_targets_serve_host(tmp_path, monkeypatch):
stop.set() stop.set()
srv.server_close() srv.server_close()
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8) assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)
def test_symlink_to_same_dir_not_cleaned_again(env, tmp_path, capsys):
"""minor-1 #16, решение 12: relay_dir сменили на симлинк к той же папке — work/ не чистится второй раз."""
r, root, h = env["relay"], env["root"], env["hub"]
r.step() # root вычищен
link = tmp_path / "bridge-link"
link.symlink_to(root, target_is_directory=True)
(root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос ещё обрабатывается
conn = h.conn()
settings.set_(conn, "relay_dir", str(link))
conn.close()
r._settings_at = None
capsys.readouterr()
r.step()
out = capsys.readouterr().out
assert (root / "work" / "busy.json").exists()
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out

View File

@ -1,6 +1,8 @@
import hashlib
import json import json
import os import os
import stat import stat
from pathlib import Path
import pytest import pytest
@ -101,3 +103,185 @@ def test_hint():
def test_hint_none_when_no_agent(): def test_hint_none_when_no_agent():
rel = {"version": 6, "sha256": "s", "published_at": "t"} rel = {"version": 6, "sha256": "s", "published_at": "t"}
assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None
# --- hub-hygiene-d: публикация при сбое записи, проверка манифеста (решения 1–6) ---------------------------------
def _fail_replace_into(monkeypatch, *names):
"""os.replace в файл с одним из имён names падает «диск полон»; остальные переименования работают."""
real = os.replace
def fake(src, dst, *a, **kw):
if Path(dst).name in names:
raise OSError(28, "No space left on device")
return real(src, dst, *a, **kw)
monkeypatch.setattr(release.os, "replace", fake)
def _listing(rd):
return {p.name: p.read_bytes() for p in rd.iterdir()}
def test_publish_success_leaves_no_prev(tmp_path):
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 5))
release.publish(rd, agent_file(tmp_path, 6))
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
assert release.current(rd)["version"] == 6
def test_manifest_write_failure_keeps_previous(tmp_path, monkeypatch):
"""M-1 #25, спека «Диск полон при записи манифеста»: прежние v8 agent.py и манифест, ни .tmp, ни agent.py.prev."""
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 8))
before = _listing(rd)
_fail_replace_into(monkeypatch, "manifest.json")
with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: .*No space left on device"):
release.publish(rd, agent_file(tmp_path, 9, body="print('v9')\n"))
monkeypatch.undo()
assert _listing(rd) == before
assert release.load(rd)["version"] == 8
def test_agent_write_failure_keeps_previous(tmp_path, monkeypatch):
"""M-1, M-6 #25: сбой fsync при записи agent.py — временный файл удалён, прежний agent.py на месте."""
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 8))
before = _listing(rd)
def no_space(fd):
raise OSError(28, "No space left on device")
monkeypatch.setattr(release.os, "fsync", no_space)
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
release.publish(rd, agent_file(tmp_path, 9))
monkeypatch.undo()
assert _listing(rd) == before
assert release.load(rd)["version"] == 8
def test_first_publish_failure_leaves_nothing(tmp_path, monkeypatch):
"""Review Focus 3: прежней публикации нет — после сбоя release/ пуст, а не с agent.py без манифеста."""
rd = tmp_path / "release"
_fail_replace_into(monkeypatch, "manifest.json")
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
release.publish(rd, agent_file(tmp_path, 1))
monkeypatch.undo()
assert list(rd.iterdir()) == [] and release.current(rd) is None
def test_stale_prev_removed_by_next_publish(tmp_path):
"""Review Focus 1: agent.py.prev, оставленный упавшим процессом, убирает следующая успешная публикация."""
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 6))
(rd / "agent.py").rename(rd / "agent.py.prev") # процесс упал между переименованием и записью
assert release.load(rd) is None
release.publish(rd, agent_file(tmp_path, 7))
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
assert release.load(rd)["version"] == 7
def test_restore_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog):
"""Review Focus 2: и возврат agent.py.prev не удался — PublishError с текстом, причина возврата в логе."""
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 8))
before = _listing(rd)
new_src = agent_file(tmp_path, 9)
_fail_replace_into(monkeypatch, "manifest.json")
real = release.os.replace
def fake(src, dst, *a, **kw):
if Path(src).name == "agent.py.prev":
raise OSError(13, "Permission denied")
return real(src, dst, *a, **kw)
monkeypatch.setattr(release.os, "replace", fake)
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
release.publish(rd, new_src)
assert "прежняя публикация не восстановлена" in caplog.text
monkeypatch.undo()
after = _listing(rd) # задокументированное состояние: новый agent.py, старый agent.py.prev, старый манифест
assert sorted(after) == ["agent.py", "agent.py.prev", "manifest.json"]
assert after["agent.py"] == new_src.read_bytes()
assert after["agent.py.prev"] == before["agent.py"]
assert after["manifest.json"] == before["manifest.json"]
@pytest.mark.parametrize("manifest", [
{"version": 6.9, "sha256": "a" * 64}, {"version": True, "sha256": "a" * 64}, {"version": "7", "sha256": "a" * 64},
{"version": 0, "sha256": "a" * 64}, {"version": 6, "sha256": "a" * 63}, {"version": 6, "sha256": "A" * 64},
{"version": 6}, [6, "a" * 64], None,
])
def test_invalid_manifest_means_no_release(tmp_path, manifest):
"""M-7 #25, решение 4: version — целое (не bool) ≥ 1, sha256 — 64 символа [0-9a-f], корень — объект."""
rd = tmp_path / "release"
rd.mkdir()
(rd / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
assert release.current(rd) is None and release.load(rd) is None
def test_invalid_manifest_warns_once_per_new_error(tmp_path, monkeypatch, caplog):
"""M-3 #25, решение 5: одно предупреждение на каждый новый текст ошибки; годный манифест сбрасывает память."""
monkeypatch.setattr(release, "_warned", None)
rd = tmp_path / "release"
rd.mkdir()
manifest = rd / "manifest.json"
manifest.write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8")
for _ in range(100):
assert release.current(rd) is None
warnings = lambda: [r for r in caplog.records if "манифест" in r.getMessage()]
assert len(warnings()) == 1 and "6.9" in warnings()[0].getMessage()
manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") # другая ошибка — ещё одно
release.current(rd)
release.current(rd)
assert len(warnings()) == 2
manifest.write_text('{"version": 6, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") # годный сбрасывает память
assert release.current(rd)["version"] == 6
manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8")
release.current(rd)
assert len(warnings()) == 3
def test_load_non_utf8_with_matching_sha_is_none(tmp_path, caplog):
"""M-5 #25, решение 6: sha256 сошёлся, но файл не в UTF-8 — публикации нет, ошибка в логе."""
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 6))
data = b"AGENT_VERSION = 6\n\xff\xfe\n"
(rd / "agent.py").write_bytes(data)
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
m["sha256"] = hashlib.sha256(data).hexdigest()
(rd / "manifest.json").write_text(json.dumps(m), encoding="utf-8")
assert release.current(rd)["version"] == 6
assert release.load(rd) is None
assert "agent.py не в UTF-8" in caplog.text
def test_move_failure_keeps_previous_agent(tmp_path, monkeypatch):
"""T2-I1: сбой переименования agent.py -> agent.py.prev при существующем agent.py — прежний agent.py не трогаем."""
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 8))
before = _listing(rd)
real = release.os.replace
def fake(src, dst, *a, **kw):
if Path(dst).name == "agent.py.prev":
raise OSError(13, "Permission denied")
return real(src, dst, *a, **kw)
monkeypatch.setattr(release.os, "replace", fake)
with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: "):
release.publish(rd, agent_file(tmp_path, 9))
monkeypatch.undo()
assert _listing(rd) == before
assert release.load(rd)["version"] == 8
def test_hint_unknown_launcher_until_reported():
"""M-2 #25, решение 7: пока после запуска хаба не было claim, «без run.py» не утверждается; откат — по update_state."""
rel = {"version": 10, "sha256": "s", "published_at": "t"}
online9 = {"agent_version": "9", "online": True}
assert release.hint(rel, online9, None, None, reported=False) is None
assert release.hint(rel, online9, None, None, reported=True) == "агент запущен без run.py — обновится только вручную"
rb = {"state": "rolled_back", "from_version": 9, "to_version": 10, "error": "код 1"}
assert release.hint(rel, online9, rb, None, reported=False) == "откат с v10 на v9: код 1"

View File

@ -3,19 +3,20 @@ from pathlib import Path
import pytest import pytest
from jira_hub import db, settings from jira_hub import settings
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
def fresh(): @pytest.fixture
conn = db.connect(":memory:") def conn(open_db):
settings.ensure_defaults(conn) """База в памяти с настройками по умолчанию; закрывается после теста."""
return conn c = open_db()
settings.ensure_defaults(c)
return c
def test_defaults_and_tokens(): def test_defaults_and_tokens(conn):
conn = fresh()
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA"
assert settings.get(conn, "pacing")["ensure_fields"] == 120 assert settings.get(conn, "pacing")["ensure_fields"] == 120
tok = settings.tokens(conn) tok = settings.tokens(conn)
@ -23,8 +24,7 @@ def test_defaults_and_tokens():
assert "tokens" not in settings.get_all(conn) assert "tokens" not in settings.get_all(conn)
def test_set_bumps_version(): def test_set_bumps_version(conn):
conn = fresh()
v0 = settings.get(conn, "settings_version") v0 = settings.get(conn, "settings_version")
v1 = settings.set_(conn, "pacing", {"ensure_fields": 5}) v1 = settings.set_(conn, "pacing", {"ensure_fields": 5})
assert v1 == v0 + 1 assert v1 == v0 + 1
@ -32,14 +32,12 @@ def test_set_bumps_version():
@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"]) @pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"])
def test_protected_keys(key): def test_protected_keys(conn, key):
conn = fresh()
with pytest.raises(ValueError): with pytest.raises(ValueError):
settings.set_(conn, key, "x") settings.set_(conn, key, "x")
def test_init_from_config(): def test_init_from_config(conn):
conn = fresh()
cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
keys = settings.init_from_config(conn, cfg) keys = settings.init_from_config(conn, cfg)
assert "transitions_map" in keys assert "transitions_map" in keys
@ -55,8 +53,8 @@ def test_init_from_config():
settings.init_from_config(conn, cfg, force=True) settings.init_from_config(conn, cfg, force=True)
def test_relay_dir_default_and_validation(tmp_path): def test_relay_dir_default_and_validation(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
settings.ensure_defaults(conn) settings.ensure_defaults(conn)
assert settings.get(conn, "relay_dir") is None assert settings.get(conn, "relay_dir") is None
settings.validate("relay_dir", None) settings.validate("relay_dir", None)
@ -65,4 +63,3 @@ def test_relay_dir_default_and_validation(tmp_path):
with pytest.raises(ValueError): with pytest.raises(ValueError):
settings.validate("relay_dir", bad) settings.validate("relay_dir", bad)
assert "relay_dir" not in settings.AGENT_KEYS assert "relay_dir" not in settings.AGENT_KEYS
conn.close()