docs: план хаба bridge-hygiene; уточнения дизайна (копия при миграции, чистка work/) (#13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a7fed00419
commit
70bcd4cf25
863
docs/superpowers/plans/2026-10-14-bridge-hygiene-hub.md
Normal file
863
docs/superpowers/plans/2026-10-14-bridge-hygiene-hub.md
Normal file
@ -0,0 +1,863 @@
|
|||||||
|
# bridge-hygiene — хаб: план реализации
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** пакет C гигиены хаба: закрытый каталог данных, одна копия при миграции, слияние заметок, проверка типов
|
||||||
|
параметров, корректные статусы и счётчики очереди, поздний отчёт по `failed`, информативная отмена пакета, устойчивое реле,
|
||||||
|
документы.
|
||||||
|
|
||||||
|
**Architecture:** правки точечные, внутри существующих модулей: `paths`/`db`/`backup` (права, миграция), `mirror`
|
||||||
|
(`rename_note`), `commands` (`validate`), `queue` (статусы, отчёты, довыдача, отмена), `relay`, `mcp_server`. Контракт
|
||||||
|
агента не меняется; новая только строка `still_claimed` в ответе отмены пакета.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.13, uv, pytest, SQLite (stdlib), stdlib `http.server`/`urllib`.
|
||||||
|
|
||||||
|
**Spec:** `openspec/changes/bridge-hygiene/` (proposal.md, design.md, specs/{hub-api,data-model,commands,mcp,migration,agent}).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hygiene` (ветка `feat/13-bridge-hygiene`).
|
||||||
|
Все команды — с этим cwd; основной checkout `jira-hub/` (main) не трогать.
|
||||||
|
- Комментарии, docstring, сообщения ошибок и коммитов — по-русски; идентификаторы — английские.
|
||||||
|
- `uv run pytest -q` — зелёным считается только весь набор (до начала: 169 passed).
|
||||||
|
- Новых зависимостей нет; миграции схемы нет (`SCHEMA_VERSION` остаётся 2).
|
||||||
|
- `error` команды хранится не длиннее 1500 символов; текст ошибки предела выдач — ровно `агент не отчитался после 3 выдач`.
|
||||||
|
- Каталог данных и `backups/` — права `0700`; сбой `chmod` — предупреждение в лог, не исключение.
|
||||||
|
- Каждая задача заканчивается коммитом с заголовком на русском, суффиксом `(#13)` и строкой
|
||||||
|
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||||
|
- Решения, принятые исполнителем без обсуждения, — в `openspec/changes/bridge-hygiene/design.md`, раздел
|
||||||
|
`## Decisions made without discussion`, со следующим свободным маркером `R-n` (занято R-1…R-11).
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Параллельные `connect` к базе v1 (старт хаба и `jira-hub import` одновременно): одна копия, миграция один раз, ни
|
||||||
|
одного исключения — тест `test_parallel_connect_makes_one_backup` в Task 1.
|
||||||
|
2. Поздний отчёт по `ensure_issue`, ставшей `failed` по пределу выдач, приходит дважды: второй ничего не меняет —
|
||||||
|
тест в Task 4.
|
||||||
|
3. Слияние заметок, когда у временной заметки поле пустая строка `""` (а не `NULL`): под ключом сохраняется значение —
|
||||||
|
тест в Task 2.
|
||||||
|
4. `refresh` довыдан и снова загружает состояние; затем полный прогон завершён: соседи помечаются по `started_at` первой
|
||||||
|
выдачи, счётчики равны одной загрузке — тест в Task 5.
|
||||||
|
5. Реле: папка пропала и появилась снова, в `work/` лежит взятый запрос — файл не удалён, лог без «перезапуск реле» —
|
||||||
|
тест в Task 7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Закрытый каталог данных и одна копия при миграции
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/jira_hub/paths.py` (новая `secure_dir`)
|
||||||
|
- Modify: `src/jira_hub/db.py` (`connect`, `migrate`)
|
||||||
|
- Modify: `src/jira_hub/backup.py` (`make_backup`)
|
||||||
|
- Test: `tests/test_db.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `paths.secure_dir(path: Path) -> None` — создать каталог (с родителями) и выставить `0700`; при `OSError` —
|
||||||
|
`log.warning`, без исключения. `db.migrate(conn, path=None)` — сигнатура прежняя.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_db.py` (импорты — в начало файла):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from jira_hub import backup, paths, queue, settings
|
||||||
|
|
||||||
|
|
||||||
|
def _mode(p):
|
||||||
|
return stat.S_IMODE(p.stat().st_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_dir_is_private(tmp_path):
|
||||||
|
home = tmp_path / "data"
|
||||||
|
home.mkdir()
|
||||||
|
home.chmod(0o755)
|
||||||
|
db.connect(home / "hub.sqlite").close()
|
||||||
|
assert _mode(home) == 0o700
|
||||||
|
|
||||||
|
|
||||||
|
def test_backups_dir_is_private(tmp_path):
|
||||||
|
conn = db.connect(tmp_path / "hub.sqlite")
|
||||||
|
backup.make_backup(conn, tmp_path / "backups", "batch-1")
|
||||||
|
conn.close()
|
||||||
|
assert _mode(tmp_path / "backups") == 0o700
|
||||||
|
|
||||||
|
|
||||||
|
def test_secure_dir_failure_only_warns(tmp_path, monkeypatch, caplog):
|
||||||
|
def deny(*a, **k):
|
||||||
|
raise PermissionError("нет прав")
|
||||||
|
monkeypatch.setattr(os, "chmod", deny)
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
paths.secure_dir(tmp_path / "x")
|
||||||
|
assert (tmp_path / "x").is_dir() and "0700" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_connect_makes_one_backup(tmp_path):
|
||||||
|
path = tmp_path / "hub.sqlite"
|
||||||
|
_v1_database(path)
|
||||||
|
barrier, errors = threading.Barrier(4), []
|
||||||
|
|
||||||
|
def go():
|
||||||
|
try:
|
||||||
|
barrier.wait()
|
||||||
|
db.connect(path).close()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=go) for _ in range(4)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(30)
|
||||||
|
assert errors == []
|
||||||
|
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
|
||||||
|
conn = db.connect(path)
|
||||||
|
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrated_claimed_redelivered_end_to_end(tmp_path):
|
||||||
|
"""База v1 с командой в claimed → миграция (deliveries = 1) → claim довыдаёт её с deliveries = 2 (M-10 #12)."""
|
||||||
|
path = tmp_path / "hub.sqlite"
|
||||||
|
_v1_database(path)
|
||||||
|
conn = db.connect(path)
|
||||||
|
settings.ensure_defaults(conn)
|
||||||
|
cmd = queue.claim(conn)
|
||||||
|
assert cmd["id"] == 1
|
||||||
|
assert queue.get_command(conn, 1)["deliveries"] == 2
|
||||||
|
conn.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_db.py -q`
|
||||||
|
Expected: FAIL — `test_data_dir_is_private` (0o755), `test_backups_dir_is_private`, `test_secure_dir_failure_only_warns`
|
||||||
|
(`AttributeError: module 'jira_hub.paths' has no attribute 'secure_dir'`); `test_parallel_connect_makes_one_backup` —
|
||||||
|
больше одной копии (может пройти случайно — это гонка; после Step 3 обязан проходить стабильно);
|
||||||
|
`test_migrated_claimed_redelivered_end_to_end` уже может проходить — это закрепление поведения.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — `src/jira_hub/paths.py` целиком:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Где лежат данные хаба: каталог, база, резервные копии."""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def home() -> Path:
|
||||||
|
return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub")))
|
||||||
|
|
||||||
|
|
||||||
|
def db_path() -> Path:
|
||||||
|
return home() / "hub.sqlite"
|
||||||
|
|
||||||
|
|
||||||
|
def backups_dir() -> Path:
|
||||||
|
return home() / "backups"
|
||||||
|
|
||||||
|
|
||||||
|
def secure_dir(path: Path) -> None:
|
||||||
|
"""Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) —
|
||||||
|
предупреждение, хаб продолжает работу."""
|
||||||
|
path = Path(path)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("каталог %s не закрыт (0700): %s", path, e)
|
||||||
|
```
|
||||||
|
|
||||||
|
В `src/jira_hub/backup.py` заменить `backups_dir.mkdir(parents=True, exist_ok=True)` на `paths.secure_dir(backups_dir)`
|
||||||
|
и добавить импорт `from . import paths`.
|
||||||
|
|
||||||
|
В `src/jira_hub/db.py`: импорт `from . import backup, paths` (если `backup` уже импортирован — добавить `paths`). В `connect`
|
||||||
|
заменить `Path(path).parent.mkdir(parents=True, exist_ok=True)` на `paths.secure_dir(Path(path).parent)`. `migrate`
|
||||||
|
целиком:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def migrate(conn: sqlite3.Connection, path=None) -> None:
|
||||||
|
"""Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы
|
||||||
|
(<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный
|
||||||
|
connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
|
||||||
|
отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции."""
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
version = _schema_version(conn)
|
||||||
|
if version is None:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),))
|
||||||
|
elif version < SCHEMA_VERSION:
|
||||||
|
with transaction(conn):
|
||||||
|
current = _schema_version(conn)
|
||||||
|
if current < SCHEMA_VERSION:
|
||||||
|
if path is not None:
|
||||||
|
reader = sqlite3.connect(str(path), timeout=10)
|
||||||
|
try:
|
||||||
|
backup.make_backup(reader, Path(path).parent / "backups", f"schema-v{current}", keep=0)
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
while current < SCHEMA_VERSION:
|
||||||
|
for sql in MIGRATIONS[current]:
|
||||||
|
conn.execute(sql)
|
||||||
|
current += 1
|
||||||
|
conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),))
|
||||||
|
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверить, что `backup` не импортирует `db` (циклов нет: `paths` ← `backup` ← `db`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_db.py -q && for i in 1 2 3 4 5; do uv run pytest tests/test_db.py::test_parallel_connect_makes_one_backup -q || break; done && uv run pytest -q`
|
||||||
|
Expected: PASS; тест параллельного connect — 5 из 5; весь набор зелёный.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/jira_hub/paths.py src/jira_hub/db.py src/jira_hub/backup.py tests/test_db.py
|
||||||
|
git commit -m "fix(db): каталог данных 0700, одна копия при миграции под блокировкой (#13)
|
||||||
|
|
||||||
|
M-7 (hub#4), M-2 и M-10 (hub#12).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Слияние заметок при переносе `new:N` → ключ
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/jira_hub/mirror.py` (`rename_note`)
|
||||||
|
- Test: `tests/test_mirror.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `mirror.NOTE_FIELDS`, `mirror.get_note`, `mirror.set_note`, `db.transaction`.
|
||||||
|
- Produces: `mirror.rename_note(conn, old_key, new_key) -> None` — сигнатура прежняя, поведение при существующей
|
||||||
|
заметке под `new_key` — слияние.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_mirror.py` (функция `fresh()`/соединение — как в
|
||||||
|
соседних тестах файла; если хелпера нет — `conn = db.connect(":memory:")`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_rename_note_merges_with_existing():
|
||||||
|
conn = db.connect(":memory:")
|
||||||
|
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
|
||||||
|
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
|
||||||
|
mirror.rename_note(conn, "new:5", "A-5")
|
||||||
|
note = mirror.get_note(conn, "A-5")
|
||||||
|
assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает
|
||||||
|
assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом
|
||||||
|
assert note["fact_status"] == "В работе"
|
||||||
|
assert note["updated_by"] == "import" # временная заметка записана позже
|
||||||
|
assert mirror.get_note(conn, "new:5") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_note_empty_string_does_not_overwrite():
|
||||||
|
conn = db.connect(":memory:")
|
||||||
|
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
|
||||||
|
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
|
||||||
|
mirror.rename_note(conn, "new:6", "A-6")
|
||||||
|
note = mirror.get_note(conn, "A-6")
|
||||||
|
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_mirror.py -q`
|
||||||
|
Expected: FAIL — `owner_note` и `rest_text` пропали (`None`): заметка под ключом удаляется.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — заменить `rename_note` в `src/jira_hub/mirror.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def rename_note(conn, old_key, new_key) -> None:
|
||||||
|
"""После создания задачи: заметка с временного ключа переезжает на настоящий. Если под настоящим ключом заметка уже
|
||||||
|
есть — слияние (M-3 #4): поле временной заметки побеждает, если оно непустое; updated_at/updated_by — от более
|
||||||
|
поздней; временная строка удаляется."""
|
||||||
|
if old_key == new_key:
|
||||||
|
return
|
||||||
|
with db.transaction(conn):
|
||||||
|
old = get_note(conn, old_key)
|
||||||
|
if old is None:
|
||||||
|
return
|
||||||
|
cur = get_note(conn, new_key)
|
||||||
|
if cur is None:
|
||||||
|
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))
|
||||||
|
return
|
||||||
|
merged = [old[f] if old[f] not in (None, "") else cur[f] for f in NOTE_FIELDS]
|
||||||
|
newer = old if (old["updated_at"] or "") >= (cur["updated_at"] or "") else cur
|
||||||
|
conn.execute(f"UPDATE issue_note SET {', '.join(f'{f} = ?' for f in NOTE_FIELDS)}, updated_at = ?, updated_by = ? "
|
||||||
|
"WHERE key = ?", merged + [newer["updated_at"], newer["updated_by"], new_key])
|
||||||
|
conn.execute("DELETE FROM issue_note WHERE key = ?", (old_key,))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_mirror.py -q && uv run pytest -q`
|
||||||
|
Expected: PASS, весь набор зелёный.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/jira_hub/mirror.py tests/test_mirror.py
|
||||||
|
git commit -m "fix(mirror): rename_note сливает заметки, а не удаляет заметку под ключом (#13)
|
||||||
|
|
||||||
|
M-3 (hub#4, PR #2).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Проверка типов параметров команд
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/jira_hub/commands.py` (`validate`, новые `LIST_PARAMS`, `BOOL_PARAMS`, `_type_error`)
|
||||||
|
- Test: `tests/test_commands.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `commands.SPEC`, `CommandError`.
|
||||||
|
- Produces: `commands.validate(commands) -> list[dict]` — прежняя сигнатура; неверный тип значения → `CommandError`
|
||||||
|
(наружу 422) с текстом `команда <i> (<тип>): <параметр> — <ожидаемое>`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_commands.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from jira_hub import commands as C
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cmd, name", [
|
||||||
|
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": 42}}, "summary"),
|
||||||
|
({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": "AWPCA"}}, "labels_add"),
|
||||||
|
({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["x", 1]}}, "labels_remove"),
|
||||||
|
({"type": "ensure_fields", "params": {"key": ["A-1"], "duedate": "2026-10-23"}}, "key"),
|
||||||
|
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": {"t": 1}}}, "body"),
|
||||||
|
({"type": "ensure_status", "params": {"key": "A-1", "target": 5}}, "target"),
|
||||||
|
({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", 2]}}, "target"),
|
||||||
|
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": "да"}}, "replace"),
|
||||||
|
({"type": "refresh", "params": {"keys": "A-1"}}, "keys"),
|
||||||
|
])
|
||||||
|
def test_validate_rejects_wrong_types(cmd, name):
|
||||||
|
with pytest.raises(C.CommandError) as e:
|
||||||
|
C.validate([cmd])
|
||||||
|
assert name in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_null_for_clearable_and_path_target():
|
||||||
|
out = C.validate([
|
||||||
|
{"type": "ensure_fields", "params": {"key": "A-1", "assignee": None, "epic_key": None, "duedate": None}},
|
||||||
|
{"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}},
|
||||||
|
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
|
||||||
|
])
|
||||||
|
assert len(out) == 3
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_commands.py -q`
|
||||||
|
Expected: FAIL — для `summary: 42` `AttributeError: 'int' object has no attribute 'strip'`, для остальных — нет исключения.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — в `src/jira_hub/commands.py` после `WRITE_TYPES`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
|
||||||
|
BOOL_PARAMS = {"replace"}
|
||||||
|
|
||||||
|
|
||||||
|
def _type_error(ctype, name, value) -> str | None:
|
||||||
|
"""Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500.
|
||||||
|
null допустим везде — обязательность проверяется отдельно."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if name in LIST_PARAMS:
|
||||||
|
ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк"
|
||||||
|
elif name in BOOL_PARAMS:
|
||||||
|
ok, want = isinstance(value, bool), "true или false"
|
||||||
|
elif ctype == "ensure_status" and name == "target":
|
||||||
|
ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value))
|
||||||
|
want = "строка или список строк"
|
||||||
|
else:
|
||||||
|
ok, want = isinstance(value, str), "строка"
|
||||||
|
return None if ok else f"{name} — {want}"
|
||||||
|
```
|
||||||
|
|
||||||
|
В `validate` сразу после проверки `unknown` (до проверки `ensure_fields без полей`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
|
||||||
|
if bad:
|
||||||
|
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_commands.py -q && uv run pytest -q`
|
||||||
|
Expected: PASS, весь набор зелёный (если упал старый тест, отправлявший параметр другого типа, — исправить тест под
|
||||||
|
контракт спеки и записать рулинг R-n в design.md).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/jira_hub/commands.py tests/test_commands.py
|
||||||
|
git commit -m "fix(commands): проверка типов параметров — 422 вместо 500 (#13)
|
||||||
|
|
||||||
|
M-3 (hub#12).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Отчёты — обрезка ошибки, поздний `done` по `failed`, статус отменённого пакета, пустой полный прогон
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/jira_hub/queue.py` (`_status_of`, `report_result`)
|
||||||
|
- Test: `tests/test_queue.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `queue.claim`, `queue.cancel_batch`, `queue.cancel_command`, `queue.MAX_DELIVERIES`, `mirror.rename_note`
|
||||||
|
(Task 2).
|
||||||
|
- Produces: `queue.report_result(...)` — сигнатура прежняя; `queue.ERROR_LIMIT = 1500`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_queue.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fail_by_limit(conn, cid):
|
||||||
|
for _ in range(queue.MAX_DELIVERIES - 1):
|
||||||
|
assert queue.claim(conn)["id"] == cid
|
||||||
|
queue.claim(conn) # четвёртый claim: cid → failed по пределу
|
||||||
|
assert queue.get_command(conn, cid)["status"] == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_is_trimmed_to_1500():
|
||||||
|
conn = fresh()
|
||||||
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
|
c = queue.claim(conn)
|
||||||
|
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
|
||||||
|
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_done_after_delivery_limit_keeps_issue_key():
|
||||||
|
conn = fresh()
|
||||||
|
mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import")
|
||||||
|
queue.create_batch(conn, "x", "claude", [
|
||||||
|
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
|
||||||
|
c = queue.claim(conn)
|
||||||
|
_fail_by_limit(conn, c["id"])
|
||||||
|
late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2)
|
||||||
|
assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True}
|
||||||
|
assert late["error"] == "агент не отчитался после 3 выдач"
|
||||||
|
assert mirror.get_note(conn, "A-30")["fact_status"] == "План" and mirror.get_note(conn, "new:8") is None
|
||||||
|
again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"})
|
||||||
|
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_batch_with_done_command_is_cancelled():
|
||||||
|
conn = fresh()
|
||||||
|
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
|
||||||
|
first = queue.claim(conn)
|
||||||
|
queue.report_result(conn, first["id"], "done", result={"changed": []})
|
||||||
|
second = queue.claim(conn)
|
||||||
|
queue.cancel_batch(conn, b["id"])
|
||||||
|
queue.report_result(conn, second["id"], "done", result={"changed": []})
|
||||||
|
view = queue.batch_view(conn, b["id"])
|
||||||
|
assert view["status"] == "cancelled"
|
||||||
|
assert view["counts"]["done"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_full_refresh_keeps_neighbors():
|
||||||
|
conn = fresh()
|
||||||
|
jql = settings.get(conn, "base_jql")
|
||||||
|
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
|
||||||
|
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
|
||||||
|
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
||||||
|
r = queue.claim(conn)
|
||||||
|
queue.report_result(conn, r["id"], "done") # загрузок состояния не было: 0 задач
|
||||||
|
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`mirror.apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True)`; `fetched_at` в прошлом
|
||||||
|
нужен, чтобы до исправления задача попала под `fetched_at < started_at` и тест падал.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_queue.py -q`
|
||||||
|
Expected: FAIL — `ERROR_LIMIT` нет, ошибка 5000 символов; поздний `done` по `failed` отброшен (`result is None`); статус
|
||||||
|
отменённого пакета `done`; задача `A-1` помечена `neighbor = 1`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — в `src/jira_hub/queue.py`:
|
||||||
|
|
||||||
|
1. Константа рядом с `MAX_DELIVERIES`: `ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands)`.
|
||||||
|
2. `_status_of`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _status_of(cmds, cancelled_at=None) -> str:
|
||||||
|
sts = [c["status"] for c in cmds]
|
||||||
|
if not sts:
|
||||||
|
return "done"
|
||||||
|
if all(s in TERMINAL for s in sts):
|
||||||
|
return "cancelled" if cancelled_at else "done" # отменённый пакет — отменён, даже с частью done (T5.6)
|
||||||
|
if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts):
|
||||||
|
return "running"
|
||||||
|
return "queued"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. `report_result`: в начале функции, после проверки `status`, —
|
||||||
|
`error = None if error is None else str(error)[:ERROR_LIMIT]`. Ветку позднего отчёта заменить:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if cmd["status"] in TERMINAL:
|
||||||
|
# поздний отчёт о созданной задаче по команде, которую хаб уже закрыл (force-отмена — M-5, предел выдач —
|
||||||
|
# M-4 #12): ключ не теряем, статус не трогаем
|
||||||
|
if (cmd["status"] in ("cancelled", "failed") and cmd["type"] == "ensure_issue" and status == "done"
|
||||||
|
and result and result.get("key") and not cmd["result"]):
|
||||||
|
conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
|
||||||
|
(_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||||
|
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
|
||||||
|
log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат",
|
||||||
|
command_id, cmd["status"], result["key"])
|
||||||
|
return get_command(conn, command_id)
|
||||||
|
return cmd # идемпотентно; терминальный статус не затирается
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Пометка соседей по полному прогону — только при непустой загрузке (M-12):
|
||||||
|
|
||||||
|
```python
|
||||||
|
if status == "done" and is_full_refresh(conn, command_id):
|
||||||
|
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
|
||||||
|
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)
|
||||||
|
run = conn.execute("SELECT started_at, issues_count FROM sync_run WHERE command_id = ?", (command_id,)).fetchone()
|
||||||
|
if run and run["issues_count"] > 0:
|
||||||
|
conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_queue.py -q && uv run pytest -q`
|
||||||
|
Expected: PASS, весь набор зелёный. Если старый тест полного прогона рассчитывал на пометку соседей без загрузки
|
||||||
|
состояния — дописать в нём загрузку хотя бы одной задачи через `record_state`/`POST /agent/state`, смысл теста не менять.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/jira_hub/queue.py tests/test_queue.py
|
||||||
|
git commit -m "fix(queue): обрезка error, поздний done по failed, статус отменённого пакета, пустой полный прогон (#13)
|
||||||
|
|
||||||
|
M-4 (hub#12), T5.6 и M-12 (hub#4).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Довыдача `refresh` — счётчики и `finished_at`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/jira_hub/queue.py` (`claim` — ветка довыдачи, `_redeliver`)
|
||||||
|
- Test: `tests/test_queue.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `queue.record_state(conn, command_id, issues_count, links_count)`, `queue.MAX_DELIVERIES`.
|
||||||
|
- Produces: поведение — довыдача `refresh` обнуляет `sync_run.issues_count/links_count` (`started_at` прежний); перевод
|
||||||
|
`refresh` в `failed` по пределу проставляет `sync_run.finished_at`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_queue.py`; заодно переименовать
|
||||||
|
`test_fresh_definer_still_blocks_queue` в `test_redelivered_definer_blocks_dependents` (тело не менять; M-10 #12):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_redelivered_refresh_resets_counts():
|
||||||
|
conn = fresh()
|
||||||
|
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||||
|
r = queue.claim(conn)
|
||||||
|
queue.record_state(conn, r["id"], 150, 40)
|
||||||
|
started = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["started_at"]
|
||||||
|
assert queue.claim(conn)["id"] == r["id"] # отчёт потерян — довыдача
|
||||||
|
queue.record_state(conn, r["id"], 150, 40)
|
||||||
|
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()
|
||||||
|
assert (run["issues_count"], run["links_count"]) == (150, 40)
|
||||||
|
assert run["started_at"] == started
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_failed_by_limit_finishes_sync_run():
|
||||||
|
conn = fresh()
|
||||||
|
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||||
|
r = queue.claim(conn)
|
||||||
|
for _ in range(queue.MAX_DELIVERIES):
|
||||||
|
queue.claim(conn)
|
||||||
|
assert queue.get_command(conn, r["id"])["status"] == "failed"
|
||||||
|
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_queue.py -q`
|
||||||
|
Expected: FAIL — `issues_count = 300`; `finished_at` пуст.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — в `src/jira_hub/queue.py`:
|
||||||
|
|
||||||
|
В `_redeliver`, сразу после `UPDATE command SET status = 'failed' …`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if cmd["type"] == "refresh": # загрузка закончилась ничем — журнал синхронизаций закрыт (M-4 #12)
|
||||||
|
conn.execute("UPDATE sync_run SET finished_at = ? WHERE command_id = ?", (now, cmd["id"]))
|
||||||
|
```
|
||||||
|
|
||||||
|
В `claim`, в ветке `if cmd is not None:` (довыдача), после `UPDATE command SET claimed_at …, deliveries = deliveries + 1`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if cmd["type"] == "refresh":
|
||||||
|
# повторное выполнение загрузит то же заново: счётчики с нуля; started_at — граница пометки соседей,
|
||||||
|
# не сдвигается (M-5 #12)
|
||||||
|
conn.execute("UPDATE sync_run SET issues_count = 0, links_count = 0 WHERE command_id = ?", (cmd["id"],))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_queue.py -q && uv run pytest -q`
|
||||||
|
Expected: PASS, весь набор зелёный.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/jira_hub/queue.py tests/test_queue.py
|
||||||
|
git commit -m "fix(queue): довыдача refresh не удваивает счётчики, предел выдач закрывает sync_run (#13)
|
||||||
|
|
||||||
|
M-4, M-5, M-10 (hub#12).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Отмена пакета с командой у агента; подсказка про метки в MCP
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/jira_hub/queue.py` (`cancel_batch`)
|
||||||
|
- Modify: `src/jira_hub/mcp_server.py` (`fmt_batch`, docstring `batch_cancel`, `batch_create`)
|
||||||
|
- Test: `tests/test_queue.py`, `tests/test_mcp.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `queue.batch_view`.
|
||||||
|
- Produces: `queue.cancel_batch(conn, batch_id) -> dict` — вид пакета + `still_claimed: list[{"id": int, "type": str}]`;
|
||||||
|
`mcp_server.fmt_batch(b)` выводит строку на каждую запись `still_claimed`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
В `tests/test_queue.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_cancel_batch_reports_still_claimed():
|
||||||
|
conn = fresh()
|
||||||
|
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||||
|
c = queue.claim(conn)
|
||||||
|
view = queue.cancel_batch(conn, b["id"])
|
||||||
|
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
|
||||||
|
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||||||
|
```
|
||||||
|
|
||||||
|
В `tests/test_mcp.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_fmt_batch_warns_about_claimed():
|
||||||
|
b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running",
|
||||||
|
"remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [],
|
||||||
|
"still_claimed": [{"id": 30, "type": "ensure_fields"}]}
|
||||||
|
text = mcp_server.fmt_batch(b)
|
||||||
|
assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_create_mentions_labels():
|
||||||
|
doc = mcp_server.batch_create.__doc__ or ""
|
||||||
|
assert "labels" in doc and "AWPCA" in doc
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `batch_create` после декораторов `@mcp.tool()`/`@guard` теряет `__doc__` — взять описание так, как его видит MCP
|
||||||
|
(посмотреть, как это делает `test_async_tools_are_coroutines_and_registered`), и проверять его.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_queue.py tests/test_mcp.py -q`
|
||||||
|
Expected: FAIL — `KeyError: 'still_claimed'`; строки о команде у агента нет; в описании нет `labels`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
`queue.cancel_batch` — вместо `return batch_view(conn, batch_id)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
view = batch_view(conn, batch_id)
|
||||||
|
# взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12)
|
||||||
|
view["still_claimed"] = [{"id": c["id"], "type": c["type"]} for c in view["commands"] if c["status"] == "claimed"]
|
||||||
|
return view
|
||||||
|
```
|
||||||
|
|
||||||
|
`mcp_server.fmt_batch` — перед `return "\n".join(lines)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for s in b.get("still_claimed") or []:
|
||||||
|
lines.append(f" команда #{s['id']} ({s['type']}) уже у агента и будет выполнена; остановить — "
|
||||||
|
f"POST /commands/{s['id']}/cancel с force: true")
|
||||||
|
```
|
||||||
|
|
||||||
|
Docstring `batch_cancel`: `"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
|
||||||
|
в ответе подсказка (POST /commands/N/cancel с force)."""`
|
||||||
|
|
||||||
|
Docstring `batch_create` дополнить предложением: `ensure_issue передавайте с labels, включающими базовую метку проекта
|
||||||
|
(AWPCA): хаб и агент метки сами не добавляют, задача без неё выпадет из базовой JQL и из зеркала.`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_queue.py tests/test_mcp.py -q && uv run pytest -q`
|
||||||
|
Expected: PASS, весь набор зелёный.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/jira_hub/queue.py src/jira_hub/mcp_server.py tests/test_queue.py tests/test_mcp.py
|
||||||
|
git commit -m "feat(queue,mcp): отмена пакета сообщает о команде у агента; подсказка про labels (#13)
|
||||||
|
|
||||||
|
M-11 (hub#12), T3.7 (agent#4).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Реле — 502 на сбой протокола, `work/` чистится один раз на папку, `serve --host`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/jira_hub/relay.py` (`__init__`, `_prepare`, `_forward`)
|
||||||
|
- Test: `tests/test_relay.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `api.start(hub, host, port) -> (server, stop_event)`; `relay.Relay(hub, port, host="127.0.0.1", poll=0.1)`.
|
||||||
|
- Produces: `Relay._cleaned: Path | None` — папка, чей `work/` уже вычищен после старта.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_relay.py` (импорт `http.client` — в начало файла):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_protocol_error_gives_502(env, monkeypatch):
|
||||||
|
r, root = env["relay"], env["root"]
|
||||||
|
r.step()
|
||||||
|
|
||||||
|
def broken(*a, **k):
|
||||||
|
raise http.client.BadStatusLine("мусор")
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", broken)
|
||||||
|
put_request(root, "b1", "GET", "/agent/settings", env["agent"])
|
||||||
|
run_once(r)
|
||||||
|
resp = response(root, "b1")
|
||||||
|
assert resp["status"] == 502 and "хаб недоступен" in resp["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reprepare_keeps_work(env, capsys):
|
||||||
|
r, root = env["relay"], env["root"]
|
||||||
|
r.step() # первая подготовка
|
||||||
|
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
|
||||||
|
(root / "req").rmdir() # пропал подкаталог — реле подготовит папку заново
|
||||||
|
for _ in range(3):
|
||||||
|
r.step()
|
||||||
|
assert (root / "req").is_dir() and (root / "work" / "busy.json").exists()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "перезапуск реле" not in out and "подготовлена заново" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_relay_targets_serve_host(tmp_path, monkeypatch):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class Spy(relay.Relay):
|
||||||
|
def __init__(self, hub, port, host="127.0.0.1", poll=0.1):
|
||||||
|
seen.append(host)
|
||||||
|
super().__init__(hub, port, host, poll)
|
||||||
|
|
||||||
|
monkeypatch.setattr(relay, "Relay", Spy)
|
||||||
|
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||||
|
for host in ("0.0.0.0", "localhost"):
|
||||||
|
srv, stop = api.start(h, host, 0)
|
||||||
|
stop.set()
|
||||||
|
srv.server_close()
|
||||||
|
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_relay.py -q`
|
||||||
|
Expected: FAIL — `b1` без ответа (исключение в потоке); `busy.json` удалён и в логе «перезапуск реле»;
|
||||||
|
`test_relay_targets_serve_host` может пройти сразу — это закрепление (M-1 #8).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — в `src/jira_hub/relay.py`:
|
||||||
|
|
||||||
|
- импорт `import http.client`;
|
||||||
|
- в `__init__`: `self._cleaned: Path | None = None # папка, чей work/ вычищен после старта реле`;
|
||||||
|
- `_prepare`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _prepare(self) -> None:
|
||||||
|
for sub in ("req", "resp", "work"):
|
||||||
|
(self.root / sub).mkdir(exist_ok=True)
|
||||||
|
if self._cleaned != self.root:
|
||||||
|
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
|
||||||
|
for stale in (self.root / "work").iterdir():
|
||||||
|
stale.unlink(missing_ok=True)
|
||||||
|
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
|
||||||
|
self._cleaned = self.root
|
||||||
|
else:
|
||||||
|
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
|
||||||
|
say(f"relay: папка {self.root} подготовлена заново")
|
||||||
|
self._ready, self._warned = True, None
|
||||||
|
```
|
||||||
|
|
||||||
|
- в `_forward` последний `except` расширить: `except (urllib.error.URLError, http.client.HTTPException, OSError) as e:`
|
||||||
|
(текст ответа прежний: `хаб недоступен изнутри: {e}`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_relay.py -q && uv run pytest -q`
|
||||||
|
Expected: PASS, весь набор зелёный (`test_work_cleared_on_prepare` проходит: первая подготовка чистит).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/jira_hub/relay.py tests/test_relay.py
|
||||||
|
git commit -m "fix(relay): 502 на сбой протокола, work/ чистится один раз на папку, тест serve --host (#13)
|
||||||
|
|
||||||
|
M-1, M-3, M-4 (hub#8).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Документы и `.gitignore`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.gitignore`, `ARCHITECTURE.md`, `CLAUDE.md`
|
||||||
|
|
||||||
|
**Interfaces:** нет (только документы).
|
||||||
|
|
||||||
|
- [ ] **Step 1: `.gitignore`** — дописать строку `.wt/` (worktree внутри репозитория не должны попадать в `git status`).
|
||||||
|
- [ ] **Step 2: `ARCHITECTURE.md`**:
|
||||||
|
- строка `paths`: `каталог данных (\`JIRA_HUB_HOME\`), пути базы и \`backups/\`, права 0700 (\`secure_dir\`)`;
|
||||||
|
- строка `db`: зависимость `backup`, `paths`; строка `backup`: зависимость `paths`;
|
||||||
|
- строка `relay`: `реле общей папки (настройка \`relay_dir\`): поток в \`serve\`, файлы \`req/\` → свой HTTP (адрес
|
||||||
|
\`serve\`, для \`0.0.0.0\` — \`127.0.0.1\`) → \`resp/\`; агент с \`hub_url = folder:<путь>\` ходит через него; HTTP-обработчики
|
||||||
|
о нём не знают`;
|
||||||
|
- в «Потоки данных» строка: `- Агент за VPN: агент → файлы в общей папке (\`relay_dir\`) → \`relay\` → HTTP хаба → ответ файлом.`
|
||||||
|
- [ ] **Step 3: `CLAUDE.md`** — в «Что это» заменить «текущего change нет (последний — …bridge-reliability/)» на
|
||||||
|
«текущий change — `openspec/changes/bridge-hygiene/`». В «Стек и ограничения» к строке про данные добавить
|
||||||
|
«каталог данных и `backups/` — права 0700».
|
||||||
|
- [ ] **Step 4: Проверка**
|
||||||
|
|
||||||
|
Run: `git status --short && openspec validate bridge-hygiene --strict && openspec validate --specs --strict && uv run pytest -q`
|
||||||
|
Expected: `.wt/` не виден в `git status`; обе проверки OpenSpec зелёные; весь набор тестов зелёный.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitignore ARCHITECTURE.md CLAUDE.md
|
||||||
|
git commit -m "docs: реле и relay_dir в ARCHITECTURE, права каталога, текущий change, .wt/ в .gitignore (#13)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
@ -59,11 +59,14 @@
|
|||||||
строк; `labels`, `labels_add`, `labels_remove`, `keys` — списки строк; `replace` — bool.
|
строк; `labels`, `labels_add`, `labels_remove`, `keys` — списки строк; `replace` — bool.
|
||||||
9. **Миграция под блокировкой (M-2 #12).** `migrate`: версия читается вне транзакции; если ниже текущей — `BEGIN
|
9. **Миграция под блокировкой (M-2 #12).** `migrate`: версия читается вне транзакции; если ниже текущей — `BEGIN
|
||||||
IMMEDIATE` (блокировка записи), повторное чтение версии, и только если всё ещё ниже — копия базы и миграция в той же
|
IMMEDIATE` (блокировка записи), повторное чтение версии, и только если всё ещё ниже — копия базы и миграция в той же
|
||||||
транзакции. Копия — `sqlite3.Connection.backup` из того же соединения, работает при открытой транзакции (читает
|
транзакции. Копия делается отдельным соединением к тому же файлу (`VACUUM INTO` внутри транзакции SQLite запрещает,
|
||||||
снимок). Второй `connect`, дождавшись блокировки, видит новую версию и копию не делает. Docstring исправляется.
|
а читатель при WAL работает и под чужой блокировкой записи и видит последнее зафиксированное состояние — базу до
|
||||||
|
миграции; проверено 14.10.2026). Второй `connect`, дождавшись блокировки, видит новую версию и копию не делает.
|
||||||
|
Docstring исправляется.
|
||||||
10. **Реле (M-3 #8, M-4 #8).** `_forward` ловит `http.client.HTTPException` вместе с `URLError`/`OSError` → 502. Чистка
|
10. **Реле (M-3 #8, M-4 #8).** `_forward` ловит `http.client.HTTPException` вместе с `URLError`/`OSError` → 502. Чистка
|
||||||
`work/` выносится из `_prepare` в старт реле (первый `_prepare` после запуска потока); повторная подготовка (смена
|
`work/` — только при первой подготовке каждой папки после старта реле (реле помнит, какую папку уже чистило; новая
|
||||||
`relay_dir`, пропала папка) создаёт подкаталоги без чистки и пишет «relay: папка подготовлена заново». Запросы,
|
папка после смены `relay_dir` чистится); повторная подготовка той же папки (пропал подкаталог, не было доступа)
|
||||||
|
создаёт подкаталоги без чистки и пишет «relay: папка подготовлена заново». Запросы,
|
||||||
взятые до пропажи папки, обрабатываются своими потоками или пропадают вместе с папкой.
|
взятые до пропажи папки, обрабатываются своими потоками или пропадают вместе с папкой.
|
||||||
11. **Отмена пакета с взятой командой (M-11 #12).** `cancel_batch` возвращает вид пакета с полем `still_claimed` —
|
11. **Отмена пакета с взятой командой (M-11 #12).** `cancel_batch` возвращает вид пакета с полем `still_claimed` —
|
||||||
список `{id, type}` команд пакета в `claimed`. MCP `batch_cancel` выводит строку «команда #N уже у агента и будет
|
список `{id, type}` команд пакета в `claimed`. MCP `batch_cancel` выводит строку «команда #N уже у агента и будет
|
||||||
@ -132,8 +135,8 @@
|
|||||||
| R-4 (design #6) | 2026-10-14 | Довыдача `refresh` обнуляет счётчики, `started_at` не трогает | `started_at` — граница пометки соседей; сдвиг пометил бы прочитанное первым выполнением | только отображение счётчиков |
|
| 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-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-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-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-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-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-10 (design #13) | 2026-10-14 | Расхождения спеки и кода из разбора D закрываются правкой спеки, не кода | код разумнее текста спеки (спринты с доски, автообновление от `sync_run`), потребитель один | нет |
|
||||||
| R-11 (design #21) | 2026-10-14 | Агент — версия 4, `min_agent_version` не поднимается | поведение сравнения и темпа изменилось; v3 продолжает работать | нет |
|
| R-11 (design #21) | 2026-10-14 | Агент — версия 4, `min_agent_version` не поднимается | поведение сравнения и темпа изменилось; v3 продолжает работать | нет |
|
||||||
|
|||||||
@ -134,8 +134,9 @@
|
|||||||
ошибки в `body`;
|
ошибки в `body`;
|
||||||
- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог.
|
- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог.
|
||||||
|
|
||||||
При старте потока реле (первая подготовка папки) SHALL удалить файлы из `work/` с записью в лог. Повторная подготовка
|
При первой подготовке каждой папки после старта реле (в том числе новой папки после смены `relay_dir`) реле SHALL
|
||||||
(смена `relay_dir`, пропавший подкаталог) SHALL только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту
|
удалить файлы из `work/` с записью в лог. Повторная подготовка той же папки (пропал подкаталог, не было доступа) SHALL
|
||||||
|
только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту
|
||||||
реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку
|
реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку
|
||||||
старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay <METHOD> <path> <status>
|
старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay <METHOD> <path> <status>
|
||||||
<секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются.
|
<секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user