fix: правки по финальному ревью bridge-hygiene — права backups/, cwd, чистка work/ по папкам, отмена завершённого пакета (#13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b76152959
commit
8e82b31d48
@ -20,7 +20,7 @@
|
|||||||
| `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`.
|
||||||
|
|||||||
@ -167,4 +167,4 @@
|
|||||||
|
|
||||||
#### Scenario: Повторная подготовка не трогает взятые запросы
|
#### Scenario: Повторная подготовка не трогает взятые запросы
|
||||||
- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/`
|
- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/`
|
||||||
- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «перезапуск реле»
|
- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «удалён незавершённый запрос»
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -117,7 +117,7 @@ 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("."): # путь без каталога — рабочий каталог процесса не трогаем
|
||||||
paths.secure_dir(Path(path).parent)
|
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
|
||||||
@ -137,7 +137,8 @@ def migrate(conn: sqlite3.Connection, path=None) -> None:
|
|||||||
"""Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы
|
"""Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы
|
||||||
(<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный
|
(<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный
|
||||||
connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
|
connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
|
||||||
отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции."""
|
отдельное соединение: 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:
|
||||||
|
|||||||
@ -237,7 +237,7 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
|||||||
log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат",
|
log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат",
|
||||||
command_id, cmd["status"], result["key"])
|
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))
|
||||||
@ -285,7 +285,9 @@ 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))
|
||||||
view = batch_view(conn, batch_id)
|
view = batch_view(conn, batch_id)
|
||||||
# взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12)
|
# взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12)
|
||||||
|
|||||||
@ -61,7 +61,7 @@ class Relay:
|
|||||||
self._cleanup_at = 0.0
|
self._cleanup_at = 0.0
|
||||||
self._ready = False
|
self._ready = False
|
||||||
self._warned: str | None = None
|
self._warned: str | None = None
|
||||||
self._cleaned: Path | None = None # папка, чей work/ вычищен после старта реле
|
self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле
|
||||||
self.workers: list[threading.Thread] = []
|
self.workers: list[threading.Thread] = []
|
||||||
|
|
||||||
# --- цикл ---------------------------------------------------------------------
|
# --- цикл ---------------------------------------------------------------------
|
||||||
@ -124,12 +124,12 @@ 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._cleaned != self.root:
|
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 = self.root
|
self._cleaned.add(self.root)
|
||||||
else:
|
else:
|
||||||
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
|
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
|
||||||
say(f"relay: папка {self.root} подготовлена заново")
|
say(f"relay: папка {self.root} подготовлена заново")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -128,6 +128,22 @@ def test_data_dir_is_private(tmp_path):
|
|||||||
assert _mode(home) == 0o700
|
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):
|
def test_backups_dir_is_private(tmp_path):
|
||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = db.connect(tmp_path / "hub.sqlite")
|
||||||
backup.make_backup(conn, tmp_path / "backups", "batch-1")
|
backup.make_backup(conn, tmp_path / "backups", "batch-1")
|
||||||
|
|||||||
@ -116,6 +116,18 @@ def test_rename_note_merges_with_existing():
|
|||||||
assert mirror.get_note(conn, "new:5") is None
|
assert mirror.get_note(conn, "new:5") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_note_keeps_newer_real_key_stamp():
|
||||||
|
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():
|
def test_rename_note_empty_string_does_not_overwrite():
|
||||||
conn = db.connect(":memory:")
|
conn = db.connect(":memory:")
|
||||||
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
|
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
|
||||||
|
|||||||
@ -435,6 +435,34 @@ def test_empty_full_refresh_keeps_neighbors():
|
|||||||
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
|
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_finished_batch_stays_done():
|
||||||
|
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():
|
def test_redelivered_refresh_resets_counts():
|
||||||
conn = fresh()
|
conn = fresh()
|
||||||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import http.client
|
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
|
||||||
@ -349,7 +350,41 @@ def test_reprepare_keeps_work(env, capsys):
|
|||||||
r.step()
|
r.step()
|
||||||
assert (root / "req").is_dir() and (root / "work" / "busy.json").exists()
|
assert (root / "req").is_dir() and (root / "work" / "busy.json").exists()
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "перезапуск реле" not in out and "подготовлена заново" in 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):
|
def test_relay_targets_serve_host(tmp_path, monkeypatch):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user