Пакет C: гигиена хаба моста (bridge-hygiene, #13) #14

Merged
aep merged 13 commits from feat/13-bridge-hygiene into main 2026-10-14 12:22:15 +03:00
35 changed files with 2152 additions and 50 deletions

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ tests/fixtures/*.webm
__pycache__/ __pycache__/
*.sqlite* *.sqlite*
.pytest_cache/ .pytest_cache/
.wt/

View File

@ -6,25 +6,26 @@
| Модуль | Ответственность | Зависит от | | Модуль | Ответственность | Зависит от |
|---|---|---| |---|---|---|
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/` | stdlib | | `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup` | | `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` |
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` | | `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` |
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `backup` | копии файла базы, ротация | stdlib | | `backup` | копии файла базы, ротация | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` | `queue`, `mirror`, `settings`, `backup`, `relay` | | `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`) | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio | `client` | | `mcp_server` | инструменты MCP по stdio | `client` |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` | | `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: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. - Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
## Границы ## Границы

View File

@ -1,14 +1,14 @@
# jira-hub # 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). - Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). - Хаб не создаёт команды сам: повтор после 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/*`, клиентский — остальное. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
## Архитектура ## Архитектура

View 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>"
```

View File

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

View 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 | нет |

View 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 — кроме «на потом».

View 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` («нечитаемый ответ»), процесс не падает; цикл повторяет вызов как при
недоступном хабе

View 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` перенесена на ключ

View 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` не изменился

View 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/` не удалён, в логе нет «удалён незавершённый запрос»

View 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 уже у агента и будет выполнена; остановить — …»

View 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:*` для неё не создаётся

View File

@ -1,7 +1,7 @@
# agent Specification # agent Specification
## Purpose ## Purpose
TBD - created by archiving change bridge-core. Update Purpose after archive. Агент моста `jira-agent`: один файл на Python 3.9 (только stdlib) на виртуалке банка. Получает команды хаба (по HTTP или через общую папку), выполняет их в Jira по правилу «проверь, потом сделай» с политикой темпа, ведёт журнал на диске для восстановления после сбоев и возвращает хабу результаты и состояние задач. Учётные данные Jira есть только у агента.
## Requirements ## Requirements

View File

@ -1,7 +1,7 @@
# commands Specification # commands Specification
## Purpose ## Purpose
TBD - created by archiving change bridge-core. Update Purpose after archive. Контракт команд моста: каждая команда — желаемое состояние одной задачи Jira (`ensure_*`) или чтение состояния (`refresh`). Здесь типы команд и их параметры, временные ключи внутри пакета, порядок выполнения, политика темпа, обработка ошибок, повторная выдача и подтверждение по состоянию зеркала.
## Requirements ## Requirements

View File

@ -1,7 +1,7 @@
# data-model Specification # data-model Specification
## Purpose ## Purpose
TBD - created by archiving change bridge-core. Update Purpose after archive. Модель данных хаба: один файл SQLite с зеркалом задач Jira, служебными заметками, пакетами и командами, журналом синхронизаций, статусом агента и настройками. Здесь таблицы, их смысл, правила записи (зеркало пишет только загрузка состояния от агента) и миграции схемы.
## Requirements ## Requirements

View File

@ -1,7 +1,7 @@
# hub-api Specification # hub-api Specification
## Purpose ## Purpose
TBD - created by archiving change bridge-core. Update Purpose after archive. HTTP API хаба `jira-hub serve`: маршруты агента (выдача команд, отчёты, загрузка состояния), маршруты интерфейсов (зеркало, заметки, пакеты, настройки), резервные копии, автообновление зеркала и реле общей папки для агента за VPN.
## Requirements ## Requirements

View File

@ -1,7 +1,7 @@
# mcp Specification # mcp Specification
## Purpose ## Purpose
TBD - created by archiving change bridge-core. Update Purpose after archive. MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструменты чтения зеркала и заметок, постановки и отслеживания пакетов команд, обновления зеркала, статуса агента и настроек — поверх HTTP API хаба с клиентским токеном.
## Requirements ## Requirements

View File

@ -1,7 +1,7 @@
# migration Specification # migration Specification
## Purpose ## Purpose
TBD - created by archiving change bridge-core. Update Purpose after archive. Переход с CSV-пакета `admin/jira-sync` на мост: начальные настройки хаба из `config.example.json`, разовый импорт служебных заметок и сверочного набора связей из CSV, и что остаётся в `admin/` как история.
## Requirements ## Requirements

View File

@ -14,6 +14,7 @@ from urllib.parse import parse_qs, urlparse
from . import backup, db, mirror, queue, relay, settings from . import backup, db, mirror, queue, relay, settings
from .commands import CommandError from .commands import CommandError
from .paths import backups_dir as default_backups_dir from .paths import backups_dir as default_backups_dir
from .paths import secure_dir
from .queue import ClaimedError from .queue import ClaimedError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -33,6 +34,7 @@ class Hub:
self.db_path = Path(db_path) self.db_path = Path(db_path)
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.claim_wait = 30 self.claim_wait = 30
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
conn = self.conn() conn = self.conn()
settings.ensure_defaults(conn) settings.ensure_defaults(conn)
conn.close() conn.close()

View File

@ -2,10 +2,12 @@
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from . import paths
def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path: def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path:
backups_dir = Path(backups_dir) 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" target = backups_dir / f"{datetime.now():%Y%m%d-%H%M%S-%f}-{label}.sqlite"
conn.execute("VACUUM INTO ?", (str(target),)) conn.execute("VACUUM INTO ?", (str(target),))
# копии перед миграцией схемы (db.migrate) — для отката, ротации не подлежат # копии перед миграцией схемы (db.migrate) — для отката, ротации не подлежат

View File

@ -22,6 +22,8 @@ SPEC = {
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]}, "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]},
} }
WRITE_TYPES = [t for t in SPEC if t != "refresh"] WRITE_TYPES = [t for t in SPEC if t != "refresh"]
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
BOOL_PARAMS = {"replace"}
class CommandError(ValueError): 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] 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]: def validate(commands) -> list[dict]:
if not isinstance(commands, list) or not commands: if not isinstance(commands, list) or not commands:
raise CommandError("пакет без команд") raise CommandError("пакет без команд")
@ -68,6 +87,9 @@ def validate(commands) -> list[dict]:
unknown = set(params) - set(spec["required"]) - set(spec["optional"]) unknown = set(params) - set(spec["required"]) - set(spec["optional"])
if unknown: if unknown:
raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(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"]): if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]):
raise CommandError(f"команда {i}: ensure_fields без полей") raise CommandError(f"команда {i}: ensure_fields без полей")
if ctype == "refresh" and not (params.get("keys") or params.get("jql")): if ctype == "refresh" and not (params.get("keys") or params.get("jql")):

View File

@ -6,7 +6,7 @@ from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from . import backup from . import backup, paths
SCHEMA_VERSION = 2 SCHEMA_VERSION = 2
@ -117,8 +117,8 @@ def connect(path) -> sqlite3.Connection:
"""Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных """Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных
операций — transaction().""" операций — transaction()."""
is_memory = str(path) == ":memory:" is_memory = str(path) == ":memory:"
if not is_memory: if not is_memory and Path(path).parent != Path("."): # путь без каталога — рабочий каталог процесса не трогаем
Path(path).parent.mkdir(parents=True, exist_ok=True) paths.secure_dir(Path(path).parent)
conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False) conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
if not is_memory: if not is_memory:
@ -134,17 +134,25 @@ def _schema_version(conn) -> int | None:
def migrate(conn: sqlite3.Connection, path=None) -> None: def migrate(conn: sqlite3.Connection, path=None) -> None:
"""Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Перед миграцией файловой базы — """Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы
копия в <каталог базы>/backups (без ротации). Параллельный connect безопасен: версия перечитывается в транзакции.""" (<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный
connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции.
Вызывается только вне транзакции."""
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
version = _schema_version(conn) version = _schema_version(conn)
if version is None: 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: 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): with transaction(conn):
current = _schema_version(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: while current < SCHEMA_VERSION:
for sql in MIGRATIONS[current]: for sql in MIGRATIONS[current]:
conn.execute(sql) conn.execute(sql)

View File

@ -91,6 +91,9 @@ def fmt_batch(b) -> str:
for c in b.get("commands", []): for c in b.get("commands", []):
lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}" 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 "")) + (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) return "\n".join(lines)
@ -142,7 +145,9 @@ def notes_set(key: str, fact_status: str | None = None, work_kind: str | None =
@guard @guard
def batch_create(title: str, commands: list[dict]) -> str: def batch_create(title: str, commands: list[dict]) -> str:
"""Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status| """Поставить пакет команд. Каждая команда — {"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}) b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands})
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
@ -186,7 +191,8 @@ def command_retry(id: int) -> str:
@mcp.tool() @mcp.tool()
@guard @guard
def batch_cancel(id: int) -> str: def batch_cancel(id: int) -> str:
"""Отменить пакет (только команды в очереди; взятую агентом — command_cancel с force через API).""" """Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
в ответе подсказка (POST /commands/N/cancel с force)."""
return fmt_batch(_client().post(f"/batches/{id}/cancel", {})) return fmt_batch(_client().post(f"/batches/{id}/cancel", {}))

View File

@ -112,12 +112,21 @@ def set_note(conn, key, fields: dict, updated_by: str) -> dict:
def rename_note(conn, old_key, new_key) -> None: def rename_note(conn, old_key, new_key) -> None:
"""После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом, """После создания задачи: заметка с временного ключа переезжает на настоящий. Если под настоящим ключом заметка уже
если вдруг была, уступает место).""" есть — слияние (M-3 #4): поле временной заметки побеждает, если оно непустое; updated_at/updated_by — от более
поздней; временная строка удаляется."""
if old_key == new_key: if old_key == new_key:
return return
with db.transaction(conn): with db.transaction(conn):
if get_note(conn, old_key) is None: old = get_note(conn, old_key)
if old is None:
return return
conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,)) cur = get_note(conn, new_key)
if cur is None:
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key)) 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,))

View File

@ -1,7 +1,10 @@
"""Где лежат данные хаба: каталог, база, резервные копии.""" """Где лежат данные хаба: каталог, база, резервные копии."""
import logging
import os import os
from pathlib import Path from pathlib import Path
log = logging.getLogger(__name__)
def home() -> Path: def home() -> Path:
return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub"))) 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: def backups_dir() -> Path:
return home() / "backups" 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)

View File

@ -13,6 +13,7 @@ log = logging.getLogger(__name__)
TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"} TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"}
MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2) MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2)
ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands)
ONLINE_MINUTES = 5 ONLINE_MINUTES = 5
@ -71,7 +72,7 @@ def _status_of(cmds, cancelled_at=None) -> str:
if not sts: if not sts:
return "done" return "done"
if all(s in TERMINAL for s in sts): 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): if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts):
return "running" return "running"
return "queued" return "queued"
@ -148,6 +149,8 @@ def _redeliver(conn, now) -> dict | None:
return cmd return cmd
conn.execute("UPDATE command SET status = 'failed', finished_at = ?, error = ? WHERE id = ?", conn.execute("UPDATE command SET status = 'failed', finished_at = ?, error = ? WHERE id = ?",
(now, f"агент не отчитался после {MAX_DELIVERIES} выдач", cmd["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"],)) 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) 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)) pause = int(settings.pacing(conn).get(cmd["type"], 0))
conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?", conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?",
(now, pause, cmd["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"],)) conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],))
log.info("команда %s выдана снова (выдача %s)", cmd["id"], cmd["deliveries"] + 1) log.info("команда %s выдана снова (выдача %s)", cmd["id"], cmd["deliveries"] + 1)
else: 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: def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict:
if status not in ("done", "failed"): if status not in ("done", "failed"):
raise ValueError("status — done или failed") raise ValueError("status — done или failed")
error = None if error is None else str(error)[:ERROR_LIMIT]
with db.transaction(conn): with db.transaction(conn):
cmd = get_command(conn, command_id) cmd = get_command(conn, command_id)
if cmd is None: 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": if cmd["status"] == "queued":
raise NotClaimedError(f"команда {command_id} не выдана") raise NotClaimedError(f"команда {command_id} не выдана")
if cmd["status"] in TERMINAL: if cmd["status"] in TERMINAL:
# поздний отчёт о созданной задаче по отменённой команде: ключ не теряем, статус не трогаем (M-5) # поздний отчёт о созданной задаче по команде, которую хаб уже закрыл (force-отмена — M-5, предел выдач —
if (cmd["status"] == "cancelled" and cmd["type"] == "ensure_issue" and status == "done" # 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"]): and result and result.get("key") and not cmd["result"]):
conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", 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)) (_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id))
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) 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 get_command(conn, command_id)
return cmd # идемпотентно; cancelled не затирается return cmd # идемпотентно; терминальный статус (cancelled/failed) не затирается
now = db.now() now = db.now()
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", 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)) (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 = ?", 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)) (now, int(jira_calls or 0), int(rate_limited or 0), command_id))
if status == "done" and is_full_refresh(conn, command_id): if status == "done" and is_full_refresh(conn, command_id):
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
run = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (command_id,)).fetchone() # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)
if run: 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"],)) conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],))
if cmd["type"] == "refresh" and status == "done": if cmd["type"] == "refresh" and status == "done":
reconcile(conn) 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: if conn.execute("SELECT 1 FROM batch WHERE id = ?", (batch_id,)).fetchone() is None:
raise KeyError(batch_id) raise KeyError(batch_id)
now = db.now() 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)) 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: def cancel_command(conn, command_id, force=False) -> dict:

View File

@ -1,6 +1,7 @@
"""Реле через общую папку (specs/hub-api, «Реле через общую папку»): файлы-запросы агента из <relay_dir>/req/ """Реле через общую папку (specs/hub-api, «Реле через общую папку»): файлы-запросы агента из <relay_dir>/req/
пересылаются в HTTP этого же хаба, ответы кладутся в <relay_dir>/resp/. HTTP API, обработчики и токены не меняются; пересылаются в HTTP этого же хаба, ответы кладутся в <relay_dir>/resp/. HTTP API, обработчики и токены не меняются;
реле для них — ещё один клиент. Нужно, когда VPN на виртуалке закрывает сеть до хаба (design.md folder-transport).""" реле для них — ещё один клиент. Нужно, когда VPN на виртуалке закрывает сеть до хаба (design.md folder-transport)."""
import http.client
import json import json
import os import os
import threading import threading
@ -60,6 +61,7 @@ class Relay:
self._cleanup_at = 0.0 self._cleanup_at = 0.0
self._ready = False self._ready = False
self._warned: str | None = None self._warned: str | None = None
self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле
self.workers: list[threading.Thread] = [] self.workers: list[threading.Thread] = []
# --- цикл --------------------------------------------------------------------- # --- цикл ---------------------------------------------------------------------
@ -122,9 +124,15 @@ class Relay:
def _prepare(self) -> None: def _prepare(self) -> None:
for sub in ("req", "resp", "work"): for sub in ("req", "resp", "work"):
(self.root / sub).mkdir(exist_ok=True) (self.root / sub).mkdir(exist_ok=True)
if self.root not in self._cleaned:
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
for stale in (self.root / "work").iterdir(): for stale in (self.root / "work").iterdir():
stale.unlink(missing_ok=True) stale.unlink(missing_ok=True)
say(f"relay: удалён незавершённый запрос {stale.name} (перезапуск реле)") say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
self._cleaned.add(self.root)
else:
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
say(f"relay: папка {self.root} подготовлена заново")
self._ready, self._warned = True, None self._ready, self._warned = True, None
def _take(self, path: Path) -> None: def _take(self, path: Path) -> None:
@ -193,7 +201,7 @@ class Relay:
e.close() e.close()
except ValueError as e: # недопустимый заголовок (перевод строки) или URL except ValueError as e: # недопустимый заголовок (перевод строки) или URL
return 400, _error("bad_request", str(e)) 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}") return 502, _error("relay", f"хаб недоступен изнутри: {e}")
# --- уборка ------------------------------------------------------------------------ # --- уборка ------------------------------------------------------------------------

View File

@ -75,6 +75,16 @@ def test_agent_flow(hub):
assert c.get("/agent")["online"] is True 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): def test_interface_routes(hub):
c = hub["client"] c = hub["client"]
with pytest.raises(HubError) as e: with pytest.raises(HubError) as e:

View File

@ -123,3 +123,29 @@ def test_validate_rejects_duplicate_issue_summary():
commands.validate(cmds) commands.validate(cmds)
assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value) assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value)
commands.validate([cmds[0], cmds[2]]) # регистр различается — пакет допустим 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

View File

@ -1,6 +1,10 @@
import logging
import os
import sqlite3 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(): def test_connect_creates_schema():
@ -110,3 +114,83 @@ def test_nested_transaction_rollback_is_local(tmp_path):
except RuntimeError: except RuntimeError:
pass pass
assert [r["title"] for r in conn.execute("SELECT title FROM batch")] == ["outer"] 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()

View File

@ -189,3 +189,16 @@ def test_batch_wait_does_not_block_event_loop(monkeypatch):
out, ticks = asyncio.run(scenario()) out, ticks = asyncio.run(scenario())
assert "done" in out and len(ticks) == 5 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

View File

@ -101,3 +101,37 @@ def test_set_note_partial_keeps_other_fields():
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y") got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано" assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
assert got["fact_status"] == "Готово" and got["updated_by"] == "y" assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
def test_rename_note_merges_with_existing():
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"] == "сделано"

View File

@ -264,7 +264,7 @@ def _two_batches_with_dependent(conn):
return a, b, first return a, b, first
def test_fresh_definer_still_blocks_queue(): def test_redelivered_definer_blocks_dependents():
conn = fresh() conn = fresh()
a, b, first = _two_batches_with_dependent(conn) a, b, first = _two_batches_with_dependent(conn)
assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут
@ -379,3 +379,117 @@ def test_late_failed_report_after_force_cancel_changes_nothing():
queue.cancel_command(conn, c["id"], force=True) queue.cancel_command(conn, c["id"], force=True)
late = queue.report_result(conn, c["id"], "failed", error="HTTP 400") 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 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"

View File

@ -1,5 +1,7 @@
import http.client
import json import json
import os import os
import shutil
import threading import threading
import time import time
from datetime import datetime, timedelta, timezone 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"]) put_request(root, "r1", "GET", "/agent/settings", env["agent"])
run_once(r) run_once(r)
assert response(root, "r1")["status"] == 200 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)