Compare commits
14 Commits
c6aa2edd06
...
01fd588a09
| Author | SHA1 | Date | |
|---|---|---|---|
| 01fd588a09 | |||
|
|
72b0914fce | ||
|
|
8e82b31d48 | ||
|
|
4b76152959 | ||
|
|
2429d08a63 | ||
|
|
615530aa1b | ||
|
|
bd8c769e75 | ||
|
|
19aa032a25 | ||
|
|
4f0b873db6 | ||
|
|
a309c9bfb1 | ||
|
|
7a2f2e694b | ||
|
|
ec49ed64ba | ||
|
|
70bcd4cf25 | ||
|
|
a7fed00419 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,3 +11,4 @@ tests/fixtures/*.webm
|
||||
__pycache__/
|
||||
*.sqlite*
|
||||
.pytest_cache/
|
||||
.wt/
|
||||
|
||||
@ -6,25 +6,26 @@
|
||||
|
||||
| Модуль | Ответственность | Зависит от |
|
||||
|---|---|---|
|
||||
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/` | stdlib |
|
||||
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup` |
|
||||
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
|
||||
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` |
|
||||
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
|
||||
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` |
|
||||
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
|
||||
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
|
||||
| `backup` | копии файла базы, ротация | stdlib |
|
||||
| `backup` | копии файла базы, ротация | `paths` |
|
||||
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` | `queue`, `mirror`, `settings`, `backup`, `relay` |
|
||||
| `relay` | реле общей папки: поток в `serve`, файлы `req/` → HTTP `127.0.0.1` → `resp/`; HTTP-обработчики о нём не знают | `settings` |
|
||||
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
|
||||
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
|
||||
| `mcp_server` | инструменты MCP по stdio | `client` |
|
||||
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
|
||||
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` |
|
||||
|
||||
Цепочки: `api → queue → commands/mirror/settings → db`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
|
||||
Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
|
||||
|
||||
## Потоки данных
|
||||
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
|
||||
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
|
||||
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
|
||||
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
|
||||
|
||||
## Границы
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
# jira-hub
|
||||
|
||||
## Что это
|
||||
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущего change нет (последний — `openspec/changes/archive/2026-10-13-bridge-reliability/`).
|
||||
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/bridge-hygiene/`.
|
||||
|
||||
## Стек и ограничения
|
||||
- Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
|
||||
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
||||
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
||||
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
|
||||
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`. Порт по умолчанию 8790.
|
||||
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`; каталог данных и `backups/` — права 0700. Порт по умолчанию 8790.
|
||||
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
|
||||
|
||||
## Архитектура
|
||||
|
||||
863
docs/superpowers/plans/2026-10-14-bridge-hygiene-hub.md
Normal file
863
docs/superpowers/plans/2026-10-14-bridge-hygiene-hub.md
Normal file
@ -0,0 +1,863 @@
|
||||
# bridge-hygiene — хаб: план реализации
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** пакет C гигиены хаба: закрытый каталог данных, одна копия при миграции, слияние заметок, проверка типов
|
||||
параметров, корректные статусы и счётчики очереди, поздний отчёт по `failed`, информативная отмена пакета, устойчивое реле,
|
||||
документы.
|
||||
|
||||
**Architecture:** правки точечные, внутри существующих модулей: `paths`/`db`/`backup` (права, миграция), `mirror`
|
||||
(`rename_note`), `commands` (`validate`), `queue` (статусы, отчёты, довыдача, отмена), `relay`, `mcp_server`. Контракт
|
||||
агента не меняется; новая только строка `still_claimed` в ответе отмены пакета.
|
||||
|
||||
**Tech Stack:** Python 3.13, uv, pytest, SQLite (stdlib), stdlib `http.server`/`urllib`.
|
||||
|
||||
**Spec:** `openspec/changes/bridge-hygiene/` (proposal.md, design.md, specs/{hub-api,data-model,commands,mcp,migration,agent}).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hygiene` (ветка `feat/13-bridge-hygiene`).
|
||||
Все команды — с этим cwd; основной checkout `jira-hub/` (main) не трогать.
|
||||
- Комментарии, docstring, сообщения ошибок и коммитов — по-русски; идентификаторы — английские.
|
||||
- `uv run pytest -q` — зелёным считается только весь набор (до начала: 169 passed).
|
||||
- Новых зависимостей нет; миграции схемы нет (`SCHEMA_VERSION` остаётся 2).
|
||||
- `error` команды хранится не длиннее 1500 символов; текст ошибки предела выдач — ровно `агент не отчитался после 3 выдач`.
|
||||
- Каталог данных и `backups/` — права `0700`; сбой `chmod` — предупреждение в лог, не исключение.
|
||||
- Каждая задача заканчивается коммитом с заголовком на русском, суффиксом `(#13)` и строкой
|
||||
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Решения, принятые исполнителем без обсуждения, — в `openspec/changes/bridge-hygiene/design.md`, раздел
|
||||
`## Decisions made without discussion`, со следующим свободным маркером `R-n` (занято R-1…R-11).
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Параллельные `connect` к базе v1 (старт хаба и `jira-hub import` одновременно): одна копия, миграция один раз, ни
|
||||
одного исключения — тест `test_parallel_connect_makes_one_backup` в Task 1.
|
||||
2. Поздний отчёт по `ensure_issue`, ставшей `failed` по пределу выдач, приходит дважды: второй ничего не меняет —
|
||||
тест в Task 4.
|
||||
3. Слияние заметок, когда у временной заметки поле пустая строка `""` (а не `NULL`): под ключом сохраняется значение —
|
||||
тест в Task 2.
|
||||
4. `refresh` довыдан и снова загружает состояние; затем полный прогон завершён: соседи помечаются по `started_at` первой
|
||||
выдачи, счётчики равны одной загрузке — тест в Task 5.
|
||||
5. Реле: папка пропала и появилась снова, в `work/` лежит взятый запрос — файл не удалён, лог без «перезапуск реле» —
|
||||
тест в Task 7.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Закрытый каталог данных и одна копия при миграции
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/jira_hub/paths.py` (новая `secure_dir`)
|
||||
- Modify: `src/jira_hub/db.py` (`connect`, `migrate`)
|
||||
- Modify: `src/jira_hub/backup.py` (`make_backup`)
|
||||
- Test: `tests/test_db.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `paths.secure_dir(path: Path) -> None` — создать каталог (с родителями) и выставить `0700`; при `OSError` —
|
||||
`log.warning`, без исключения. `db.migrate(conn, path=None)` — сигнатура прежняя.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_db.py` (импорты — в начало файла):
|
||||
|
||||
```python
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
|
||||
from jira_hub import backup, paths, queue, settings
|
||||
|
||||
|
||||
def _mode(p):
|
||||
return stat.S_IMODE(p.stat().st_mode)
|
||||
|
||||
|
||||
def test_data_dir_is_private(tmp_path):
|
||||
home = tmp_path / "data"
|
||||
home.mkdir()
|
||||
home.chmod(0o755)
|
||||
db.connect(home / "hub.sqlite").close()
|
||||
assert _mode(home) == 0o700
|
||||
|
||||
|
||||
def test_backups_dir_is_private(tmp_path):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
backup.make_backup(conn, tmp_path / "backups", "batch-1")
|
||||
conn.close()
|
||||
assert _mode(tmp_path / "backups") == 0o700
|
||||
|
||||
|
||||
def test_secure_dir_failure_only_warns(tmp_path, monkeypatch, caplog):
|
||||
def deny(*a, **k):
|
||||
raise PermissionError("нет прав")
|
||||
monkeypatch.setattr(os, "chmod", deny)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
paths.secure_dir(tmp_path / "x")
|
||||
assert (tmp_path / "x").is_dir() and "0700" in caplog.text
|
||||
|
||||
|
||||
def test_parallel_connect_makes_one_backup(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v1_database(path)
|
||||
barrier, errors = threading.Barrier(4), []
|
||||
|
||||
def go():
|
||||
try:
|
||||
barrier.wait()
|
||||
db.connect(path).close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=go) for _ in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(30)
|
||||
assert errors == []
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrated_claimed_redelivered_end_to_end(tmp_path):
|
||||
"""База v1 с командой в claimed → миграция (deliveries = 1) → claim довыдаёт её с deliveries = 2 (M-10 #12)."""
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v1_database(path)
|
||||
conn = db.connect(path)
|
||||
settings.ensure_defaults(conn)
|
||||
cmd = queue.claim(conn)
|
||||
assert cmd["id"] == 1
|
||||
assert queue.get_command(conn, 1)["deliveries"] == 2
|
||||
conn.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_db.py -q`
|
||||
Expected: FAIL — `test_data_dir_is_private` (0o755), `test_backups_dir_is_private`, `test_secure_dir_failure_only_warns`
|
||||
(`AttributeError: module 'jira_hub.paths' has no attribute 'secure_dir'`); `test_parallel_connect_makes_one_backup` —
|
||||
больше одной копии (может пройти случайно — это гонка; после Step 3 обязан проходить стабильно);
|
||||
`test_migrated_claimed_redelivered_end_to_end` уже может проходить — это закрепление поведения.
|
||||
|
||||
- [ ] **Step 3: Implement** — `src/jira_hub/paths.py` целиком:
|
||||
|
||||
```python
|
||||
"""Где лежат данные хаба: каталог, база, резервные копии."""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def home() -> Path:
|
||||
return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub")))
|
||||
|
||||
|
||||
def db_path() -> Path:
|
||||
return home() / "hub.sqlite"
|
||||
|
||||
|
||||
def backups_dir() -> Path:
|
||||
return home() / "backups"
|
||||
|
||||
|
||||
def secure_dir(path: Path) -> None:
|
||||
"""Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) —
|
||||
предупреждение, хаб продолжает работу."""
|
||||
path = Path(path)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(path, 0o700)
|
||||
except OSError as e:
|
||||
log.warning("каталог %s не закрыт (0700): %s", path, e)
|
||||
```
|
||||
|
||||
В `src/jira_hub/backup.py` заменить `backups_dir.mkdir(parents=True, exist_ok=True)` на `paths.secure_dir(backups_dir)`
|
||||
и добавить импорт `from . import paths`.
|
||||
|
||||
В `src/jira_hub/db.py`: импорт `from . import backup, paths` (если `backup` уже импортирован — добавить `paths`). В `connect`
|
||||
заменить `Path(path).parent.mkdir(parents=True, exist_ok=True)` на `paths.secure_dir(Path(path).parent)`. `migrate`
|
||||
целиком:
|
||||
|
||||
```python
|
||||
def migrate(conn: sqlite3.Connection, path=None) -> None:
|
||||
"""Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы
|
||||
(<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный
|
||||
connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
|
||||
отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции."""
|
||||
conn.executescript(SCHEMA)
|
||||
version = _schema_version(conn)
|
||||
if version is None:
|
||||
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),))
|
||||
elif version < SCHEMA_VERSION:
|
||||
with transaction(conn):
|
||||
current = _schema_version(conn)
|
||||
if current < SCHEMA_VERSION:
|
||||
if path is not None:
|
||||
reader = sqlite3.connect(str(path), timeout=10)
|
||||
try:
|
||||
backup.make_backup(reader, Path(path).parent / "backups", f"schema-v{current}", keep=0)
|
||||
finally:
|
||||
reader.close()
|
||||
while current < SCHEMA_VERSION:
|
||||
for sql in MIGRATIONS[current]:
|
||||
conn.execute(sql)
|
||||
current += 1
|
||||
conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),))
|
||||
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
||||
```
|
||||
|
||||
Проверить, что `backup` не импортирует `db` (циклов нет: `paths` ← `backup` ← `db`).
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `uv run pytest tests/test_db.py -q && for i in 1 2 3 4 5; do uv run pytest tests/test_db.py::test_parallel_connect_makes_one_backup -q || break; done && uv run pytest -q`
|
||||
Expected: PASS; тест параллельного connect — 5 из 5; весь набор зелёный.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/jira_hub/paths.py src/jira_hub/db.py src/jira_hub/backup.py tests/test_db.py
|
||||
git commit -m "fix(db): каталог данных 0700, одна копия при миграции под блокировкой (#13)
|
||||
|
||||
M-7 (hub#4), M-2 и M-10 (hub#12).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Слияние заметок при переносе `new:N` → ключ
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/jira_hub/mirror.py` (`rename_note`)
|
||||
- Test: `tests/test_mirror.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `mirror.NOTE_FIELDS`, `mirror.get_note`, `mirror.set_note`, `db.transaction`.
|
||||
- Produces: `mirror.rename_note(conn, old_key, new_key) -> None` — сигнатура прежняя, поведение при существующей
|
||||
заметке под `new_key` — слияние.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_mirror.py` (функция `fresh()`/соединение — как в
|
||||
соседних тестах файла; если хелпера нет — `conn = db.connect(":memory:")`):
|
||||
|
||||
```python
|
||||
def test_rename_note_merges_with_existing():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
|
||||
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
|
||||
mirror.rename_note(conn, "new:5", "A-5")
|
||||
note = mirror.get_note(conn, "A-5")
|
||||
assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает
|
||||
assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом
|
||||
assert note["fact_status"] == "В работе"
|
||||
assert note["updated_by"] == "import" # временная заметка записана позже
|
||||
assert mirror.get_note(conn, "new:5") is None
|
||||
|
||||
|
||||
def test_rename_note_empty_string_does_not_overwrite():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
|
||||
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
|
||||
mirror.rename_note(conn, "new:6", "A-6")
|
||||
note = mirror.get_note(conn, "A-6")
|
||||
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_mirror.py -q`
|
||||
Expected: FAIL — `owner_note` и `rest_text` пропали (`None`): заметка под ключом удаляется.
|
||||
|
||||
- [ ] **Step 3: Implement** — заменить `rename_note` в `src/jira_hub/mirror.py`:
|
||||
|
||||
```python
|
||||
def rename_note(conn, old_key, new_key) -> None:
|
||||
"""После создания задачи: заметка с временного ключа переезжает на настоящий. Если под настоящим ключом заметка уже
|
||||
есть — слияние (M-3 #4): поле временной заметки побеждает, если оно непустое; updated_at/updated_by — от более
|
||||
поздней; временная строка удаляется."""
|
||||
if old_key == new_key:
|
||||
return
|
||||
with db.transaction(conn):
|
||||
old = get_note(conn, old_key)
|
||||
if old is None:
|
||||
return
|
||||
cur = get_note(conn, new_key)
|
||||
if cur is None:
|
||||
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))
|
||||
return
|
||||
merged = [old[f] if old[f] not in (None, "") else cur[f] for f in NOTE_FIELDS]
|
||||
newer = old if (old["updated_at"] or "") >= (cur["updated_at"] or "") else cur
|
||||
conn.execute(f"UPDATE issue_note SET {', '.join(f'{f} = ?' for f in NOTE_FIELDS)}, updated_at = ?, updated_by = ? "
|
||||
"WHERE key = ?", merged + [newer["updated_at"], newer["updated_by"], new_key])
|
||||
conn.execute("DELETE FROM issue_note WHERE key = ?", (old_key,))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `uv run pytest tests/test_mirror.py -q && uv run pytest -q`
|
||||
Expected: PASS, весь набор зелёный.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/jira_hub/mirror.py tests/test_mirror.py
|
||||
git commit -m "fix(mirror): rename_note сливает заметки, а не удаляет заметку под ключом (#13)
|
||||
|
||||
M-3 (hub#4, PR #2).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Проверка типов параметров команд
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/jira_hub/commands.py` (`validate`, новые `LIST_PARAMS`, `BOOL_PARAMS`, `_type_error`)
|
||||
- Test: `tests/test_commands.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `commands.SPEC`, `CommandError`.
|
||||
- Produces: `commands.validate(commands) -> list[dict]` — прежняя сигнатура; неверный тип значения → `CommandError`
|
||||
(наружу 422) с текстом `команда <i> (<тип>): <параметр> — <ожидаемое>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_commands.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
from jira_hub import commands as C
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd, name", [
|
||||
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": 42}}, "summary"),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": "AWPCA"}}, "labels_add"),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["x", 1]}}, "labels_remove"),
|
||||
({"type": "ensure_fields", "params": {"key": ["A-1"], "duedate": "2026-10-23"}}, "key"),
|
||||
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": {"t": 1}}}, "body"),
|
||||
({"type": "ensure_status", "params": {"key": "A-1", "target": 5}}, "target"),
|
||||
({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", 2]}}, "target"),
|
||||
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": "да"}}, "replace"),
|
||||
({"type": "refresh", "params": {"keys": "A-1"}}, "keys"),
|
||||
])
|
||||
def test_validate_rejects_wrong_types(cmd, name):
|
||||
with pytest.raises(C.CommandError) as e:
|
||||
C.validate([cmd])
|
||||
assert name in str(e.value)
|
||||
|
||||
|
||||
def test_validate_accepts_null_for_clearable_and_path_target():
|
||||
out = C.validate([
|
||||
{"type": "ensure_fields", "params": {"key": "A-1", "assignee": None, "epic_key": None, "duedate": None}},
|
||||
{"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}},
|
||||
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
|
||||
])
|
||||
assert len(out) == 3
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_commands.py -q`
|
||||
Expected: FAIL — для `summary: 42` `AttributeError: 'int' object has no attribute 'strip'`, для остальных — нет исключения.
|
||||
|
||||
- [ ] **Step 3: Implement** — в `src/jira_hub/commands.py` после `WRITE_TYPES`:
|
||||
|
||||
```python
|
||||
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
|
||||
BOOL_PARAMS = {"replace"}
|
||||
|
||||
|
||||
def _type_error(ctype, name, value) -> str | None:
|
||||
"""Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500.
|
||||
null допустим везде — обязательность проверяется отдельно."""
|
||||
if value is None:
|
||||
return None
|
||||
if name in LIST_PARAMS:
|
||||
ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк"
|
||||
elif name in BOOL_PARAMS:
|
||||
ok, want = isinstance(value, bool), "true или false"
|
||||
elif ctype == "ensure_status" and name == "target":
|
||||
ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value))
|
||||
want = "строка или список строк"
|
||||
else:
|
||||
ok, want = isinstance(value, str), "строка"
|
||||
return None if ok else f"{name} — {want}"
|
||||
```
|
||||
|
||||
В `validate` сразу после проверки `unknown` (до проверки `ensure_fields без полей`):
|
||||
|
||||
```python
|
||||
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
|
||||
if bad:
|
||||
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `uv run pytest tests/test_commands.py -q && uv run pytest -q`
|
||||
Expected: PASS, весь набор зелёный (если упал старый тест, отправлявший параметр другого типа, — исправить тест под
|
||||
контракт спеки и записать рулинг R-n в design.md).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/jira_hub/commands.py tests/test_commands.py
|
||||
git commit -m "fix(commands): проверка типов параметров — 422 вместо 500 (#13)
|
||||
|
||||
M-3 (hub#12).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Отчёты — обрезка ошибки, поздний `done` по `failed`, статус отменённого пакета, пустой полный прогон
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/jira_hub/queue.py` (`_status_of`, `report_result`)
|
||||
- Test: `tests/test_queue.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `queue.claim`, `queue.cancel_batch`, `queue.cancel_command`, `queue.MAX_DELIVERIES`, `mirror.rename_note`
|
||||
(Task 2).
|
||||
- Produces: `queue.report_result(...)` — сигнатура прежняя; `queue.ERROR_LIMIT = 1500`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_queue.py`:
|
||||
|
||||
```python
|
||||
def _fail_by_limit(conn, cid):
|
||||
for _ in range(queue.MAX_DELIVERIES - 1):
|
||||
assert queue.claim(conn)["id"] == cid
|
||||
queue.claim(conn) # четвёртый claim: cid → failed по пределу
|
||||
assert queue.get_command(conn, cid)["status"] == "failed"
|
||||
|
||||
|
||||
def test_error_is_trimmed_to_1500():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||
c = queue.claim(conn)
|
||||
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
|
||||
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
|
||||
|
||||
|
||||
def test_late_done_after_delivery_limit_keeps_issue_key():
|
||||
conn = fresh()
|
||||
mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import")
|
||||
queue.create_batch(conn, "x", "claude", [
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
|
||||
c = queue.claim(conn)
|
||||
_fail_by_limit(conn, c["id"])
|
||||
late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2)
|
||||
assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True}
|
||||
assert late["error"] == "агент не отчитался после 3 выдач"
|
||||
assert mirror.get_note(conn, "A-30")["fact_status"] == "План" and mirror.get_note(conn, "new:8") is None
|
||||
again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"})
|
||||
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
|
||||
|
||||
|
||||
def test_cancelled_batch_with_done_command_is_cancelled():
|
||||
conn = fresh()
|
||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
|
||||
first = queue.claim(conn)
|
||||
queue.report_result(conn, first["id"], "done", result={"changed": []})
|
||||
second = queue.claim(conn)
|
||||
queue.cancel_batch(conn, b["id"])
|
||||
queue.report_result(conn, second["id"], "done", result={"changed": []})
|
||||
view = queue.batch_view(conn, b["id"])
|
||||
assert view["status"] == "cancelled"
|
||||
assert view["counts"]["done"] == 2
|
||||
|
||||
|
||||
def test_empty_full_refresh_keeps_neighbors():
|
||||
conn = fresh()
|
||||
jql = settings.get(conn, "base_jql")
|
||||
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
|
||||
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
|
||||
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
||||
r = queue.claim(conn)
|
||||
queue.report_result(conn, r["id"], "done") # загрузок состояния не было: 0 задач
|
||||
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
|
||||
```
|
||||
|
||||
`mirror.apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True)`; `fetched_at` в прошлом
|
||||
нужен, чтобы до исправления задача попала под `fetched_at < started_at` и тест падал.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_queue.py -q`
|
||||
Expected: FAIL — `ERROR_LIMIT` нет, ошибка 5000 символов; поздний `done` по `failed` отброшен (`result is None`); статус
|
||||
отменённого пакета `done`; задача `A-1` помечена `neighbor = 1`.
|
||||
|
||||
- [ ] **Step 3: Implement** — в `src/jira_hub/queue.py`:
|
||||
|
||||
1. Константа рядом с `MAX_DELIVERIES`: `ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands)`.
|
||||
2. `_status_of`:
|
||||
|
||||
```python
|
||||
def _status_of(cmds, cancelled_at=None) -> str:
|
||||
sts = [c["status"] for c in cmds]
|
||||
if not sts:
|
||||
return "done"
|
||||
if all(s in TERMINAL for s in sts):
|
||||
return "cancelled" if cancelled_at else "done" # отменённый пакет — отменён, даже с частью done (T5.6)
|
||||
if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts):
|
||||
return "running"
|
||||
return "queued"
|
||||
```
|
||||
|
||||
3. `report_result`: в начале функции, после проверки `status`, —
|
||||
`error = None if error is None else str(error)[:ERROR_LIMIT]`. Ветку позднего отчёта заменить:
|
||||
|
||||
```python
|
||||
if cmd["status"] in TERMINAL:
|
||||
# поздний отчёт о созданной задаче по команде, которую хаб уже закрыл (force-отмена — M-5, предел выдач —
|
||||
# M-4 #12): ключ не теряем, статус не трогаем
|
||||
if (cmd["status"] in ("cancelled", "failed") and cmd["type"] == "ensure_issue" and status == "done"
|
||||
and result and result.get("key") and not cmd["result"]):
|
||||
conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
|
||||
(_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
|
||||
log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат",
|
||||
command_id, cmd["status"], result["key"])
|
||||
return get_command(conn, command_id)
|
||||
return cmd # идемпотентно; терминальный статус не затирается
|
||||
```
|
||||
|
||||
4. Пометка соседей по полному прогону — только при непустой загрузке (M-12):
|
||||
|
||||
```python
|
||||
if status == "done" and is_full_refresh(conn, command_id):
|
||||
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
|
||||
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)
|
||||
run = conn.execute("SELECT started_at, issues_count FROM sync_run WHERE command_id = ?", (command_id,)).fetchone()
|
||||
if run and run["issues_count"] > 0:
|
||||
conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `uv run pytest tests/test_queue.py -q && uv run pytest -q`
|
||||
Expected: PASS, весь набор зелёный. Если старый тест полного прогона рассчитывал на пометку соседей без загрузки
|
||||
состояния — дописать в нём загрузку хотя бы одной задачи через `record_state`/`POST /agent/state`, смысл теста не менять.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/jira_hub/queue.py tests/test_queue.py
|
||||
git commit -m "fix(queue): обрезка error, поздний done по failed, статус отменённого пакета, пустой полный прогон (#13)
|
||||
|
||||
M-4 (hub#12), T5.6 и M-12 (hub#4).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Довыдача `refresh` — счётчики и `finished_at`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/jira_hub/queue.py` (`claim` — ветка довыдачи, `_redeliver`)
|
||||
- Test: `tests/test_queue.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `queue.record_state(conn, command_id, issues_count, links_count)`, `queue.MAX_DELIVERIES`.
|
||||
- Produces: поведение — довыдача `refresh` обнуляет `sync_run.issues_count/links_count` (`started_at` прежний); перевод
|
||||
`refresh` в `failed` по пределу проставляет `sync_run.finished_at`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_queue.py`; заодно переименовать
|
||||
`test_fresh_definer_still_blocks_queue` в `test_redelivered_definer_blocks_dependents` (тело не менять; M-10 #12):
|
||||
|
||||
```python
|
||||
def test_redelivered_refresh_resets_counts():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
r = queue.claim(conn)
|
||||
queue.record_state(conn, r["id"], 150, 40)
|
||||
started = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["started_at"]
|
||||
assert queue.claim(conn)["id"] == r["id"] # отчёт потерян — довыдача
|
||||
queue.record_state(conn, r["id"], 150, 40)
|
||||
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()
|
||||
assert (run["issues_count"], run["links_count"]) == (150, 40)
|
||||
assert run["started_at"] == started
|
||||
|
||||
|
||||
def test_refresh_failed_by_limit_finishes_sync_run():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
r = queue.claim(conn)
|
||||
for _ in range(queue.MAX_DELIVERIES):
|
||||
queue.claim(conn)
|
||||
assert queue.get_command(conn, r["id"])["status"] == "failed"
|
||||
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_queue.py -q`
|
||||
Expected: FAIL — `issues_count = 300`; `finished_at` пуст.
|
||||
|
||||
- [ ] **Step 3: Implement** — в `src/jira_hub/queue.py`:
|
||||
|
||||
В `_redeliver`, сразу после `UPDATE command SET status = 'failed' …`:
|
||||
|
||||
```python
|
||||
if cmd["type"] == "refresh": # загрузка закончилась ничем — журнал синхронизаций закрыт (M-4 #12)
|
||||
conn.execute("UPDATE sync_run SET finished_at = ? WHERE command_id = ?", (now, cmd["id"]))
|
||||
```
|
||||
|
||||
В `claim`, в ветке `if cmd is not None:` (довыдача), после `UPDATE command SET claimed_at …, deliveries = deliveries + 1`:
|
||||
|
||||
```python
|
||||
if cmd["type"] == "refresh":
|
||||
# повторное выполнение загрузит то же заново: счётчики с нуля; started_at — граница пометки соседей,
|
||||
# не сдвигается (M-5 #12)
|
||||
conn.execute("UPDATE sync_run SET issues_count = 0, links_count = 0 WHERE command_id = ?", (cmd["id"],))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `uv run pytest tests/test_queue.py -q && uv run pytest -q`
|
||||
Expected: PASS, весь набор зелёный.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/jira_hub/queue.py tests/test_queue.py
|
||||
git commit -m "fix(queue): довыдача refresh не удваивает счётчики, предел выдач закрывает sync_run (#13)
|
||||
|
||||
M-4, M-5, M-10 (hub#12).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Отмена пакета с командой у агента; подсказка про метки в MCP
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/jira_hub/queue.py` (`cancel_batch`)
|
||||
- Modify: `src/jira_hub/mcp_server.py` (`fmt_batch`, docstring `batch_cancel`, `batch_create`)
|
||||
- Test: `tests/test_queue.py`, `tests/test_mcp.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `queue.batch_view`.
|
||||
- Produces: `queue.cancel_batch(conn, batch_id) -> dict` — вид пакета + `still_claimed: list[{"id": int, "type": str}]`;
|
||||
`mcp_server.fmt_batch(b)` выводит строку на каждую запись `still_claimed`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
В `tests/test_queue.py`:
|
||||
|
||||
```python
|
||||
def test_cancel_batch_reports_still_claimed():
|
||||
conn = fresh()
|
||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||
c = queue.claim(conn)
|
||||
view = queue.cancel_batch(conn, b["id"])
|
||||
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
|
||||
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||||
```
|
||||
|
||||
В `tests/test_mcp.py`:
|
||||
|
||||
```python
|
||||
def test_fmt_batch_warns_about_claimed():
|
||||
b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running",
|
||||
"remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [],
|
||||
"still_claimed": [{"id": 30, "type": "ensure_fields"}]}
|
||||
text = mcp_server.fmt_batch(b)
|
||||
assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text
|
||||
|
||||
|
||||
def test_batch_create_mentions_labels():
|
||||
doc = mcp_server.batch_create.__doc__ or ""
|
||||
assert "labels" in doc and "AWPCA" in doc
|
||||
```
|
||||
|
||||
Если `batch_create` после декораторов `@mcp.tool()`/`@guard` теряет `__doc__` — взять описание так, как его видит MCP
|
||||
(посмотреть, как это делает `test_async_tools_are_coroutines_and_registered`), и проверять его.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_queue.py tests/test_mcp.py -q`
|
||||
Expected: FAIL — `KeyError: 'still_claimed'`; строки о команде у агента нет; в описании нет `labels`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
`queue.cancel_batch` — вместо `return batch_view(conn, batch_id)`:
|
||||
|
||||
```python
|
||||
view = batch_view(conn, batch_id)
|
||||
# взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12)
|
||||
view["still_claimed"] = [{"id": c["id"], "type": c["type"]} for c in view["commands"] if c["status"] == "claimed"]
|
||||
return view
|
||||
```
|
||||
|
||||
`mcp_server.fmt_batch` — перед `return "\n".join(lines)`:
|
||||
|
||||
```python
|
||||
for s in b.get("still_claimed") or []:
|
||||
lines.append(f" команда #{s['id']} ({s['type']}) уже у агента и будет выполнена; остановить — "
|
||||
f"POST /commands/{s['id']}/cancel с force: true")
|
||||
```
|
||||
|
||||
Docstring `batch_cancel`: `"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
|
||||
в ответе подсказка (POST /commands/N/cancel с force)."""`
|
||||
|
||||
Docstring `batch_create` дополнить предложением: `ensure_issue передавайте с labels, включающими базовую метку проекта
|
||||
(AWPCA): хаб и агент метки сами не добавляют, задача без неё выпадет из базовой JQL и из зеркала.`
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `uv run pytest tests/test_queue.py tests/test_mcp.py -q && uv run pytest -q`
|
||||
Expected: PASS, весь набор зелёный.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/jira_hub/queue.py src/jira_hub/mcp_server.py tests/test_queue.py tests/test_mcp.py
|
||||
git commit -m "feat(queue,mcp): отмена пакета сообщает о команде у агента; подсказка про labels (#13)
|
||||
|
||||
M-11 (hub#12), T3.7 (agent#4).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Реле — 502 на сбой протокола, `work/` чистится один раз на папку, `serve --host`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/jira_hub/relay.py` (`__init__`, `_prepare`, `_forward`)
|
||||
- Test: `tests/test_relay.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.start(hub, host, port) -> (server, stop_event)`; `relay.Relay(hub, port, host="127.0.0.1", poll=0.1)`.
|
||||
- Produces: `Relay._cleaned: Path | None` — папка, чей `work/` уже вычищен после старта.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_relay.py` (импорт `http.client` — в начало файла):
|
||||
|
||||
```python
|
||||
def test_protocol_error_gives_502(env, monkeypatch):
|
||||
r, root = env["relay"], env["root"]
|
||||
r.step()
|
||||
|
||||
def broken(*a, **k):
|
||||
raise http.client.BadStatusLine("мусор")
|
||||
monkeypatch.setattr("urllib.request.urlopen", broken)
|
||||
put_request(root, "b1", "GET", "/agent/settings", env["agent"])
|
||||
run_once(r)
|
||||
resp = response(root, "b1")
|
||||
assert resp["status"] == 502 and "хаб недоступен" in resp["body"]
|
||||
|
||||
|
||||
def test_reprepare_keeps_work(env, capsys):
|
||||
r, root = env["relay"], env["root"]
|
||||
r.step() # первая подготовка
|
||||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
|
||||
(root / "req").rmdir() # пропал подкаталог — реле подготовит папку заново
|
||||
for _ in range(3):
|
||||
r.step()
|
||||
assert (root / "req").is_dir() and (root / "work" / "busy.json").exists()
|
||||
out = capsys.readouterr().out
|
||||
assert "перезапуск реле" not in out and "подготовлена заново" in out
|
||||
|
||||
|
||||
def test_relay_targets_serve_host(tmp_path, monkeypatch):
|
||||
seen = []
|
||||
|
||||
class Spy(relay.Relay):
|
||||
def __init__(self, hub, port, host="127.0.0.1", poll=0.1):
|
||||
seen.append(host)
|
||||
super().__init__(hub, port, host, poll)
|
||||
|
||||
monkeypatch.setattr(relay, "Relay", Spy)
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
for host in ("0.0.0.0", "localhost"):
|
||||
srv, stop = api.start(h, host, 0)
|
||||
stop.set()
|
||||
srv.server_close()
|
||||
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_relay.py -q`
|
||||
Expected: FAIL — `b1` без ответа (исключение в потоке); `busy.json` удалён и в логе «перезапуск реле»;
|
||||
`test_relay_targets_serve_host` может пройти сразу — это закрепление (M-1 #8).
|
||||
|
||||
- [ ] **Step 3: Implement** — в `src/jira_hub/relay.py`:
|
||||
|
||||
- импорт `import http.client`;
|
||||
- в `__init__`: `self._cleaned: Path | None = None # папка, чей work/ вычищен после старта реле`;
|
||||
- `_prepare`:
|
||||
|
||||
```python
|
||||
def _prepare(self) -> None:
|
||||
for sub in ("req", "resp", "work"):
|
||||
(self.root / sub).mkdir(exist_ok=True)
|
||||
if self._cleaned != self.root:
|
||||
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
|
||||
for stale in (self.root / "work").iterdir():
|
||||
stale.unlink(missing_ok=True)
|
||||
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
|
||||
self._cleaned = self.root
|
||||
else:
|
||||
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
|
||||
say(f"relay: папка {self.root} подготовлена заново")
|
||||
self._ready, self._warned = True, None
|
||||
```
|
||||
|
||||
- в `_forward` последний `except` расширить: `except (urllib.error.URLError, http.client.HTTPException, OSError) as e:`
|
||||
(текст ответа прежний: `хаб недоступен изнутри: {e}`).
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `uv run pytest tests/test_relay.py -q && uv run pytest -q`
|
||||
Expected: PASS, весь набор зелёный (`test_work_cleared_on_prepare` проходит: первая подготовка чистит).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/jira_hub/relay.py tests/test_relay.py
|
||||
git commit -m "fix(relay): 502 на сбой протокола, work/ чистится один раз на папку, тест serve --host (#13)
|
||||
|
||||
M-1, M-3, M-4 (hub#8).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Документы и `.gitignore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitignore`, `ARCHITECTURE.md`, `CLAUDE.md`
|
||||
|
||||
**Interfaces:** нет (только документы).
|
||||
|
||||
- [ ] **Step 1: `.gitignore`** — дописать строку `.wt/` (worktree внутри репозитория не должны попадать в `git status`).
|
||||
- [ ] **Step 2: `ARCHITECTURE.md`**:
|
||||
- строка `paths`: `каталог данных (\`JIRA_HUB_HOME\`), пути базы и \`backups/\`, права 0700 (\`secure_dir\`)`;
|
||||
- строка `db`: зависимость `backup`, `paths`; строка `backup`: зависимость `paths`;
|
||||
- строка `relay`: `реле общей папки (настройка \`relay_dir\`): поток в \`serve\`, файлы \`req/\` → свой HTTP (адрес
|
||||
\`serve\`, для \`0.0.0.0\` — \`127.0.0.1\`) → \`resp/\`; агент с \`hub_url = folder:<путь>\` ходит через него; HTTP-обработчики
|
||||
о нём не знают`;
|
||||
- в «Потоки данных» строка: `- Агент за VPN: агент → файлы в общей папке (\`relay_dir\`) → \`relay\` → HTTP хаба → ответ файлом.`
|
||||
- [ ] **Step 3: `CLAUDE.md`** — в «Что это» заменить «текущего change нет (последний — …bridge-reliability/)» на
|
||||
«текущий change — `openspec/changes/bridge-hygiene/`». В «Стек и ограничения» к строке про данные добавить
|
||||
«каталог данных и `backups/` — права 0700».
|
||||
- [ ] **Step 4: Проверка**
|
||||
|
||||
Run: `git status --short && openspec validate bridge-hygiene --strict && openspec validate --specs --strict && uv run pytest -q`
|
||||
Expected: `.wt/` не виден в `git status`; обе проверки OpenSpec зелёные; весь набор тестов зелёный.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitignore ARCHITECTURE.md CLAUDE.md
|
||||
git commit -m "docs: реле и relay_dir в ARCHITECTURE, права каталога, текущий change, .wt/ в .gitignore (#13)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
2
openspec/changes/bridge-hygiene/.openspec.yaml
Normal file
2
openspec/changes/bridge-hygiene/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-14
|
||||
145
openspec/changes/bridge-hygiene/design.md
Normal file
145
openspec/changes/bridge-hygiene/design.md
Normal file
@ -0,0 +1,145 @@
|
||||
## Context
|
||||
|
||||
Мост в работе с 13.10.2026: хаб на Mac mini (launchd, `~/.local/share/jira-hub/hub.sqlite`, схема v2, 196 задач,
|
||||
11 пакетов), агент v3 на виртуалке через общую папку. Код: хаб `main` c6aa2ed, агент `main` d7cd050.
|
||||
|
||||
Источник состава — разбор пакета D 14.10.2026 (комментарии в hub#4 и agent#4) и issues hub#8, hub#12, agent#8, agent#12.
|
||||
Маркеры находок сохраняются как в исходных issues; одинаковые номера из разных issue различаются по источнику:
|
||||
«M-3 #4» — `rename_note` (PR #2 bridge-core), «M-3 #8» — 502 в реле, «M-3 #12» — нестроковый `summary`.
|
||||
|
||||
Найдено при чтении кода:
|
||||
- `paths.home()` и `db.connect` создают каталоги с правами по umask (на Mac mini — `0755`), файлы — `0644`.
|
||||
- `mirror.rename_note` делает `DELETE … WHERE key = new_key`, затем `UPDATE key = new_key`.
|
||||
- `queue._status_of` даёт `cancelled` только если все команды `cancelled`.
|
||||
- `queue.report_result` пишет `error` как пришёл; ветка позднего отчёта есть только для `cancelled`.
|
||||
- `queue._redeliver` переводит команду в `failed` по пределу, не трогая `sync_run`; повторная выдача `refresh` не
|
||||
трогает `sync_run`, а `record_state` прибавляет счётчики.
|
||||
- `db.migrate` делает копию до транзакции миграции; версию перечитывает уже внутри.
|
||||
- `relay._prepare` вызывается при каждом `_ready = False` (смена `relay_dir`, пропажа подкаталога), а чистит `work/` как
|
||||
при старте.
|
||||
- агент: `restore_pace` пишет оценку из журнала и в `last_call_at`, и в `last_call_iso`; второе уходит в `claim`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** закрыть пункты пакета C без новых возможностей; `openspec validate --specs --strict` зелёный; хаб — без
|
||||
миграции схемы; агент — версия 4, совместим с хабом без изменений контракта.
|
||||
|
||||
**Non-Goals:** см. proposal (живые проверки, «на потом», отклонённое в разборе D).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Права каталога данных (M-7 #4).** `paths.home()` и `paths.backups_dir()` создают каталог и выставляют `0700`
|
||||
(`os.chmod` и для уже существующего). `db.connect` для файловой базы — `chmod 0700` на каталог базы. Файлы внутри не
|
||||
трогаем: закрытого каталога достаточно. Каталог задаётся `JIRA_HUB_HOME` и в тестах лежит в `tmp_path` — chmod там
|
||||
безвреден.
|
||||
2. **Слияние заметок (M-3 #4).** `rename_note(old, new)`: если заметки под `new` нет — переименование, как сейчас. Если
|
||||
есть — в одной транзакции для каждого поля заметки (`fact_status`, `work_kind`, `done_text`, `rest_text`,
|
||||
`owner_note`) берётся значение временной заметки, если оно непустое, иначе значение под `new`; `updated_at`/`updated_by`
|
||||
— от более поздней; временная строка удаляется. Так заметка, которую владелец успел завести на настоящий ключ, не
|
||||
теряется, а заполненные при планировании поля `new:N` побеждают.
|
||||
3. **Статус отменённого пакета (T5.6).** `_status_of`: если у пакета `cancelled_at` и все команды терминальные —
|
||||
`cancelled`, даже если часть `done`. Без `cancelled_at` — как сейчас. Видимые счётчики `done`/`failed`/`skipped` в
|
||||
`batch_view` не меняются.
|
||||
4. **Обрезка `error` (спека commands).** `report_result` обрезает `error` до 1500 символов. Одна точка — для агента и
|
||||
любого клиента.
|
||||
5. **Поздний отчёт по `failed` по пределу (M-4 #12).** Ветка позднего отчёта в `report_result` расширяется: команда
|
||||
`ensure_issue` в `cancelled` **или** `failed`, отчёт `done` с `result.key`, у команды нет `result` — ключ записывается
|
||||
в `result`, заметка переносится (`rename_note`), статус не меняется. Для `failed` это означает «хаб сдался, агент
|
||||
всё-таки создал»: зависимые команды уже `skipped`, повтор — новой командой. При переводе в `failed` по пределу выдач
|
||||
(`_redeliver`) для `refresh` хаб SHALL проставить `sync_run.finished_at`. Ключ для поздних отчётов по прочим типам не
|
||||
нужен — они отбрасываются, как сейчас.
|
||||
6. **Довыдача `refresh` (M-5 #12).** При довыдаче команды `refresh` хаб обнуляет `issues_count`, `links_count` её
|
||||
`sync_run`; `started_at` не меняется (по нему полный прогон помечает соседей — так безопаснее: прочитанное первым
|
||||
выполнением не станет соседом).
|
||||
7. **Пустой полный прогон (M-12 #4).** Пометка соседей по полному прогону делается только при `sync_run.issues_count > 0`.
|
||||
Пустой результат валидной JQL почти всегда означает сбой выборки (сменили метку, права), а не «все задачи ушли».
|
||||
8. **Типы параметров (M-3 #12).** `commands.validate` проверяет, что строковые параметры (`summary`, `description`,
|
||||
`temp_key`, `key`, `marker`, `body`, `source`, `target`, `link_type`, `sprint`, `assignee`, `priority`, даты) —
|
||||
строки или `null` там, где `null` разрешён; иначе `ValueError` → 422. `target` в `ensure_status` — строка или список
|
||||
строк; `labels`, `labels_add`, `labels_remove`, `keys` — списки строк; `replace` — bool.
|
||||
9. **Миграция под блокировкой (M-2 #12).** `migrate`: версия читается вне транзакции; если ниже текущей — `BEGIN
|
||||
IMMEDIATE` (блокировка записи), повторное чтение версии, и только если всё ещё ниже — копия базы и миграция в той же
|
||||
транзакции. Копия делается отдельным соединением к тому же файлу (`VACUUM INTO` внутри транзакции SQLite запрещает,
|
||||
а читатель при WAL работает и под чужой блокировкой записи и видит последнее зафиксированное состояние — базу до
|
||||
миграции; проверено 14.10.2026). Второй `connect`, дождавшись блокировки, видит новую версию и копию не делает.
|
||||
Docstring исправляется.
|
||||
10. **Реле (M-3 #8, M-4 #8).** `_forward` ловит `http.client.HTTPException` вместе с `URLError`/`OSError` → 502. Чистка
|
||||
`work/` — только при первой подготовке каждой папки после старта реле (реле помнит, какую папку уже чистило; новая
|
||||
папка после смены `relay_dir` чистится); повторная подготовка той же папки (пропал подкаталог, не было доступа)
|
||||
создаёт подкаталоги без чистки и пишет «relay: папка подготовлена заново». Запросы,
|
||||
взятые до пропажи папки, обрабатываются своими потоками или пропадают вместе с папкой.
|
||||
11. **Отмена пакета с взятой командой (M-11 #12).** `cancel_batch` возвращает вид пакета с полем `still_claimed` —
|
||||
список `{id, type}` команд пакета в `claimed`. MCP `batch_cancel` выводит строку «команда #N уже у агента и будет
|
||||
выполнена; остановить — `POST /commands/N/cancel` с `force: true`». Отдельный инструмент MCP не добавляется.
|
||||
12. **Подсказка про метки (T3.7).** Описание MCP-инструмента `batch_create`: `ensure_issue` должна
|
||||
нести `labels` с базовой меткой (`AWPCA`), иначе задача окажется вне `base_jql` и выпадет из зеркала. Код не меняется.
|
||||
13. **Спеки к коду (разбор D).** Только текст спек: `sprint_names` убирается из перечня настроек (спринты агент читает с
|
||||
доски `board_id`); коды ответов — 400/401/404/409/413/422/500, реле — 502; прочие методы HTTP — 501 без JSON;
|
||||
`page`/`last_page` в `POST /agent/state` — информационные; `GET /issues` отдаёт поля зеркала с `fact_status`,
|
||||
`work_kind`; автообновление — нет `queued`/`claimed` и последний `sync_run.started_at` старше интервала; MCP
|
||||
`refresh` — id пакета или итог при `wait`; `new:<строка>` — только для `new_tasks.csv`, строки `tasks.csv`/`epics.csv`
|
||||
без ключа пропускаются (T7.3).
|
||||
14. **Purpose спек (O-4 #8).** В каждый `openspec/specs/*/spec.md` вписывается назначение capability (2–3 предложения)
|
||||
прямо в ветке: дельты OpenSpec раздел Purpose не меняют.
|
||||
15. **Тесты (M-1 #8, M-10 #12).** Тест реле при `serve --host 127.0.0.2` (или другом конкретном адресе) — реле пересылает
|
||||
на этот адрес. `test_fresh_definer_still_blocks_queue` переименовывается по смыслу довыдачи. Сквозной тест: база v1
|
||||
с командой в `claimed` → `connect` (миграция, `deliveries = 1`) → `claim` отдаёт её снова с `deliveries = 2`.
|
||||
|
||||
Агентские решения (подробно — `jira-agent/openspec/changes/bridge-hygiene/design.md`):
|
||||
|
||||
16. **CRLF (T3.9).** Сравнение описания в `ensure_fields` и тела комментария в `ensure_comment` — после
|
||||
`replace("\r\n", "\n")` с обеих сторон; отправляется текст команды как есть. Поиск маркера комментария не меняется.
|
||||
17. **Время журнала в будущем (M-6 #12).** `restore_pace` принимает его за «сейчас»: `last_call_at = monotonic()`, пауза
|
||||
первой команды — полная `pause_seconds`. Лог сохраняется.
|
||||
18. **`last_call_iso` (M-7 #12).** `restore_pace` заполняет только `last_call_at`; `last_call_iso` — только при настоящем
|
||||
вызове Jira. До первого вызова в `claim` уходит `null`, хаб (`COALESCE`) сохраняет прошлое значение.
|
||||
19. **Ответ папки с неверными типами (M-2 #8).** `status` не целое или `body` не строка — как нечитаемый JSON:
|
||||
перечитать до 2 с, затем `ConnectionError`.
|
||||
20. **`prune` под блокировкой (T2.3).** `prune` берёт ту же `acquire_lock`; если занята — «агент запущен, prune невозможен»,
|
||||
код 1. README: снять «Блокировки нет».
|
||||
21. **Версия агента 4.** Поведение сравнения и темпа изменилось; `min_agent_version` не поднимается.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [chmod на чужом `JIRA_HUB_HOME`] → каталог задаёт владелец; при ошибке `chmod` (не владелец) хаб пишет предупреждение
|
||||
и продолжает.
|
||||
- [Слияние заметок выбирает значение временной заметки] → поля `new:N` заполняются при планировании и свежее; поля,
|
||||
пустые у временной, сохраняются под ключом.
|
||||
- [M-12: настоящий «0 задач» в базовой JQL] → соседи не пометятся до следующего непустого прогона; зеркало это переживает.
|
||||
- [Миграция в `BEGIN IMMEDIATE` держит блокировку записи на время копии] → база меньше 1 МБ, копия — доли секунды;
|
||||
миграция бывает раз в версию.
|
||||
- [CRLF: Jira хранит `\r\n`, команда — `\n`] → после правки агентом в Jira будет `\n`; это и есть текст команды.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Хаб: merge → `git pull` в `jira-hub` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`; проверить
|
||||
`stat -f %Lp ~/.local/share/jira-hub` = 700, `schema_version` = 2 (миграции нет).
|
||||
2. Агент: остановить v3 (Ctrl-C), положить `agent.py` v4 в `RSHB/` (корень общей папки), на виртуалке скопировать в
|
||||
`~/jira-agent-bundle`, `check`, `run`; `agent_status` — версия 4.
|
||||
3. Откат: `git checkout` предыдущего `main` и перезапуск; агент — вернуть файл v3.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Владелец 14.10.2026 утвердил состав пакета и решения 5 (M-4 #12), 16 (CRLF), 18 (M-7 #12). Остальное — рулинги
|
||||
при написании спеки.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-1 (design #1) | 2026-10-14 | Права `0700` только на каталоги; при сбое `chmod` — предупреждение, не остановка | закрытый каталог закрывает файлы; хаб под launchd не должен падать из-за прав | токены остаются читаемы, если каталог чужой — видно в логе |
|
||||
| R-2 (design #2) | 2026-10-14 | При слиянии заметок побеждает непустое поле временной заметки | поля `new:N` заполняются при планировании и свежее | владелец потеряет поле под ключом, если временная заметка заполнена тем же полем — маловероятно |
|
||||
| R-3 (design #3) | 2026-10-14 | Пакет «отменён», если есть `cancelled_at` и всё терминально, даже при части `done` | после отмены остаток не применён — «завершён» вводит в заблуждение | только отображение |
|
||||
| R-4 (design #6) | 2026-10-14 | Довыдача `refresh` обнуляет счётчики, `started_at` не трогает | `started_at` — граница пометки соседей; сдвиг пометил бы прочитанное первым выполнением | только отображение счётчиков |
|
||||
| R-5 (design #7) | 2026-10-14 | Соседи по полному прогону помечаются только при `issues_count > 0` | пустая выборка валидной JQL — почти всегда сбой (метка, права) | настоящий «0 задач» не пометит соседей до следующего прогона |
|
||||
| R-6 (design #8) | 2026-10-14 | Проверка типов всех параметров команд, не только `summary` | M-3 #12 — частный случай; одна проверка дешевле серии 500 | слишком строгая проверка отвергнет допустимый пакет — 422 с именем параметра, видно сразу |
|
||||
| R-7 (design #9) | 2026-10-14 | Копия перед миграцией — под `BEGIN IMMEDIATE`, после повторной проверки версии, отдельным соединением-читателем | одна копия при параллельных `connect` (M-2 #12) | блокировка записи на долю секунды при миграции |
|
||||
| R-8 (design #10) | 2026-10-14 | `work/` чистится только при первой подготовке каждой папки после старта реле | при повторной подготовке взятые запросы ещё обрабатываются своими потоками (M-4 #8) | после пропажи папки в `work/` может остаться файл до перезапуска — безвредно |
|
||||
| R-9 (design #11) | 2026-10-14 | Отмена пакета возвращает `still_claimed`; MCP-инструмент отмены команды не добавляется | M-11 #12 — про информирование; force-отмена нужна редко | для остановки взятой команды нужен вызов API, не MCP |
|
||||
| R-10 (design #13) | 2026-10-14 | Расхождения спеки и кода из разбора D закрываются правкой спеки, не кода | код разумнее текста спеки (спринты с доски, автообновление от `sync_run`), потребитель один | нет |
|
||||
| R-11 (design #21) | 2026-10-14 | Агент — версия 4, `min_agent_version` не поднимается | поведение сравнения и темпа изменилось; v3 продолжает работать | нет |
|
||||
| R-14 (design #22) | 2026-10-14 | В `fmt_batch` MCP строка про команду у агента без типа команды: «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | бриф Task 6 («команда #N (тип) …») противоречил тесту и спеке mcp; побеждает спека; тип доступен в JSON `still_claimed` | в тексте MCP не виден тип команды, только номер |
|
||||
| R-15 (design #8) | 2026-10-14 | Спека commands приведена к коду: `null` допустим у любого необязательного параметра («не задан»; у `ensure_fields` — очистка), а не только «где параметр можно очистить» | проверка типов (R-6) не должна вводить список очищаемых параметров — агент уже трактует `null` как «не задан»; финальное ревью хаба M-2 | `null` в необязательном параметре `ensure_issue` (например, `labels`) создаст задачу без поля — как и пропуск параметра |
|
||||
| R-13 (агент) | 2026-10-14 | Спека agent: ответ папки с `body: null` или без `body` — пустое тело, нечитаемый — только `body` иного типа | совместимость с прежним поведением агента; реле всегда пишет строку; финальное ревью агента M-1 | нет |
|
||||
91
openspec/changes/bridge-hygiene/proposal.md
Normal file
91
openspec/changes/bridge-hygiene/proposal.md
Normal file
@ -0,0 +1,91 @@
|
||||
## Why
|
||||
|
||||
Мост в ежедневной работе с 13.10.2026; пакеты A (`bridge-reliability`, #9) и B (проверки на живой Jira) закрыты. Осталось
|
||||
три follow-up issue в каждом репозитории (hub#4, #8, #12; agent#4, #8, #12). Пакет D (разбор хвоста hub#4/agent#4,
|
||||
14.10.2026) разделил их на «в C», «отклонить», «на потом». Этот change — пакет C: мелкие исправления, приведение спек
|
||||
к коду и гигиена, одним change на оба репозитория. Новых возможностей нет.
|
||||
|
||||
Что чинится по существу:
|
||||
- **M-7 (hub#4)** — каталог данных хаба `0755`, база `0644`, а в базе токены: читаемы любым локальным пользователем.
|
||||
- **M-3 (hub#4, PR #2)** — `rename_note` при переносе `new:N` → ключ удаляет заметку, уже стоящую под настоящим ключом.
|
||||
- **T2.3 (agent#4)** — `prune` перезаписывает журнал без блокировки: запись `finished`, добавленная работающим агентом,
|
||||
теряется (потерянный отчёт).
|
||||
- **M-4 (hub#12)** — поздний `done` с ключом по `ensure_issue`, ставшей `failed` по пределу выдач, отбрасывается: заметка
|
||||
`new:N` не переезжает; у `refresh`, ставшего `failed` по пределу, `sync_run.finished_at` пуст.
|
||||
- **M-5 (hub#12)** — повторное выполнение `refresh` после довыдачи удваивает счётчики `sync_run`.
|
||||
- **M-12 (hub#4)** — полный прогон, вернувший 0 задач, помечает всё зеркало соседями.
|
||||
- **T5.6 (hub#4)** — отменённый пакет с частью выполненных команд показывается «завершён».
|
||||
- **M-3, M-4 (hub#8)** — реле: `HTTPException` от своего HTTP не превращается в 502; `work/` чистится при каждой
|
||||
повторной подготовке, а не только при старте.
|
||||
- **M-2 (hub#12)** — параллельные `connect` к базе старой версии делают по копии на соединение.
|
||||
- **M-3 (hub#12)** — нестроковый `summary` в `ensure_issue` даёт 500 вместо 422.
|
||||
- **M-2 (agent#8)** — ответ папки с неверными типами `status`/`body` роняет агента.
|
||||
- **M-6 (agent#12)** — время последней записи журнала в будущем отключает паузу темпа.
|
||||
- **M-7 (agent#12)** — после перезапуска в `claim` уходит время записи журнала как «последний вызов Jira».
|
||||
- **T3.9 (agent#4)** — Jira хранит текст, правленный в UI, с `\r\n`; агент сравнивает с `\n` из команды и делает лишний
|
||||
`PUT` описания/комментария (проверено на живой Jira 13.10.2026: 71 из 180 задач).
|
||||
|
||||
Решения владельца 14.10.2026 (разбор D и brainstorming):
|
||||
- состав пакета C — по таблице разбора D (комментарии hub#4 #3323, agent#4 #3326), плюс M-12 и подсказка про `labels`;
|
||||
- T-n без сохранившегося текста ревью — отклонены пачкой;
|
||||
- живая проверка потери отчёта, довыдачи и force-cancel — отдельно, не в этом change;
|
||||
- M-4 (hub#12): поздний отчёт по `failed` по пределу выдач — как по отменённой (ключ в результат, заметка переезжает,
|
||||
статус остаётся `failed`); `refresh`, ставший `failed` по пределу, получает `sync_run.finished_at`;
|
||||
- M-7 (agent#12): в `claim` уходит только время настоящего вызова Jira; оценка из журнала — только для паузы;
|
||||
- CRLF: агент сравнивает описание и тело комментария после замены `\r\n` на `\n`, отправляет как есть; хаб не меняется.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **jira-hub**:
|
||||
- права `0700` на каталог данных и `backups/` (при создании и при старте);
|
||||
- `rename_note` сливает заметки: поле под настоящим ключом сохраняется, если у временной заметки оно пустое;
|
||||
- статус пакета `cancelled`, если пакет отменён и все его команды в терминальных статусах;
|
||||
- `report_result` обрезает `error` до 1500 символов;
|
||||
- поздний отчёт `done` с ключом по `ensure_issue` в `failed` без результата — как по `cancelled`; при переводе `refresh`
|
||||
в `failed` по пределу выдач — `sync_run.finished_at`;
|
||||
- довыдача `refresh` обнуляет `issues_count`/`links_count` его `sync_run`;
|
||||
- полный прогон с `issues_count = 0` не помечает задачи соседями;
|
||||
- `commands.validate`: строковые параметры проверяются на тип (422);
|
||||
- миграция: копия перед миграцией и сама миграция — под одной блокировкой записи, копия одна;
|
||||
- реле: `http.client.HTTPException` → 502; `work/` чистится только при старте реле; строка лога при повторной
|
||||
подготовке не говорит «перезапуск»;
|
||||
- ответ `POST /batches/{id}/cancel` и MCP `batch_cancel` сообщают о команде пакета, уже взятой агентом;
|
||||
- MCP `batch_create`: в описании инструмента — передавать базовые метки (`labels`) в `ensure_issue`;
|
||||
- тесты: `serve --host <IP>` для реле, переименование `test_fresh_definer_still_blocks_queue`, сквозной тест
|
||||
«база v1 → миграция → довыдача 1 → 2»;
|
||||
- `.wt/` в `.gitignore`; `ARCHITECTURE.md` — реле и `relay_dir`.
|
||||
- **jira-agent** (версия 4):
|
||||
- сравнение описания и тела комментария без различия `\r\n`/`\n`;
|
||||
- время журнала в будущем — принимается за «сейчас» (полная пауза);
|
||||
- в `claim` — только время настоящего вызова Jira в этом процессе;
|
||||
- ответ папки с неверными типами `status`/`body` — `ConnectionError` (как нечитаемый JSON);
|
||||
- `prune` берёт блокировку журнала и отказывает при работающем агенте; README;
|
||||
- лог «хаб: хаб не ответил…» без повтора слова.
|
||||
- Спеки: `hub-api`, `data-model`, `commands`, `agent`, `mcp`, `migration` — MODIFIED. Кроме исправлений, спеки
|
||||
приводятся к коду (разбор D): нет настройки `sprint_names`; коды ответов 413/500/502 и 501 на прочие методы;
|
||||
`page`/`last_page` информационные; `GET /issues` — поля зеркала; автообновление — от последнего `sync_run`;
|
||||
MCP `refresh` — id пакета; `new:<строка>` — только для `new_tasks.csv`. Раздел `## Purpose` всех шести спек
|
||||
заполняется (сейчас — заглушка `TBD`, из-за неё падает `openspec validate --specs --strict`).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Живые проверки потери отчёта, довыдачи, force-cancel и `ensure_link` (agent#12 O-1) — отдельно, на тестовой задаче.
|
||||
- «На потом» из разбора D: ResourceWarning в тестах хаба (M-9/O-4), пробелы тестов (M-10 хаба), точность фейков агента
|
||||
(M-10 агента).
|
||||
- Отклонённые в разборе D пункты (M-4, M-8/T6.12, T5.4, T5.11, T6.5, T6.6, T9.1, T1.1, M-6, M-11 хаба; FM-1…FM-3, O-2,
|
||||
T1.9, N-3, T2.7, T3.2, T3.3, T4.7, T4.11, T5.2, T7.6, «Task 2 minor» агента; T-n без текста).
|
||||
- Автодобавление базовых меток хабом или агентом (T3.7): только подсказка в описании инструмента.
|
||||
- MCP-инструмент отмены отдельной команды: `batch_cancel` лишь подсказывает про `force`.
|
||||
- Подъём `min_agent_version` до 4.
|
||||
|
||||
## Impact
|
||||
|
||||
- `jira-hub`: `paths.py`, `db.py` (миграция под блокировкой, права), `mirror.py` (`rename_note`), `queue.py` (`_status_of`,
|
||||
`report_result`, `_redeliver`, `claim`, `cancel_batch`), `commands.py` (`validate`), `relay.py`, `api.py` (ответ отмены
|
||||
пакета), `mcp_server.py` (`batch_cancel`, `batch_create`), тесты, `.gitignore`, `ARCHITECTURE.md`.
|
||||
- `jira-agent`: `src/agent.py` (`do_ensure_fields`, `do_ensure_comment`, `restore_pace`, `JiraClient.last_call_iso`,
|
||||
`FolderTransport`, `cmd prune`, лог), `README.md`, тесты, `.gitignore`; версия 4.
|
||||
- Контракт хаб ↔ агент: тела запросов не меняются; ответ отмены пакета получает поле `still_claimed`.
|
||||
- Выкатка: хаб — `git pull` + перезапуск launchd (схема не меняется), проверить права каталога; агент v4 — через
|
||||
общую папку, как v3.
|
||||
- Issues: aep/jira-hub#13, aep/jira-agent#15; закрывают hub#8, hub#12, agent#8, agent#12, а hub#4/agent#4 — кроме «на потом».
|
||||
90
openspec/changes/bridge-hygiene/specs/agent/spec.md
Normal file
90
openspec/changes/bridge-hygiene/specs/agent/spec.md
Normal file
@ -0,0 +1,90 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Журнал на диске и восстановление
|
||||
Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `claimed {command_id, at}` (сразу после
|
||||
получения команды, до паузы), `started {command_id, at}`, `finished {command_id, status, result, error, jira_calls,
|
||||
rate_limited, at}`, `acked {command_id, at}`. При старте агент SHALL: отправить хабу все `finished` без `acked`; для
|
||||
`claimed`/`started` без `finished` — запросить команду у хаба по id и, если она в хабе всё ещё `claimed`, выполнить её
|
||||
заново по правилу «проверь, потом сделай» с паузой `pause_seconds` из ответа хаба, не записывая `claimed` повторно.
|
||||
Команда `python3 agent.py prune` SHALL удалять из журнала записи с `acked` старше 30 дней; она SHALL брать ту же
|
||||
блокировку `<журнал>.lock`, что `run`, и при занятой блокировке завершаться с кодом 1 и сообщением «агент запущен»,
|
||||
не меняя журнал.
|
||||
|
||||
#### Scenario: Падение после ответа Jira
|
||||
- **WHEN** агент упал между `POST /issue` и записью `finished`, затем перезапущен
|
||||
- **THEN** агент находит в журнале `started` без `finished`, запрашивает команду у хаба, находит созданную задачу поиском
|
||||
дубля и отчитывается `done` с её ключом; второй задачи нет
|
||||
|
||||
#### Scenario: Восстановление без лишней записи
|
||||
- **WHEN** в журнале `claimed 40` без `finished`, хаб отдаёт команду 40 в `claimed` с `pause_seconds = 30`
|
||||
- **THEN** в журнал дописываются `started 40`, `finished 40`, `acked 40`; второй строки `claimed 40` нет; перед выполнением
|
||||
агент выдерживает 30 с от последнего вызова Jira
|
||||
|
||||
#### Scenario: prune при работающем агенте
|
||||
- **WHEN** `agent.py run` работает, и запущен `agent.py prune` с тем же журналом
|
||||
- **THEN** `prune` пишет «агент запущен…» и завершается с кодом 1; журнал не изменён
|
||||
|
||||
### Requirement: Ограничение темпа и 429
|
||||
Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. При
|
||||
старте агент SHALL принять за время последнего вызова Jira самое позднее `at` в журнале; если оно в будущем (часы
|
||||
сдвинулись назад) — текущий момент (пауза перед первой командой — полная `pause_seconds`); при пустом журнале паузы
|
||||
перед первой командой нет. Оценка из журнала служит только паузе: в `claim` агент SHALL передавать `last_jira_call_at`
|
||||
только после настоящего вызова Jira в текущем процессе, до него — `null`. На HTTP 429 и 503 агент SHALL ждать
|
||||
`Retry-After` (иначе 60 с × номер попытки) и повторять тот же запрос до 5 раз, считая попытки в `rate_limited`; после
|
||||
5 неудач — `failed`. Пауза между записями внутри одной команды — по зазору типа команды.
|
||||
|
||||
#### Scenario: 429 с Retry-After
|
||||
- **WHEN** Jira ответила 429 с `Retry-After: 90`
|
||||
- **THEN** агент ждёт 90 с, повторяет тот же запрос; в отчёте `rate_limited = 1`
|
||||
|
||||
#### Scenario: Перезапуск не сбивает темп
|
||||
- **WHEN** последняя запись журнала `finished` сделана 10 с назад, агент перезапущен и получил команду с `pause_seconds = 30`
|
||||
- **THEN** агент ждёт около 20 с перед первым вызовом Jira по команде
|
||||
|
||||
#### Scenario: Время журнала в будущем
|
||||
- **WHEN** последняя запись журнала на 2 часа позже текущего времени, агент перезапущен и получил команду с `pause_seconds = 30`
|
||||
- **THEN** агент ждёт 30 с перед первым вызовом Jira; в логе — что время журнала в будущем
|
||||
|
||||
#### Scenario: После перезапуска хаб не получает оценку из журнала
|
||||
- **WHEN** агент перезапущен с непустым журналом и отправляет первый `claim`
|
||||
- **THEN** `last_jira_call_at` в `claim` — `null`; после первого вызова Jira следующий `claim` несёт его время
|
||||
|
||||
### Requirement: Доставка и запуск
|
||||
README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json`
|
||||
(включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist,
|
||||
`prune` (только при остановленном агенте, иначе откажет). Версия агента SHALL быть в константе файла (4 — со сравнением
|
||||
текстов без различия переводов строк и уточнённым восстановлением темпа) и в каждом отчёте; хаб предупреждает в
|
||||
`agent_status`, если версия ниже минимальной из настроек.
|
||||
|
||||
#### Scenario: Устаревший агент
|
||||
- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2`
|
||||
- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему
|
||||
|
||||
### Requirement: Транспорт через общую папку
|
||||
При `hub_url = "folder:<путь>"` агент SHALL обращаться к хабу теми же запросами, что по HTTP, через файлы в `<путь>`:
|
||||
на каждый вызов записать `req/<id>.json` = `{id, method, path, body, authorization, deadline}` (`id` — `uuid4().hex`,
|
||||
`authorization` — `Bearer <hub_token>`, `deadline` — момент UTC = сейчас + таймаут вызова; для `claim` таймаут не меньше
|
||||
45 с) через временный файл с именем на точку и переименование, затем опрашивать `resp/<id>.json` каждые 0,1 с.
|
||||
Полученный ответ агент SHALL удалить; `status` 2xx — вернуть разобранное тело, иной — выбросить `HttpError(status, body)`
|
||||
так же, как HTTP-клиент. Если к `deadline` ответа нет, агент SHALL удалить свой `req/<id>.json` (если он ещё там) и
|
||||
выбросить `ConnectionError`. Нечитаемый ответ — не JSON-объект, `status` не целое число или `body` не строка и не `null` (`null` и отсутствие — пустое тело) — SHALL
|
||||
перечитываться до 2 с, затем `ConnectionError`. Чужие файлы в `resp/` агент MUST NOT трогать. Цикл выполнения, журнал,
|
||||
повторы отчёта и восстановление SHALL работать без изменений.
|
||||
|
||||
#### Scenario: Ответ хаба через папку
|
||||
- **WHEN** агент вызвал `settings`, реле положило `resp/<id>.json` со `status = 200`
|
||||
- **THEN** агент получает настройки, файл ответа удалён
|
||||
|
||||
#### Scenario: Реле не отвечает
|
||||
- **WHEN** агент вызвал `claim`, а ответа нет 45 с
|
||||
- **THEN** `req/<id>.json` удалён агентом, выброшен `ConnectionError`; цикл пишет «хаб недоступен» и повторяет `claim`
|
||||
через `poll_seconds`, как при недоступном хабе по сети
|
||||
|
||||
#### Scenario: Код ошибки хаба через папку
|
||||
- **WHEN** агент отправил `result` по команде, которой в хабе нет, реле положило ответ со `status = 404`
|
||||
- **THEN** агент получает `HttpError(404)` и считает команду закрытой, как по HTTP
|
||||
|
||||
#### Scenario: Ответ с неверными типами
|
||||
- **WHEN** в `resp/<id>.json` лежит `{"id": …, "status": "ok", "body": 5}`
|
||||
- **THEN** агент выбрасывает `ConnectionError` («нечитаемый ответ»), процесс не падает; цикл повторяет вызов как при
|
||||
недоступном хабе
|
||||
108
openspec/changes/bridge-hygiene/specs/commands/spec.md
Normal file
108
openspec/changes/bridge-hygiene/specs/commands/spec.md
Normal file
@ -0,0 +1,108 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Команда — желаемый результат на одной задаче
|
||||
Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить →
|
||||
записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done`
|
||||
без записи. Типы команд и их контракт:
|
||||
|
||||
| Тип | Параметры | Что читает | Что пишет |
|
||||
|---|---|---|---|
|
||||
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = <proj> AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` (для эпика — с `epic_name_field_id`); результат `key` |
|
||||
| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` |
|
||||
| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет |
|
||||
| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных |
|
||||
| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием |
|
||||
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | `GET /issue/{key}` (поле спринта), список спринтов доски из `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` |
|
||||
| `refresh` | `keys` (список) или `jql` | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks` | ничего в Jira; состояние уходит в хаб |
|
||||
|
||||
Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в
|
||||
`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026).
|
||||
|
||||
Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira
|
||||
без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный
|
||||
в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026).
|
||||
|
||||
#### Scenario: Повтор уже выполненной команды
|
||||
- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10
|
||||
- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1`
|
||||
|
||||
#### Scenario: Описание отличается только переводами строк
|
||||
- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"`
|
||||
- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []`
|
||||
|
||||
#### Scenario: Замена связи
|
||||
- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между
|
||||
ними «блокирует начало» 1537→1538
|
||||
- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями
|
||||
|
||||
#### Scenario: Связь-конфликт без replace
|
||||
- **WHEN** та же команда с `replace: false`
|
||||
- **THEN** `failed`, в `error` найденная связь, Jira не изменена
|
||||
|
||||
#### Scenario: Переход по пути
|
||||
- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки»
|
||||
- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи
|
||||
|
||||
#### Scenario: Создание с проверкой дубля
|
||||
- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад
|
||||
- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной
|
||||
|
||||
### Requirement: Пакет и порядок выполнения
|
||||
Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка
|
||||
и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh`). Хаб SHALL проверять
|
||||
типы параметров: текстовые параметры и ключи — строки (`null` у необязательного параметра — «не задан»; агент
|
||||
трактует его по контракту типа, для `ensure_fields` — очистка поля), `target` команды
|
||||
`ensure_status` — строка или список строк, `labels`, `labels_add`, `labels_remove`, `keys` — списки строк, `replace` —
|
||||
логическое значение; нарушение — 422 с именем параметра. Пакет с двумя и более `ensure_issue` с одинаковой `summary`
|
||||
(после обрезки пробелов по краям) SHALL отвергаться (422). Очередь SHALL выдавать команды в порядке
|
||||
`(batch.created_at, seq)`, одну в полёте. Пакет и его `queued` команды MAY быть отменены; `claimed` команда MUST NOT
|
||||
отменяться автоматически.
|
||||
|
||||
#### Scenario: Оценка длительности
|
||||
- **WHEN** создаётся пакет из 3 `ensure_fields` и 2 `ensure_comment`
|
||||
- **THEN** `estimate_seconds` = сумма пауз по политике для последовательности типов + пауза `refresh`
|
||||
|
||||
#### Scenario: Две задачи с одной темой
|
||||
- **WHEN** пакет содержит `ensure_issue {temp_key: new:1, summary: "Отчёт"}` и `ensure_issue {temp_key: new:2, summary: " Отчёт "}`
|
||||
- **THEN** 422 с темой «Отчёт», пакет не создан
|
||||
|
||||
#### Scenario: Параметр не того типа
|
||||
- **WHEN** пакет содержит `ensure_fields {key: X, labels_add: "AWPCA"}`
|
||||
- **THEN** 422 с указанием `labels_add`, пакет не создан
|
||||
|
||||
### Requirement: Ошибки и подтверждение по состоянию
|
||||
Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`,
|
||||
обрезая присланное. Пакет после ошибки продолжается.
|
||||
|
||||
**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому
|
||||
команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL
|
||||
сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры,
|
||||
`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL
|
||||
перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed`
|
||||
или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать
|
||||
сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался
|
||||
`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести
|
||||
заметку, не меняя статус (как для отменённой, см. `hub-api`).
|
||||
|
||||
**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб
|
||||
SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают; `ensure_status` — статус равен последнему
|
||||
в пути; `ensure_link` — связь есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment`
|
||||
и `refresh` по состоянию не подтверждаются: потерянный отчёт по ним закрывается повторной выдачей (агент находит
|
||||
созданную задачу поиском дубля) или отменой человеком.
|
||||
|
||||
#### Scenario: Отчёт потерян, факт на месте
|
||||
- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2»
|
||||
- **THEN** команда `confirmed_by_state`
|
||||
|
||||
#### Scenario: Ответ на claim потерян
|
||||
- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim`
|
||||
- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40
|
||||
|
||||
#### Scenario: Предел выдач
|
||||
- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42
|
||||
- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42
|
||||
|
||||
#### Scenario: Потерянный отчёт о создании задачи
|
||||
- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim`
|
||||
- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй
|
||||
задачи нет, заметка `new:5` перенесена на ключ
|
||||
122
openspec/changes/bridge-hygiene/specs/data-model/spec.md
Normal file
122
openspec/changes/bridge-hygiene/specs/data-model/spec.md
Normal file
@ -0,0 +1,122 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Схема базы хаба
|
||||
База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_note`, `batch`, `command`,
|
||||
`sync_run`, `agent_status`, `setting`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру версии
|
||||
в `setting.schema_version` (текущая — 2). Миграция существующей базы SHALL выполняться в одной транзакции с блокировкой
|
||||
записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в
|
||||
`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни
|
||||
открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их
|
||||
при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и
|
||||
дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени).
|
||||
|
||||
#### Scenario: Первый запуск
|
||||
- **WHEN** хаб стартует с несуществующим файлом базы
|
||||
- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700`
|
||||
|
||||
#### Scenario: Миграция базы v1
|
||||
- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed`
|
||||
- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); данные на месте;
|
||||
`schema_version = 2`
|
||||
|
||||
#### Scenario: Два соединения к базе v1
|
||||
- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения
|
||||
- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1`
|
||||
|
||||
#### Scenario: Открытый каталог данных
|
||||
- **WHEN** хаб стартует, каталог данных существует с правами `0755`
|
||||
- **THEN** права каталога и `backups/` — `0700`
|
||||
|
||||
### Requirement: Зеркало задачи
|
||||
Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki),
|
||||
`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список),
|
||||
`sprint`, `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link`
|
||||
SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается
|
||||
на `target` (у `source` в GET это `outwardIssue`). Обновлять `issue` и `issue_link` SHALL только загрузка состояния от
|
||||
агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Полный прогон (`refresh` по базовой JQL), завершившийся
|
||||
`done`, SHALL помечать `neighbor = 1` задачи, не обновлённые им, — только если прогон загрузил хотя бы одну задачу.
|
||||
|
||||
#### Scenario: Загрузка состояния заменяет связи
|
||||
- **WHEN** агент прислал состояние задачи X с двумя связями
|
||||
- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные
|
||||
со стороны X, не трогаются до загрузки их источника
|
||||
|
||||
#### Scenario: Соседняя задача
|
||||
- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL
|
||||
- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0`
|
||||
|
||||
#### Scenario: Пустой полный прогон
|
||||
- **WHEN** полный `refresh` завершился `done`, загрузив 0 задач
|
||||
- **THEN** признак `neighbor` задач зеркала не меняется
|
||||
|
||||
### Requirement: Служебные заметки
|
||||
Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:<n>`),
|
||||
`fact_status`, `work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`. Обновление зеркала
|
||||
MUST NOT менять `issue_note`. При завершении `ensure_issue` с временным ключом хаб SHALL перенести заметку на настоящий
|
||||
ключ — в том числе по позднему отчёту `done` с ключом о команде, отменённой или переведённой в `failed` до отчёта. Если
|
||||
под настоящим ключом заметка уже есть, хаб SHALL слить их: каждое поле берётся из временной заметки, если там оно
|
||||
непустое, иначе остаётся под настоящим ключом; `updated_at`/`updated_by` — от более поздней; временная строка удаляется.
|
||||
|
||||
#### Scenario: Заметка переживает refresh
|
||||
- **WHEN** у задачи есть `issue_note.fact_status = "В работе"` и приходит полное обновление зеркала
|
||||
- **THEN** `fact_status` не изменился
|
||||
|
||||
#### Scenario: Слияние с заметкой под ключом
|
||||
- **WHEN** у `new:5` заметка `work_kind = "Аналитика"`, `owner_note` пусто; у `AURORA-1640` заметка `owner_note = "спросить ИБ"`;
|
||||
`ensure_issue` для `new:5` завершилась с `AURORA-1640`
|
||||
- **THEN** у `AURORA-1640` `work_kind = "Аналитика"` и `owner_note = "спросить ИБ"`; строки `new:5` нет
|
||||
|
||||
### Requirement: Пакеты и команды
|
||||
Таблица `batch` SHALL хранить `id`, `title`, `author`, `created_at`, `cancelled_at`, `estimate_seconds`.
|
||||
Таблица `command` SHALL хранить `id`, `batch_id`, `seq`, `type`, `params` (JSON), `status`
|
||||
(`queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`), `claimed_at` (время последней выдачи),
|
||||
`deliveries` (сколько раз команда выдана агенту), `finished_at`, `result` (JSON), `error` (до 1500 символов), `jira_calls`,
|
||||
`rate_limited` (число 429/503), `pause_seconds` (выданная пауза), `depends_on` (id команды, чей результат подставляется).
|
||||
Статус пакета SHALL вычисляться из команд и `cancelled_at`, не храниться: есть команды не в терминальном статусе —
|
||||
«выполняется» (или «в очереди», если все `queued`); все терминальные и пакет отменён (`cancelled_at`) — «отменён»;
|
||||
все терминальные без отмены — «завершён».
|
||||
|
||||
#### Scenario: Статус пакета
|
||||
- **WHEN** в пакете 3 команды: `done`, `claimed`, `queued`
|
||||
- **THEN** статус пакета «выполняется»; когда все в терминальных статусах — «завершён», с числом `failed`/`skipped`
|
||||
|
||||
#### Scenario: Отменённый пакет с выполненной командой
|
||||
- **WHEN** в пакете команда 1 `done`, команда 2 `claimed`, команда 3 `queued`; пакет отменён, затем агент отчитался по 2
|
||||
- **THEN** статус пакета «отменён» (`cancelled`), счётчики показывают две `done` и одну `cancelled`
|
||||
|
||||
### Requirement: Журнал синхронизаций и статус агента
|
||||
Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`,
|
||||
`issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. При повторной выдаче команды `refresh` хаб
|
||||
SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started_at` не меняется); при переводе `refresh` в `failed`
|
||||
по пределу выдач — проставить `finished_at`. Таблица `agent_status` (одна строка) SHALL хранить `last_seen_at`,
|
||||
`agent_version`, `settings_version`, `current_command_id`, `last_jira_call_at`. Через API и MCP `agent_status` SHALL
|
||||
отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и списком `redelivered` — команд в
|
||||
`claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, `deliveries`).
|
||||
|
||||
#### Scenario: Агент давно не выходил на связь
|
||||
- **WHEN** `last_seen_at` старше 5 минут
|
||||
- **THEN** `agent_status` через API и MCP отдаётся с `online = false`
|
||||
|
||||
#### Scenario: Довыдача refresh не удваивает счётчики
|
||||
- **WHEN** `refresh` загрузил 150 задач, отчёт потерян, команда выдана снова и загрузила те же 150
|
||||
- **THEN** у её `sync_run` `issues_count = 150`
|
||||
|
||||
#### Scenario: refresh сдался по пределу выдач
|
||||
- **WHEN** `refresh` переведён в `failed` «агент не отчитался после 3 выдач»
|
||||
- **THEN** у его `sync_run` проставлен `finished_at`
|
||||
|
||||
### Requirement: Настройки
|
||||
Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`,
|
||||
`transitions_map`, `pacing`, `board_id`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой
|
||||
правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`;
|
||||
относительный путь и не-строка при записи — 422; агенту не передаётся). Спринты агент читает с доски `board_id`,
|
||||
отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json`
|
||||
командой `jira-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать.
|
||||
|
||||
#### Scenario: Правка настроек поднимает версию
|
||||
- **WHEN** через API изменён `pacing`
|
||||
- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию
|
||||
|
||||
#### Scenario: Неверный relay_dir
|
||||
- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"`
|
||||
- **THEN** 422, значение не записано, `settings_version` не изменился
|
||||
170
openspec/changes/bridge-hygiene/specs/hub-api/spec.md
Normal file
170
openspec/changes/bridge-hygiene/specs/hub-api/spec.md
Normal file
@ -0,0 +1,170 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Процесс и доступ
|
||||
`jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в
|
||||
`~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты SHALL требовать `Authorization: Bearer <token>`:
|
||||
агентский токен открывает только `/agent/*`, клиентский — всё остальное. Ответы — JSON, ошибки — `{error, detail}` с
|
||||
кодами 400 (неверное тело или `Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка);
|
||||
реле общей папки дополнительно отвечает 502. Методы, кроме GET, POST и PUT, отвечают 501 стандартной страницей
|
||||
`http.server`, не JSON.
|
||||
|
||||
#### Scenario: Чужой токен
|
||||
- **WHEN** агентским токеном вызван `GET /issues`
|
||||
- **THEN** 401
|
||||
|
||||
#### Scenario: Неподдерживаемый метод
|
||||
- **WHEN** `DELETE /issues/AURORA-1`
|
||||
- **THEN** 501, тело не JSON; база не меняется
|
||||
|
||||
### Requirement: Маршруты агента
|
||||
Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном):
|
||||
- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at}` — долгий запрос до 30 с; ответ `{command:
|
||||
{id, type, params, pause_seconds}, settings_version}` или `{command: null, settings_version}`; обновляет `agent_status`
|
||||
(`last_jira_call_at = null` не затирает прежнее значение). Если есть команда в `claimed`, `claim` SHALL выдать её снова
|
||||
(повторная выдача, см. `commands`), а не следующую из очереди.
|
||||
- `GET /agent/settings` — настройки для агента (без токенов).
|
||||
- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`.
|
||||
- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный
|
||||
отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по
|
||||
`ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N`
|
||||
переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до
|
||||
1500 символов.
|
||||
- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния
|
||||
пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая
|
||||
пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются.
|
||||
- `GET /agent/known-keys` — ключи зеркала (для определения соседей).
|
||||
|
||||
#### Scenario: Выдача с подстановкой ключа
|
||||
- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590`
|
||||
- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах
|
||||
|
||||
#### Scenario: Повторный отчёт
|
||||
- **WHEN** агент дважды прислал `result` для команды 15
|
||||
- **THEN** второй ответ 200, статус и результат не изменились
|
||||
|
||||
#### Scenario: Отчёт по невыданной команде
|
||||
- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued`
|
||||
- **THEN** 409 `not_claimed`, команда осталась `queued`
|
||||
|
||||
#### Scenario: Длинная ошибка
|
||||
- **WHEN** агент прислал `failed` с `error` длиной 5000 символов
|
||||
- **THEN** в `command.error` первые 1500 символов
|
||||
|
||||
#### Scenario: Поздний отчёт после предела выдач
|
||||
- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент
|
||||
прислал `done` с `result.key = AURORA-1630`
|
||||
- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630`
|
||||
|
||||
### Requirement: Маршруты интерфейсов
|
||||
Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном:
|
||||
- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`,
|
||||
`q` (подстрока темы), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок;
|
||||
сокращение до карточки — дело интерфейса.
|
||||
- `GET /issues/{key}` — задача целиком: поля, связи в обе стороны, заметки, последние 10 команд по ней.
|
||||
- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела).
|
||||
- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных
|
||||
параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с
|
||||
одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы).
|
||||
- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды
|
||||
пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены
|
||||
агентом; остановить — отмена команды с `force`).
|
||||
- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N».
|
||||
- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`.
|
||||
- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`.
|
||||
- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`).
|
||||
|
||||
#### Scenario: Фильтр по спринту
|
||||
- **WHEN** `GET /issues?sprint=Спринт 2`
|
||||
- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу
|
||||
|
||||
#### Scenario: Отмена взятой команды
|
||||
- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed`
|
||||
- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется
|
||||
|
||||
#### Scenario: Поздний отчёт по отменённому созданию
|
||||
- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620`
|
||||
- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620`
|
||||
|
||||
#### Scenario: Дубль темы в пакете
|
||||
- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"`
|
||||
- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан
|
||||
|
||||
#### Scenario: Тема не строка
|
||||
- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}`
|
||||
- **THEN** 422 с указанием параметра `summary`, пакет не создан
|
||||
|
||||
#### Scenario: Отмена пакета с командой у агента
|
||||
- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel`
|
||||
- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed`
|
||||
|
||||
### Requirement: Автообновление зеркала
|
||||
Если `auto_refresh_minutes > 0` и агент на связи, хаб SHALL поставить пакет «Автообновление» из `refresh {jql: base_jql}`,
|
||||
когда в очереди нет команд в `queued` и `claimed` и последний `sync_run` начат раньше, чем `auto_refresh_minutes` назад
|
||||
(или `sync_run` ещё нет).
|
||||
|
||||
#### Scenario: Очередь занята
|
||||
- **WHEN** интервал прошёл, но в очереди есть `queued` команды
|
||||
- **THEN** автообновление не ставится до опустошения очереди
|
||||
|
||||
#### Scenario: Есть выданная команда без отчёта
|
||||
- **WHEN** интервал прошёл, `queued` нет, одна команда в `claimed`
|
||||
- **THEN** автообновление не ставится; команда довыдаётся при следующем `claim`
|
||||
|
||||
#### Scenario: Недавнее обновление
|
||||
- **WHEN** очередь пуста, `auto_refresh_minutes = 60`, последний `sync_run` начат 20 минут назад
|
||||
- **THEN** автообновление не ставится
|
||||
|
||||
### Requirement: Реле через общую папку
|
||||
`jira-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле
|
||||
бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если
|
||||
каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует —
|
||||
записать одно предупреждение в лог и проверять снова.
|
||||
|
||||
Обмен:
|
||||
- запрос агента — файл `req/<id>.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC
|
||||
(ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать;
|
||||
- реле SHALL брать запрос переименованием в `work/<id>.json` и обрабатывать каждый взятый запрос в отдельном потоке;
|
||||
- запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до
|
||||
`deadline` осталось не меньше 35 с, иначе выбрасываться без ответа;
|
||||
- остальные запросы реле SHALL пересылать в свой HTTP на `<host>:<port>` процесса `serve` (`127.0.0.1`, если `serve`
|
||||
слушает `0.0.0.0`) тем же методом, путём и телом, с заголовком `Authorization` из файла, и записывать ответ
|
||||
`resp/<id>.json` = `{id, status, body}` (тело — строка ответа HTTP) через временный файл с именем на точку и
|
||||
переименование; затем удалять `work/<id>.json`;
|
||||
- если соединение со своим HTTP не удалось или ответ нарушает протокол HTTP, ответ SHALL иметь `status = 502` и текст
|
||||
ошибки в `body`;
|
||||
- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог.
|
||||
|
||||
При первой подготовке каждой папки после старта реле (в том числе новой папки после смены `relay_dir`) реле SHALL
|
||||
удалить файлы из `work/` с записью в лог. Повторная подготовка той же папки (пропал подкаталог, не было доступа) SHALL
|
||||
только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту
|
||||
реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку
|
||||
старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay <METHOD> <path> <status>
|
||||
<секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются.
|
||||
|
||||
#### Scenario: Запрос через папку
|
||||
- **WHEN** в `req/` появился `a1.json` с `GET /agent/settings`, агентским токеном и `deadline` через 60 с
|
||||
- **THEN** в `resp/` появляется `a1.json` со `status = 200` и телом настроек; `work/a1.json` удалён
|
||||
|
||||
#### Scenario: Чужой токен через папку
|
||||
- **WHEN** в файле запроса `GET /issues` с агентским токеном
|
||||
- **THEN** ответ со `status = 401`, как по HTTP
|
||||
|
||||
#### Scenario: Просроченный claim
|
||||
- **WHEN** реле взяло `POST /agent/claim`, до `deadline` которого осталось 20 с
|
||||
- **THEN** запрос выброшен без ответа и без обращения к хабу; команда в очереди осталась `queued`
|
||||
|
||||
#### Scenario: Долгий claim не задерживает другие запросы
|
||||
- **WHEN** реле пересылает `claim`, который ждёт команду 30 с, и в это время в `req/` появляется `GET /agent/settings`
|
||||
- **THEN** ответ на `settings` пишется, не дожидаясь `claim`
|
||||
|
||||
#### Scenario: Папка не подключена
|
||||
- **WHEN** `relay_dir` задан, но каталога нет; через минуту каталог появился
|
||||
- **THEN** в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы
|
||||
|
||||
#### Scenario: Сбой протокола своего HTTP
|
||||
- **WHEN** при пересылке запроса свой HTTP оборвал ответ (`http.client.HTTPException`)
|
||||
- **THEN** в `resp/` ответ со `status = 502` и текстом ошибки; реле продолжает работу
|
||||
|
||||
#### Scenario: Повторная подготовка не трогает взятые запросы
|
||||
- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/`
|
||||
- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «удалён незавершённый запрос»
|
||||
39
openspec/changes/bridge-hygiene/specs/mcp/spec.md
Normal file
39
openspec/changes/bridge-hygiene/specs/mcp/spec.md
Normal file
@ -0,0 +1,39 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Инструменты
|
||||
| Инструмент | Параметры | Результат |
|
||||
|---|---|---|
|
||||
| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту |
|
||||
| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды |
|
||||
| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки |
|
||||
| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации |
|
||||
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
|
||||
| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту |
|
||||
| `batches` | `limit` | последние пакеты со статусом |
|
||||
| `command_retry` | `id` | новый пакет |
|
||||
| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» |
|
||||
| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` |
|
||||
| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек |
|
||||
| `sync_log` | `limit` | последние `sync_run` |
|
||||
| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` |
|
||||
|
||||
Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой
|
||||
проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала.
|
||||
|
||||
Автор команд из MCP SHALL быть `claude`; из API без автора — `api`.
|
||||
|
||||
#### Scenario: Пакет из чата
|
||||
- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}`
|
||||
- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах
|
||||
|
||||
#### Scenario: Ожидание результата
|
||||
- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут
|
||||
- **THEN** ответ через 6 минут со статусами всех команд
|
||||
|
||||
#### Scenario: Статус агента с довыдачей
|
||||
- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта
|
||||
- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2
|
||||
|
||||
#### Scenario: Отмена пакета с командой у агента
|
||||
- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed`
|
||||
- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …»
|
||||
24
openspec/changes/bridge-hygiene/specs/migration/spec.md
Normal file
24
openspec/changes/bridge-hygiene/specs/migration/spec.md
Normal file
@ -0,0 +1,24 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Импорт CSV-пакета
|
||||
`jira-hub import <папка admin/jira-sync>` SHALL прочитать `tasks.csv`, `new_tasks.csv`, `epics.csv` и заполнить
|
||||
`issue_note` по ключу Jira: статус по факту, вид работы, что сделано, что осталось, внутренние колонки. Строки
|
||||
`new_tasks.csv` без ключа SHALL получать временный ключ `new:<номер строки>`; строки `tasks.csv` и `epics.csv` без ключа
|
||||
SHALL пропускаться. `links.csv` SHALL импортироваться как сверочный набор в отчёт импорта (ожидаемые связи против зеркала
|
||||
после первого `refresh`), не как команды. Связь, которой в зеркале нет как `source→target` того же типа, но есть как
|
||||
`target→source` того же типа, SHALL попадать в `reversed_links`, а не в `missing_links`. Импорт идемпотентен: повтор
|
||||
обновляет заметки, не дублирует.
|
||||
|
||||
#### Scenario: Импорт и первая синхронизация
|
||||
- **WHEN** выполнены `init`, `import`, запущены хаб и агент, `refresh full` завершён
|
||||
- **THEN** число задач зеркала ≥ числа строк `tasks.csv` + созданных из `new_tasks.csv`; отчёт импорта перечисляет ключи
|
||||
из CSV, которых нет в зеркале, связи из `links.csv`, которых нет в `issue_link`, и связи, которые есть в обратную сторону
|
||||
|
||||
#### Scenario: Связь в обратную сторону
|
||||
- **WHEN** в `links.csv` строка «AURORA-1445 → AURORA-1446, связанные», а в зеркале есть только «ASUPR_Связанные»
|
||||
1446→1445
|
||||
- **THEN** связь в `reversed_links`, в `missing_links` её нет
|
||||
|
||||
#### Scenario: Эпик без ключа
|
||||
- **WHEN** в `epics.csv` строка без значения в колонке «Ключ»
|
||||
- **THEN** строка пропущена, заметка `new:*` для неё не создаётся
|
||||
@ -1,7 +1,7 @@
|
||||
# agent Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change bridge-core. Update Purpose after archive.
|
||||
Агент моста `jira-agent`: один файл на Python 3.9 (только stdlib) на виртуалке банка. Получает команды хаба (по HTTP или через общую папку), выполняет их в Jira по правилу «проверь, потом сделай» с политикой темпа, ведёт журнал на диске для восстановления после сбоев и возвращает хабу результаты и состояние задач. Учётные данные Jira есть только у агента.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# commands Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change bridge-core. Update Purpose after archive.
|
||||
Контракт команд моста: каждая команда — желаемое состояние одной задачи Jira (`ensure_*`) или чтение состояния (`refresh`). Здесь типы команд и их параметры, временные ключи внутри пакета, порядок выполнения, политика темпа, обработка ошибок, повторная выдача и подтверждение по состоянию зеркала.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# data-model Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change bridge-core. Update Purpose after archive.
|
||||
Модель данных хаба: один файл SQLite с зеркалом задач Jira, служебными заметками, пакетами и командами, журналом синхронизаций, статусом агента и настройками. Здесь таблицы, их смысл, правила записи (зеркало пишет только загрузка состояния от агента) и миграции схемы.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# hub-api Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change bridge-core. Update Purpose after archive.
|
||||
HTTP API хаба `jira-hub serve`: маршруты агента (выдача команд, отчёты, загрузка состояния), маршруты интерфейсов (зеркало, заметки, пакеты, настройки), резервные копии, автообновление зеркала и реле общей папки для агента за VPN.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# mcp Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change bridge-core. Update Purpose after archive.
|
||||
MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструменты чтения зеркала и заметок, постановки и отслеживания пакетов команд, обновления зеркала, статуса агента и настроек — поверх HTTP API хаба с клиентским токеном.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# migration Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change bridge-core. Update Purpose after archive.
|
||||
Переход с CSV-пакета `admin/jira-sync` на мост: начальные настройки хаба из `config.example.json`, разовый импорт служебных заметок и сверочного набора связей из CSV, и что остаётся в `admin/` как история.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
from . import backup, db, mirror, queue, relay, settings
|
||||
from .commands import CommandError
|
||||
from .paths import backups_dir as default_backups_dir
|
||||
from .paths import secure_dir
|
||||
from .queue import ClaimedError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@ -33,6 +34,7 @@ class Hub:
|
||||
self.db_path = Path(db_path)
|
||||
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
|
||||
self.claim_wait = 30
|
||||
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
|
||||
conn = self.conn()
|
||||
settings.ensure_defaults(conn)
|
||||
conn.close()
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from . import paths
|
||||
|
||||
|
||||
def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path:
|
||||
backups_dir = Path(backups_dir)
|
||||
backups_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths.secure_dir(backups_dir)
|
||||
target = backups_dir / f"{datetime.now():%Y%m%d-%H%M%S-%f}-{label}.sqlite"
|
||||
conn.execute("VACUUM INTO ?", (str(target),))
|
||||
# копии перед миграцией схемы (db.migrate) — для отката, ротации не подлежат
|
||||
|
||||
@ -22,6 +22,8 @@ SPEC = {
|
||||
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]},
|
||||
}
|
||||
WRITE_TYPES = [t for t in SPEC if t != "refresh"]
|
||||
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
|
||||
BOOL_PARAMS = {"replace"}
|
||||
|
||||
|
||||
class CommandError(ValueError):
|
||||
@ -49,6 +51,23 @@ def temp_keys_used(cmd) -> list[str]:
|
||||
return [k for k in referenced_keys(cmd) if is_temp(k) and k != own]
|
||||
|
||||
|
||||
def _type_error(ctype, name, value) -> str | None:
|
||||
"""Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500.
|
||||
null допустим везде — обязательность проверяется отдельно."""
|
||||
if value is None:
|
||||
return None
|
||||
if name in LIST_PARAMS:
|
||||
ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк"
|
||||
elif name in BOOL_PARAMS:
|
||||
ok, want = isinstance(value, bool), "true или false"
|
||||
elif ctype == "ensure_status" and name == "target":
|
||||
ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value))
|
||||
want = "строка или список строк"
|
||||
else:
|
||||
ok, want = isinstance(value, str), "строка"
|
||||
return None if ok else f"{name} — {want}"
|
||||
|
||||
|
||||
def validate(commands) -> list[dict]:
|
||||
if not isinstance(commands, list) or not commands:
|
||||
raise CommandError("пакет без команд")
|
||||
@ -68,6 +87,9 @@ def validate(commands) -> list[dict]:
|
||||
unknown = set(params) - set(spec["required"]) - set(spec["optional"])
|
||||
if unknown:
|
||||
raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}")
|
||||
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
|
||||
if bad:
|
||||
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
|
||||
if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]):
|
||||
raise CommandError(f"команда {i}: ensure_fields без полей")
|
||||
if ctype == "refresh" and not (params.get("keys") or params.get("jql")):
|
||||
|
||||
@ -6,7 +6,7 @@ from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from . import backup
|
||||
from . import backup, paths
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
@ -117,8 +117,8 @@ def connect(path) -> sqlite3.Connection:
|
||||
"""Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных
|
||||
операций — transaction()."""
|
||||
is_memory = str(path) == ":memory:"
|
||||
if not is_memory:
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
if not is_memory and Path(path).parent != Path("."): # путь без каталога — рабочий каталог процесса не трогаем
|
||||
paths.secure_dir(Path(path).parent)
|
||||
conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
if not is_memory:
|
||||
@ -134,22 +134,30 @@ def _schema_version(conn) -> int | None:
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection, path=None) -> None:
|
||||
"""Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Перед миграцией файловой базы —
|
||||
копия в <каталог базы>/backups (без ротации). Параллельный connect безопасен: версия перечитывается в транзакции."""
|
||||
"""Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы
|
||||
(<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный
|
||||
connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
|
||||
отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции.
|
||||
Вызывается только вне транзакции."""
|
||||
conn.executescript(SCHEMA)
|
||||
version = _schema_version(conn)
|
||||
if version is None:
|
||||
conn.execute("INSERT INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),))
|
||||
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),))
|
||||
elif version < SCHEMA_VERSION:
|
||||
if path is not None:
|
||||
backup.make_backup(conn, Path(path).parent / "backups", f"schema-v{version}", keep=0)
|
||||
with transaction(conn):
|
||||
current = _schema_version(conn)
|
||||
while current < SCHEMA_VERSION:
|
||||
for sql in MIGRATIONS[current]:
|
||||
conn.execute(sql)
|
||||
current += 1
|
||||
conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),))
|
||||
if current < SCHEMA_VERSION:
|
||||
if path is not None:
|
||||
reader = sqlite3.connect(str(path), timeout=10)
|
||||
try:
|
||||
backup.make_backup(reader, Path(path).parent / "backups", f"schema-v{current}", keep=0)
|
||||
finally:
|
||||
reader.close()
|
||||
while current < SCHEMA_VERSION:
|
||||
for sql in MIGRATIONS[current]:
|
||||
conn.execute(sql)
|
||||
current += 1
|
||||
conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),))
|
||||
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
||||
|
||||
|
||||
|
||||
@ -91,6 +91,9 @@ def fmt_batch(b) -> str:
|
||||
for c in b.get("commands", []):
|
||||
lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}"
|
||||
+ (f" ({c['error']})" if c.get("error") else "") + (f" {json.dumps(c['result'], ensure_ascii=False)}" if c.get("result") else ""))
|
||||
for s in b.get("still_claimed") or []:
|
||||
lines.append(f" команда #{s['id']} уже у агента и будет выполнена; остановить — "
|
||||
f"POST /commands/{s['id']}/cancel с force: true")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@ -142,7 +145,9 @@ def notes_set(key: str, fact_status: str | None = None, work_kind: str | None =
|
||||
@guard
|
||||
def batch_create(title: str, commands: list[dict]) -> str:
|
||||
"""Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status|
|
||||
ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам."""
|
||||
ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам.
|
||||
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
|
||||
задача без неё выпадет из базовой JQL и из зеркала."""
|
||||
b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands})
|
||||
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
|
||||
|
||||
@ -186,7 +191,8 @@ def command_retry(id: int) -> str:
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def batch_cancel(id: int) -> str:
|
||||
"""Отменить пакет (только команды в очереди; взятую агентом — command_cancel с force через API)."""
|
||||
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
|
||||
в ответе подсказка (POST /commands/N/cancel с force)."""
|
||||
return fmt_batch(_client().post(f"/batches/{id}/cancel", {}))
|
||||
|
||||
|
||||
|
||||
@ -112,12 +112,21 @@ def set_note(conn, key, fields: dict, updated_by: str) -> dict:
|
||||
|
||||
|
||||
def rename_note(conn, old_key, new_key) -> None:
|
||||
"""После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом,
|
||||
если вдруг была, уступает место)."""
|
||||
"""После создания задачи: заметка с временного ключа переезжает на настоящий. Если под настоящим ключом заметка уже
|
||||
есть — слияние (M-3 #4): поле временной заметки побеждает, если оно непустое; updated_at/updated_by — от более
|
||||
поздней; временная строка удаляется."""
|
||||
if old_key == new_key:
|
||||
return
|
||||
with db.transaction(conn):
|
||||
if get_note(conn, old_key) is None:
|
||||
old = get_note(conn, old_key)
|
||||
if old is None:
|
||||
return
|
||||
conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,))
|
||||
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))
|
||||
cur = get_note(conn, new_key)
|
||||
if cur is None:
|
||||
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))
|
||||
return
|
||||
merged = [old[f] if old[f] not in (None, "") else cur[f] for f in NOTE_FIELDS]
|
||||
newer = old if (old["updated_at"] or "") >= (cur["updated_at"] or "") else cur
|
||||
conn.execute(f"UPDATE issue_note SET {', '.join(f'{f} = ?' for f in NOTE_FIELDS)}, updated_at = ?, updated_by = ? "
|
||||
"WHERE key = ?", merged + [newer["updated_at"], newer["updated_by"], new_key])
|
||||
conn.execute("DELETE FROM issue_note WHERE key = ?", (old_key,))
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
"""Где лежат данные хаба: каталог, база, резервные копии."""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def home() -> Path:
|
||||
return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub")))
|
||||
@ -13,3 +16,14 @@ def db_path() -> Path:
|
||||
|
||||
def backups_dir() -> Path:
|
||||
return home() / "backups"
|
||||
|
||||
|
||||
def secure_dir(path: Path) -> None:
|
||||
"""Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) —
|
||||
предупреждение, хаб продолжает работу."""
|
||||
path = Path(path)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(path, 0o700)
|
||||
except OSError as e:
|
||||
log.warning("каталог %s не закрыт (0700): %s", path, e)
|
||||
|
||||
@ -13,6 +13,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"}
|
||||
MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2)
|
||||
ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands)
|
||||
ONLINE_MINUTES = 5
|
||||
|
||||
|
||||
@ -71,7 +72,7 @@ def _status_of(cmds, cancelled_at=None) -> str:
|
||||
if not sts:
|
||||
return "done"
|
||||
if all(s in TERMINAL for s in sts):
|
||||
return "cancelled" if cancelled_at and all(s == "cancelled" for s in sts) else "done"
|
||||
return "cancelled" if cancelled_at else "done" # отменённый пакет — отменён, даже с частью done (T5.6)
|
||||
if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts):
|
||||
return "running"
|
||||
return "queued"
|
||||
@ -148,6 +149,8 @@ def _redeliver(conn, now) -> dict | None:
|
||||
return cmd
|
||||
conn.execute("UPDATE command SET status = 'failed', finished_at = ?, error = ? WHERE id = ?",
|
||||
(now, f"агент не отчитался после {MAX_DELIVERIES} выдач", cmd["id"]))
|
||||
if cmd["type"] == "refresh": # загрузка закончилась ничем — журнал синхронизаций закрыт (M-4 #12)
|
||||
conn.execute("UPDATE sync_run SET finished_at = ? WHERE command_id = ?", (now, cmd["id"]))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (cmd["id"],))
|
||||
log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES)
|
||||
|
||||
@ -164,6 +167,10 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non
|
||||
pause = int(settings.pacing(conn).get(cmd["type"], 0))
|
||||
conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?",
|
||||
(now, pause, cmd["id"]))
|
||||
if cmd["type"] == "refresh":
|
||||
# повторное выполнение загрузит то же заново: счётчики с нуля; started_at — граница пометки соседей,
|
||||
# не сдвигается (M-5 #12)
|
||||
conn.execute("UPDATE sync_run SET issues_count = 0, links_count = 0 WHERE command_id = ?", (cmd["id"],))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],))
|
||||
log.info("команда %s выдана снова (выдача %s)", cmd["id"], cmd["deliveries"] + 1)
|
||||
else:
|
||||
@ -212,6 +219,7 @@ def record_state(conn, command_id, issues_count, links_count) -> None:
|
||||
def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict:
|
||||
if status not in ("done", "failed"):
|
||||
raise ValueError("status — done или failed")
|
||||
error = None if error is None else str(error)[:ERROR_LIMIT]
|
||||
with db.transaction(conn):
|
||||
cmd = get_command(conn, command_id)
|
||||
if cmd is None:
|
||||
@ -219,15 +227,17 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
||||
if cmd["status"] == "queued":
|
||||
raise NotClaimedError(f"команда {command_id} не выдана")
|
||||
if cmd["status"] in TERMINAL:
|
||||
# поздний отчёт о созданной задаче по отменённой команде: ключ не теряем, статус не трогаем (M-5)
|
||||
if (cmd["status"] == "cancelled" and cmd["type"] == "ensure_issue" and status == "done"
|
||||
# поздний отчёт о созданной задаче по команде, которую хаб уже закрыл (force-отмена — M-5, предел выдач —
|
||||
# M-4 #12): ключ не теряем, статус не трогаем
|
||||
if (cmd["status"] in ("cancelled", "failed") and cmd["type"] == "ensure_issue" and status == "done"
|
||||
and result and result.get("key") and not cmd["result"]):
|
||||
conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
|
||||
(_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
|
||||
log.warning("команда %s отменена, но агент создал %s — ключ записан в результат", command_id, result["key"])
|
||||
log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат",
|
||||
command_id, cmd["status"], result["key"])
|
||||
return get_command(conn, command_id)
|
||||
return cmd # идемпотентно; cancelled не затирается
|
||||
return cmd # идемпотентно; терминальный статус (cancelled/failed) не затирается
|
||||
now = db.now()
|
||||
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
|
||||
(status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||
@ -239,9 +249,10 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
||||
conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?",
|
||||
(now, int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||
if status == "done" and is_full_refresh(conn, command_id):
|
||||
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед
|
||||
run = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (command_id,)).fetchone()
|
||||
if run:
|
||||
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
|
||||
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)
|
||||
run = conn.execute("SELECT started_at, issues_count FROM sync_run WHERE command_id = ?", (command_id,)).fetchone()
|
||||
if run and run["issues_count"] > 0:
|
||||
conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],))
|
||||
if cmd["type"] == "refresh" and status == "done":
|
||||
reconcile(conn)
|
||||
@ -274,9 +285,14 @@ def cancel_batch(conn, batch_id) -> dict:
|
||||
if conn.execute("SELECT 1 FROM batch WHERE id = ?", (batch_id,)).fetchone() is None:
|
||||
raise KeyError(batch_id)
|
||||
now = db.now()
|
||||
conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL", (now, batch_id))
|
||||
# завершённый пакет (нет queued/claimed) отменённым не становится: отменять нечего
|
||||
conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL AND EXISTS "
|
||||
"(SELECT 1 FROM command WHERE batch_id = ? AND status IN ('queued', 'claimed'))", (now, batch_id, batch_id))
|
||||
conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE batch_id = ? AND status = 'queued'", (now, batch_id))
|
||||
return batch_view(conn, batch_id)
|
||||
view = batch_view(conn, batch_id)
|
||||
# взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12)
|
||||
view["still_claimed"] = [{"id": c["id"], "type": c["type"]} for c in view["commands"] if c["status"] == "claimed"]
|
||||
return view
|
||||
|
||||
|
||||
def cancel_command(conn, command_id, force=False) -> dict:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Реле через общую папку (specs/hub-api, «Реле через общую папку»): файлы-запросы агента из <relay_dir>/req/
|
||||
пересылаются в HTTP этого же хаба, ответы кладутся в <relay_dir>/resp/. HTTP API, обработчики и токены не меняются;
|
||||
реле для них — ещё один клиент. Нужно, когда VPN на виртуалке закрывает сеть до хаба (design.md folder-transport)."""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
@ -60,6 +61,7 @@ class Relay:
|
||||
self._cleanup_at = 0.0
|
||||
self._ready = False
|
||||
self._warned: str | None = None
|
||||
self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле
|
||||
self.workers: list[threading.Thread] = []
|
||||
|
||||
# --- цикл ---------------------------------------------------------------------
|
||||
@ -122,9 +124,15 @@ class Relay:
|
||||
def _prepare(self) -> None:
|
||||
for sub in ("req", "resp", "work"):
|
||||
(self.root / sub).mkdir(exist_ok=True)
|
||||
for stale in (self.root / "work").iterdir():
|
||||
stale.unlink(missing_ok=True)
|
||||
say(f"relay: удалён незавершённый запрос {stale.name} (перезапуск реле)")
|
||||
if self.root not in self._cleaned:
|
||||
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
|
||||
for stale in (self.root / "work").iterdir():
|
||||
stale.unlink(missing_ok=True)
|
||||
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
|
||||
self._cleaned.add(self.root)
|
||||
else:
|
||||
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
|
||||
say(f"relay: папка {self.root} подготовлена заново")
|
||||
self._ready, self._warned = True, None
|
||||
|
||||
def _take(self, path: Path) -> None:
|
||||
@ -193,7 +201,7 @@ class Relay:
|
||||
e.close()
|
||||
except ValueError as e: # недопустимый заголовок (перевод строки) или URL
|
||||
return 400, _error("bad_request", str(e))
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
except (urllib.error.URLError, http.client.HTTPException, OSError) as e:
|
||||
return 502, _error("relay", f"хаб недоступен изнутри: {e}")
|
||||
|
||||
# --- уборка ------------------------------------------------------------------------
|
||||
|
||||
@ -75,6 +75,16 @@ def test_agent_flow(hub):
|
||||
assert c.get("/agent")["online"] is True
|
||||
|
||||
|
||||
def test_cancel_batch_api_reports_still_claimed(hub):
|
||||
c, a = hub["client"], hub["agent"]
|
||||
b = c.post("/batches", {"title": "x", "author": "claude", "commands": [
|
||||
{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}},
|
||||
{"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]})
|
||||
cmd = a.post("/agent/claim", {})["command"]
|
||||
out = c.post("/batches/%d/cancel" % b["id"], {})
|
||||
assert out["still_claimed"] == [{"id": cmd["id"], "type": "ensure_fields"}]
|
||||
|
||||
|
||||
def test_interface_routes(hub):
|
||||
c = hub["client"]
|
||||
with pytest.raises(HubError) as e:
|
||||
|
||||
@ -123,3 +123,29 @@ def test_validate_rejects_duplicate_issue_summary():
|
||||
commands.validate(cmds)
|
||||
assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value)
|
||||
commands.validate([cmds[0], cmds[2]]) # регистр различается — пакет допустим
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd, name", [
|
||||
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": 42}}, "summary"),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": "AWPCA"}}, "labels_add"),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["x", 1]}}, "labels_remove"),
|
||||
({"type": "ensure_fields", "params": {"key": ["A-1"], "duedate": "2026-10-23"}}, "key"),
|
||||
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": {"t": 1}}}, "body"),
|
||||
({"type": "ensure_status", "params": {"key": "A-1", "target": 5}}, "target"),
|
||||
({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", 2]}}, "target"),
|
||||
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": "да"}}, "replace"),
|
||||
({"type": "refresh", "params": {"keys": "A-1"}}, "keys"),
|
||||
])
|
||||
def test_validate_rejects_wrong_types(cmd, name):
|
||||
with pytest.raises(commands.CommandError) as e:
|
||||
commands.validate([cmd])
|
||||
assert name in str(e.value)
|
||||
|
||||
|
||||
def test_validate_accepts_null_for_clearable_and_path_target():
|
||||
out = commands.validate([
|
||||
{"type": "ensure_fields", "params": {"key": "A-1", "assignee": None, "epic_key": None, "duedate": None}},
|
||||
{"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}},
|
||||
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
|
||||
])
|
||||
assert len(out) == 3
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
import threading
|
||||
|
||||
from jira_hub import db
|
||||
from jira_hub import backup, db, paths, queue, settings
|
||||
|
||||
|
||||
def test_connect_creates_schema():
|
||||
@ -110,3 +114,83 @@ def test_nested_transaction_rollback_is_local(tmp_path):
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert [r["title"] for r in conn.execute("SELECT title FROM batch")] == ["outer"]
|
||||
|
||||
|
||||
def _mode(p):
|
||||
return stat.S_IMODE(p.stat().st_mode)
|
||||
|
||||
|
||||
def test_data_dir_is_private(tmp_path):
|
||||
home = tmp_path / "data"
|
||||
home.mkdir()
|
||||
home.chmod(0o755)
|
||||
db.connect(home / "hub.sqlite").close()
|
||||
assert _mode(home) == 0o700
|
||||
|
||||
|
||||
def test_existing_backups_dir_made_private_on_start(tmp_path):
|
||||
from jira_hub import api
|
||||
backups = tmp_path / "backups"
|
||||
backups.mkdir()
|
||||
backups.chmod(0o755)
|
||||
api.Hub(tmp_path / "hub.sqlite", backups_dir=backups)
|
||||
assert _mode(backups) == 0o700
|
||||
|
||||
|
||||
def test_connect_without_directory_does_not_chmod_cwd(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tmp_path.chmod(0o755)
|
||||
db.connect("hub.sqlite").close()
|
||||
assert _mode(tmp_path) == 0o755
|
||||
|
||||
|
||||
def test_backups_dir_is_private(tmp_path):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
backup.make_backup(conn, tmp_path / "backups", "batch-1")
|
||||
conn.close()
|
||||
assert _mode(tmp_path / "backups") == 0o700
|
||||
|
||||
|
||||
def test_secure_dir_failure_only_warns(tmp_path, monkeypatch, caplog):
|
||||
def deny(*a, **k):
|
||||
raise PermissionError("нет прав")
|
||||
monkeypatch.setattr(os, "chmod", deny)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
paths.secure_dir(tmp_path / "x")
|
||||
assert (tmp_path / "x").is_dir() and "0700" in caplog.text
|
||||
|
||||
|
||||
def test_parallel_connect_makes_one_backup(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v1_database(path)
|
||||
barrier, errors = threading.Barrier(4), []
|
||||
|
||||
def go():
|
||||
try:
|
||||
barrier.wait()
|
||||
db.connect(path).close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=go) for _ in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(30)
|
||||
assert errors == []
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrated_claimed_redelivered_end_to_end(tmp_path):
|
||||
"""База v1 с командой в claimed -> миграция (deliveries = 1) -> claim довыдаёт её с deliveries = 2 (M-10 #12)."""
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v1_database(path)
|
||||
conn = db.connect(path)
|
||||
settings.ensure_defaults(conn)
|
||||
cmd = queue.claim(conn)
|
||||
assert cmd["id"] == 1
|
||||
assert queue.get_command(conn, 1)["deliveries"] == 2
|
||||
conn.close()
|
||||
|
||||
@ -189,3 +189,16 @@ def test_batch_wait_does_not_block_event_loop(monkeypatch):
|
||||
|
||||
out, ticks = asyncio.run(scenario())
|
||||
assert "done" in out and len(ticks) == 5
|
||||
|
||||
|
||||
def test_fmt_batch_warns_about_claimed():
|
||||
b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running",
|
||||
"remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [],
|
||||
"still_claimed": [{"id": 30, "type": "ensure_fields"}]}
|
||||
text = mcp_server.fmt_batch(b)
|
||||
assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text
|
||||
|
||||
|
||||
def test_batch_create_mentions_labels():
|
||||
doc = mcp_server.batch_create.__doc__ or ""
|
||||
assert "labels" in doc and "AWPCA" in doc
|
||||
|
||||
@ -101,3 +101,37 @@ def test_set_note_partial_keeps_other_fields():
|
||||
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
|
||||
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
|
||||
assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
|
||||
|
||||
|
||||
def test_rename_note_merges_with_existing():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
|
||||
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
|
||||
mirror.rename_note(conn, "new:5", "A-5")
|
||||
note = mirror.get_note(conn, "A-5")
|
||||
assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает
|
||||
assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом
|
||||
assert note["fact_status"] == "В работе"
|
||||
assert note["updated_by"] == "import" # временная заметка записана позже
|
||||
assert mirror.get_note(conn, "new:5") is None
|
||||
|
||||
|
||||
def test_rename_note_keeps_newer_real_key_stamp():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import")
|
||||
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 = '2030-01-01T00:00:00+00:00' WHERE key = 'A-6'")
|
||||
mirror.rename_note(conn, "new:6", "A-6")
|
||||
note = mirror.get_note(conn, "A-6")
|
||||
assert note["updated_by"] == "human" and note["updated_at"] == "2030-01-01T00:00:00+00:00"
|
||||
assert note["fact_status"] == "В работе" and note["owner_note"] == "позже"
|
||||
|
||||
|
||||
def test_rename_note_empty_string_does_not_overwrite():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
|
||||
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
|
||||
mirror.rename_note(conn, "new:6", "A-6")
|
||||
note = mirror.get_note(conn, "A-6")
|
||||
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано"
|
||||
|
||||
@ -264,7 +264,7 @@ def _two_batches_with_dependent(conn):
|
||||
return a, b, first
|
||||
|
||||
|
||||
def test_fresh_definer_still_blocks_queue():
|
||||
def test_redelivered_definer_blocks_dependents():
|
||||
conn = fresh()
|
||||
a, b, first = _two_batches_with_dependent(conn)
|
||||
assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут
|
||||
@ -379,3 +379,117 @@ def test_late_failed_report_after_force_cancel_changes_nothing():
|
||||
queue.cancel_command(conn, c["id"], force=True)
|
||||
late = queue.report_result(conn, c["id"], "failed", error="HTTP 400")
|
||||
assert late["status"] == "cancelled" and late["result"] is None and late["error"] is None
|
||||
|
||||
|
||||
def _fail_by_limit(conn, cid):
|
||||
for _ in range(queue.MAX_DELIVERIES - 1):
|
||||
assert queue.claim(conn)["id"] == cid
|
||||
queue.claim(conn) # четвёртый claim: cid → failed по пределу
|
||||
assert queue.get_command(conn, cid)["status"] == "failed"
|
||||
|
||||
|
||||
def test_error_is_trimmed_to_1500():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||
c = queue.claim(conn)
|
||||
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
|
||||
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
|
||||
|
||||
|
||||
def test_late_done_after_delivery_limit_keeps_issue_key():
|
||||
conn = fresh()
|
||||
mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import")
|
||||
queue.create_batch(conn, "x", "claude", [
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
|
||||
c = queue.claim(conn)
|
||||
_fail_by_limit(conn, c["id"])
|
||||
late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2)
|
||||
assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True}
|
||||
assert late["error"] == "агент не отчитался после 3 выдач"
|
||||
assert mirror.get_note(conn, "A-30")["fact_status"] == "План" and mirror.get_note(conn, "new:8") is None
|
||||
again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"})
|
||||
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
|
||||
|
||||
|
||||
def test_cancelled_batch_with_done_command_is_cancelled():
|
||||
conn = fresh()
|
||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
|
||||
first = queue.claim(conn)
|
||||
queue.report_result(conn, first["id"], "done", result={"changed": []})
|
||||
second = queue.claim(conn)
|
||||
queue.cancel_batch(conn, b["id"])
|
||||
queue.report_result(conn, second["id"], "done", result={"changed": []})
|
||||
view = queue.batch_view(conn, b["id"])
|
||||
assert view["status"] == "cancelled"
|
||||
assert view["counts"]["done"] == 2
|
||||
|
||||
|
||||
def test_empty_full_refresh_keeps_neighbors():
|
||||
conn = fresh()
|
||||
jql = settings.get(conn, "base_jql")
|
||||
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
|
||||
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
|
||||
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
||||
r = queue.claim(conn)
|
||||
queue.report_result(conn, r["id"], "done") # загрузок состояния не было: 0 задач
|
||||
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
|
||||
|
||||
|
||||
def test_cancel_finished_batch_stays_done():
|
||||
conn = fresh()
|
||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||
while (c := queue.claim(conn)):
|
||||
queue.report_result(conn, c["id"], "done", result={"changed": []})
|
||||
view = queue.cancel_batch(conn, b["id"])
|
||||
assert view["status"] == "done"
|
||||
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():
|
||||
conn = fresh()
|
||||
jql = settings.get(conn, "base_jql")
|
||||
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}}])
|
||||
r = queue.claim(conn)
|
||||
queue.record_state(conn, r["id"], 1, 0)
|
||||
mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], [])
|
||||
assert queue.claim(conn)["id"] == r["id"] # отчёт потерян — довыдача
|
||||
assert conn.execute("SELECT deliveries FROM command WHERE id = ?", (r["id"],)).fetchone()["deliveries"] == 2
|
||||
queue.record_state(conn, r["id"], 1, 0)
|
||||
mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], [])
|
||||
queue.report_result(conn, r["id"], "done")
|
||||
nb = {row["key"]: row["neighbor"] for row in conn.execute("SELECT key, neighbor FROM issue")}
|
||||
assert nb == {"OLD": 1, "NEW": 0}
|
||||
assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1
|
||||
|
||||
|
||||
def test_redelivered_refresh_resets_counts():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
r = queue.claim(conn)
|
||||
queue.record_state(conn, r["id"], 150, 40)
|
||||
started = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["started_at"]
|
||||
assert queue.claim(conn)["id"] == r["id"] # отчёт потерян — довыдача
|
||||
queue.record_state(conn, r["id"], 150, 40)
|
||||
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()
|
||||
assert (run["issues_count"], run["links_count"]) == (150, 40)
|
||||
assert run["started_at"] == started
|
||||
|
||||
|
||||
def test_refresh_failed_by_limit_finishes_sync_run():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
r = queue.claim(conn)
|
||||
for _ in range(queue.MAX_DELIVERIES):
|
||||
queue.claim(conn)
|
||||
assert queue.get_command(conn, r["id"])["status"] == "failed"
|
||||
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
|
||||
|
||||
|
||||
def test_cancel_batch_reports_still_claimed():
|
||||
conn = fresh()
|
||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||
c = queue.claim(conn)
|
||||
view = queue.cancel_batch(conn, b["id"])
|
||||
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
|
||||
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@ -324,3 +326,79 @@ def test_removed_resp_dir_recreated_before_forward(env):
|
||||
put_request(root, "r1", "GET", "/agent/settings", env["agent"])
|
||||
run_once(r)
|
||||
assert response(root, "r1")["status"] == 200
|
||||
|
||||
|
||||
def test_protocol_error_gives_502(env, monkeypatch):
|
||||
r, root = env["relay"], env["root"]
|
||||
r.step()
|
||||
|
||||
def broken(*a, **k):
|
||||
raise http.client.BadStatusLine("мусор")
|
||||
monkeypatch.setattr("urllib.request.urlopen", broken)
|
||||
put_request(root, "b1", "GET", "/agent/settings", env["agent"])
|
||||
run_once(r)
|
||||
resp = response(root, "b1")
|
||||
assert resp["status"] == 502 and "хаб недоступен" in resp["body"]
|
||||
|
||||
|
||||
def test_reprepare_keeps_work(env, capsys):
|
||||
r, root = env["relay"], env["root"]
|
||||
r.step() # первая подготовка
|
||||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
|
||||
(root / "req").rmdir() # пропал подкаталог — реле подготовит папку заново
|
||||
for _ in range(3):
|
||||
r.step()
|
||||
assert (root / "req").is_dir() and (root / "work" / "busy.json").exists()
|
||||
out = capsys.readouterr().out
|
||||
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out
|
||||
|
||||
|
||||
def test_vanished_tree_keeps_work(env, capsys):
|
||||
r, root = env["relay"], env["root"]
|
||||
r.step()
|
||||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
|
||||
capsys.readouterr()
|
||||
shutil.rmtree(root) # папка пропала целиком и появилась снова
|
||||
root.mkdir()
|
||||
(root / "work").mkdir()
|
||||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос, который ещё обрабатывается
|
||||
for _ in range(3):
|
||||
r.step()
|
||||
assert (root / "work" / "busy.json").exists() and (root / "req").is_dir()
|
||||
assert "удалён незавершённый запрос" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_each_dir_cleaned_once(env, tmp_path, capsys):
|
||||
r, root, h = env["relay"], env["root"], env["hub"]
|
||||
other = tmp_path / "other"
|
||||
(other / "work").mkdir(parents=True)
|
||||
(other / "work" / "old.json").write_text("{}", encoding="utf-8")
|
||||
r.step() # root вычищен
|
||||
conn = h.conn()
|
||||
settings.set_(conn, "relay_dir", str(other))
|
||||
r._settings_at = None
|
||||
r.step()
|
||||
assert not (other / "work" / "old.json").exists() # новая папка чистится
|
||||
settings.set_(conn, "relay_dir", str(root))
|
||||
r._settings_at = None
|
||||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
|
||||
r.step()
|
||||
conn.close()
|
||||
assert (root / "work" / "busy.json").exists() # возврат A->B->A: A повторно не чистится
|
||||
|
||||
|
||||
def test_relay_targets_serve_host(tmp_path, monkeypatch):
|
||||
seen = []
|
||||
|
||||
class Spy(relay.Relay):
|
||||
def __init__(self, hub, port, host="127.0.0.1", poll=0.1):
|
||||
seen.append(host)
|
||||
super().__init__(hub, port, host, poll)
|
||||
|
||||
monkeypatch.setattr(relay, "Relay", Spy)
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
for host in ("0.0.0.0", "localhost"):
|
||||
srv, stop = api.start(h, host, 0)
|
||||
stop.set()
|
||||
srv.server_close()
|
||||
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user