diff --git a/src/sdlc_hub/modules/wiki/commands.py b/src/sdlc_hub/modules/wiki/commands.py index 6084c97..f01509a 100644 --- a/src/sdlc_hub/modules/wiki/commands.py +++ b/src/sdlc_hub/modules/wiki/commands.py @@ -47,16 +47,38 @@ def validate(cmds) -> list[dict]: return out +def _active_same_root(conn, label: str, root: str) -> int | None: + """id самого нового пакета проекта с wiki_sync того же корня в queued/claimed или None.""" + row = conn.execute( + "SELECT batch_id FROM command WHERE type = 'wiki_sync' AND status IN ('queued', 'claimed') " + "AND json_extract(params, '$.label') = ? AND json_extract(params, '$.root') = ? ORDER BY batch_id DESC LIMIT 1", + (label, root)).fetchone() + return row["batch_id"] if row else None + + def check_batch(conn, cmds, project, files_dir) -> list[dict]: - """wiki_sync живёт в пакете своего проекта: project пакета равен label команды.""" + """wiki_sync живёт в пакете своего проекта: project пакета равен label команды. Вторая живая синхронизация того же + корня не ставится — ни через POST /batches, ни повтором команды (T8.3); другой корень не мешает (смена корня).""" for i, c in enumerate(cmds, 1): - if c["type"] == "wiki_sync" and c["params"]["label"] != project: + if c["type"] != "wiki_sync": + continue + if c["params"]["label"] != project: raise CommandError(f"команда {i}: проект пакета ({project}) не равен label ({c['params']['label']})") + bid = _active_same_root(conn, project, c["params"]["root"]) + if bid is not None: + raise CommandError(f"команда {i}: у проекта {project} уже идёт синхронизация корня {c['params']['root']} " + f"(пакет #{bid})") return cmds def enqueue_sync(conn, label: str, root: str, author: str) -> dict: - """Пакет «Вики <метка>» из одной команды wiki_sync.""" + """Пакет «Вики <метка>» из одной команды wiki_sync. Идёт синхронизация того же корня (возврат корня A → B → A, + пока A у агента) — её пакет в той же форме {id, estimate_seconds, count}, нового нет.""" + bid = _active_same_root(conn, label, root) + if bid is not None: + row = conn.execute("SELECT b.estimate_seconds, (SELECT COUNT(*) FROM command c WHERE c.batch_id = b.id) AS n " + "FROM batch b WHERE b.id = ?", (bid,)).fetchone() + return {"id": bid, "estimate_seconds": row["estimate_seconds"], "count": row["n"]} return queue.create_batch(conn, f"Вики {label}", author, [{"type": "wiki_sync", "params": {"label": label, "root": root}}], project=label) diff --git a/tests/test_wiki_commands.py b/tests/test_wiki_commands.py index 70497be..fbd3866 100644 --- a/tests/test_wiki_commands.py +++ b/tests/test_wiki_commands.py @@ -128,3 +128,32 @@ def test_validate_settings(key, bad, good): def test_wiki_agent_gets_own_keys(conn): out = settings.for_agent(conn, "wiki") assert out == {"wiki_pacing": 0.2, "settings_version": 1} + + +# пакет D (#135): T8.3 — одна синхронизация на корень +SYNC = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-1"}}] + + +def test_check_batch_refuses_second_sync_same_root(conn): + b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") + with pytest.raises(CommandError, match=f"#{b['id']}"): + queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") + + +def test_check_batch_other_root_allowed(conn): + queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") + other = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-2"}}] + assert queue.create_batch(conn, "t", "u", other, project="AWPCA")["count"] == 1 + + +def test_retry_failed_sync_without_active_passes(conn): + b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") + conn.execute("UPDATE command SET status = 'failed' WHERE batch_id = ?", (b["id"],)) + assert queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")["count"] == 1 + + +def test_enqueue_sync_returns_active_same_root(conn): + _root(conn) + first = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u") + again = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u") + assert again == first and _count(conn) == 1 diff --git a/tests/test_wiki_projects.py b/tests/test_wiki_projects.py index fe51f38..aeb396b 100644 --- a/tests/test_wiki_projects.py +++ b/tests/test_wiki_projects.py @@ -190,3 +190,34 @@ def test_create_project_batches(hub_server): c.post("/projects", {"label": "AWPCA", "name": "A"}) patched = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"}) assert patched["batches"] == [{"module": "wiki", "id": patched["batch_ids"][0]}] + + +def test_root_back_while_old_claimed(hub_server): + """A → B → A, пока синхронизация A у агента: PATCH не 422, идущий пакет A остаётся (#135, T8.3).""" + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"}) + conn = hub_server["hub"].conn() + try: + conn.execute("UPDATE command SET status = 'claimed' WHERE type = 'wiki_sync'") + finally: + conn.close() + c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"}) + c.patch("/projects/AWPCA", {"wiki_root": "DOC-12"}) + assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("cancelled", "DOC-21")] + + +def test_retry_while_sync_running_refused(hub_server): + """Повтор упавшей wiki_sync при идущей того же корня — 422 с номером идущего пакета (#135, T8.3).""" + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"}) + conn = hub_server["hub"].conn() + try: + failed = conn.execute("SELECT id FROM command WHERE type = 'wiki_sync'").fetchone()["id"] + conn.execute("UPDATE command SET status = 'failed' WHERE id = ?", (failed,)) + finally: + conn.close() + running = c.post("/wiki/sync", {"project": "AWPCA"})["batch_id"] + with pytest.raises(HubError) as e: + c.post(f"/commands/{failed}/retry", {}) + assert e.value.status == 422 and f"#{running}" in str(e.value) + assert len(_syncs(hub_server["hub"])) == 2