"""Агенты модулей: токен определяет модуль, миграция v7 сохраняет агента jira (wiki-module, решения 1–3, 6, 15).""" import os import pytest from sdlc_hub.core import api, cli, db, queue, release, settings from sdlc_hub.core.client import HubError from v6db import make_v6_db def test_v6_upgrade_keeps_jira_agent(tmp_path): make_v6_db(tmp_path / "hub.sqlite") rel = tmp_path / "release" rel.mkdir() (rel / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8") release.publish(rel, rel / "agent.py") hub = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") hub.claim_wait = 0 conn = db.connect(tmp_path / "hub.sqlite") try: tok = settings.tokens(conn)["agents"]["jira"] assert tok == "T-agent" code, body = api.h_claim(hub, conn, None, {"_agent": "jira"}, {"agent_version": 14}) assert code == 200 and body["agent_release"]["version"] == 14 # релиз v14 перенесён в release/jira/ assert db._schema_version(conn) == db.SCHEMA_VERSION tok = settings.tokens(conn) assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone() assert row["agent_version"] == "14" assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 # jira и wiki assert list((tmp_path / "backups").glob("*schema-v6*")) # копия перед миграцией finally: conn.close() def test_v6_upgrade_keeps_settings_and_projects(tmp_path): make_v6_db(tmp_path / "hub.sqlite") conn = db.connect(tmp_path / "hub.sqlite") try: assert settings.get(conn, "min_agent_version") == 1 rows = conn.execute("SELECT label, board_id, board_type FROM jira_project ORDER BY label").fetchall() assert [tuple(r) for r in rows] == [("AWPCA", 4216, "scrum"), ("sfm_kedr", None, None)] finally: conn.close() def test_migrate_adds_status_row_for_new_agent_module(conn_two_modules): """R-1: строки agent_status для модулей реестра вставляются при каждом migrate, а не только в шаге 6 → 7.""" names = {r["agent"] for r in conn_two_modules.execute("SELECT agent FROM agent_status")} assert names == {"jira", "stub"} def test_ensure_defaults_adds_missing_agent_token(conn_two_modules): tok = settings.tokens(conn_two_modules) assert set(tok["agents"]) == {"jira", "stub"} and tok["agents"]["jira"] != tok["agents"]["stub"] def test_agent_token_routes(hub_server): assert hub_server["agent"].post("/agent/claim", {})["command"] is None assert hub_server["agent"].get("/agent/settings")["settings_version"] >= 1 with pytest.raises(HubError) as e: hub_server["client"].post("/agent/claim", {}) assert e.value.status == 401 def test_module_agent_route_requires_own_module(hub_server_two_modules): jira, stub = hub_server_two_modules["agents"]["jira"], hub_server_two_modules["agents"]["stub"] with pytest.raises(HubError) as e: stub.post("/agent/state", {}) assert e.value.status == 401 with pytest.raises(HubError) as e: jira.post("/agent/stub", {}) assert e.value.status == 401 assert stub.post("/agent/stub", {}) == {"agent": "stub"} assert jira.post("/agent/claim", {})["command"] is None # маршруты ядра открыты обоим агентам assert stub.post("/agent/claim", {})["command"] is None def test_agent_settings_per_module(hub_server_two_modules): jira = hub_server_two_modules["agents"]["jira"].get("/agent/settings") stub = hub_server_two_modules["agents"]["stub"].get("/agent/settings") assert "project_key" in jira and "link_types" in jira and "settings_version" in jira assert "transitions_map" not in jira and "status_labels" not in jira assert "stub_key" not in jira and "board_id" not in jira assert set(stub) == {"stub_key", "settings_version"} def test_agent_query_cannot_be_forged(hub_server_two_modules): """Служебный ключ запроса приходит только от диспетчера: _agent в адресе перезаписывается.""" stub = hub_server_two_modules["agents"]["stub"] assert stub.call("POST", "/agent/stub", {}, params={"_agent": "jira"}) == {"agent": "stub"} def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys): monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path)) assert cli.main(["tokens"]) == 0 lines = capsys.readouterr().out.splitlines() assert lines[0].startswith("клиентский токен: ") and lines[1].startswith("агент jira: ") assert lines[2].startswith("агент wiki: ") and lines[3].startswith("база: ") def test_agent_view_min_version_from_module_key(conn_two_modules): conn_two_modules.execute("UPDATE agent_status SET agent_version = '3' WHERE agent = 'jira'") settings.set_(conn_two_modules, "min_agent_version", 5) assert queue.agent_view(conn_two_modules, "jira")["warning"] == "обновите агента" assert queue.agent_view(conn_two_modules, "stub")["warning"] is None # --- очередь, релиз и статус по агенту (Task 2) ------------------------------------------------------------------ def _two_batches(conn): queue.create_batch(conn, "w", "t", [{"type": "stub_cmd", "params": {}}]) queue.create_batch(conn, "j", "t", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}]) def test_wiki_queue_does_not_block_jira(conn_two_modules): conn = conn_two_modules _two_batches(conn) s = queue.claim(conn, "stub") assert s["type"] == "stub_cmd" j = queue.claim(conn, "jira") # stub_cmd ещё claimed, но это очередь другого агента assert j["type"] == "ensure_issue" assert queue.agent_view(conn, "stub")["current_command_id"] == s["id"] assert queue.agent_view(conn, "jira")["current_command_id"] == j["id"] def test_redeliver_only_own(conn_two_modules): conn = conn_two_modules _two_batches(conn) s = queue.claim(conn, "stub") j = queue.claim(conn, "jira") assert queue.claim(conn, "jira")["id"] == j["id"] # довыдача — своя команда, не stub_cmd assert queue.claim(conn, "stub")["id"] == s["id"] assert queue.agent_view(conn, "jira")["redelivered"][0]["id"] == j["id"] assert [r["id"] for r in queue.agent_view(conn, "stub")["redelivered"]] == [s["id"]] def test_agent_version_per_agent(conn_two_modules): conn = conn_two_modules queue.claim(conn, "jira", agent_version=14) assert queue.agent_version(conn, "jira") == "14" and queue.agent_version(conn, "stub") is None queue.claim(conn, "stub", agent_version=1) assert queue.agent_version(conn, "jira") == "14" and queue.agent_version(conn, "stub") == "1" def test_has_active_per_module(conn_two_modules): conn = conn_two_modules assert not queue.has_active(conn) queue.create_batch(conn, "w", "t", [{"type": "stub_cmd", "params": {}}]) assert queue.has_active(conn) and queue.has_active(conn, "stub") and not queue.has_active(conn, "jira") def _publish(root, module, tmp_path, version): src = tmp_path / f"{module}{version}.py" src.write_text(f"AGENT_VERSION = {version}\n", encoding="utf-8") return release.publish(release.module_dir(root, module), src) def test_release_per_module(hub_server_two_modules, tmp_path): root = hub_server_two_modules["hub"].release_root _publish(root, "jira", tmp_path, 14) _publish(root, "stub", tmp_path, 1) assert release.module_dir(root, "stub") == root / "stub" and oct(release.module_dir(root, "stub").stat().st_mode & 0o777) == "0o700" jira, stub = hub_server_two_modules["agents"]["jira"], hub_server_two_modules["agents"]["stub"] assert jira.post("/agent/claim", {})["agent_release"]["version"] == 14 assert stub.post("/agent/claim", {})["agent_release"]["version"] == 1 assert jira.get("/agent/release")["version"] == 14 and stub.get("/agent/release")["version"] == 1 def test_release_layout_migration(tmp_path): root = tmp_path / "release" root.mkdir() (root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8") release.publish(root, root / "agent.py") # старая раскладка: agent.py и manifest.json в корне hub = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") assert not (root / "agent.py").exists() and not (root / "manifest.json").exists() assert release.current(hub.release_root / "jira")["version"] == 14 api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") # повторный старт ничего не ломает assert release.current(root / "jira")["version"] == 14 def test_migrate_layout_failure_is_warning(tmp_path, monkeypatch, caplog): root = tmp_path / "release" root.mkdir() (root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8") real = os.replace monkeypatch.setattr("os.replace", lambda *a, **k: (_ for _ in ()).throw(OSError(13, "нет прав"))) release.migrate_layout(root) # без исключения assert any("release" in r.getMessage() for r in caplog.records) assert (root / "agent.py").exists() monkeypatch.setattr("os.replace", real) def test_migrate_layout_resumes_after_partial_failure(tmp_path, monkeypatch): root = tmp_path / "release" root.mkdir() (root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8") release.publish(root, root / "agent.py") real, calls = os.replace, [] def flaky(a, b, *x, **k): calls.append(a) if len(calls) == 2: raise OSError(5, "сбой") return real(a, b, *x, **k) monkeypatch.setattr("os.replace", flaky) release.migrate_layout(root) assert (root / "jira" / "agent.py").exists() and not (root / "jira" / "manifest.json").exists() assert (root / "manifest.json").exists() and not (root / "agent.py").exists() monkeypatch.setattr("os.replace", real) release.migrate_layout(root) # повторный старт доделывает assert release.current(root / "jira")["version"] == 14 assert not (root / "manifest.json").exists() and not (root / "agent.py").exists() def test_release_dirs_are_private(tmp_path): root = tmp_path / "release" root.mkdir(mode=0o755) (root / "jira").mkdir(mode=0o755) api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") assert root.stat().st_mode & 0o777 == 0o700 and (root / "jira").stat().st_mode & 0o777 == 0o700 def test_publish_unknown_module(tmp_path, monkeypatch, capsys): monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) src = tmp_path / "agent.py" src.write_text("AGENT_VERSION = 1\n", encoding="utf-8") assert cli.main(["agent", "publish", "--module", "nope", str(src)]) == 2 assert "неизвестный модуль nope; модули с агентами: jira" in capsys.readouterr().err def test_publish_module_option(tmp_path, monkeypatch, capsys): monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) src = tmp_path / "agent.py" src.write_text("AGENT_VERSION = 1\n", encoding="utf-8") assert cli.main(["agent", "publish", "--module", "jira", str(src)]) == 0 assert (tmp_path / "home" / "release" / "jira" / "manifest.json").exists() def test_agents_endpoint(hub_server_two_modules): c = hub_server_two_modules["client"] agents = c.get("/agents")["agents"] assert [a["module"] for a in agents] == ["jira", "stub"] assert c.get("/agent")["module"] == "jira" and c.get("/agent", module="stub")["module"] == "stub" with pytest.raises(HubError) as e: c.get("/agent", module="nope") assert e.value.status == 404 and e.value.error == "not_found" def test_wiki_agent_token_after_upgrade(tmp_path): """Миграция v6: токен jira сохранён, токен wiki создан; строка agent_status есть у обоих.""" make_v6_db(tmp_path / "hub.sqlite") conn = db.connect(tmp_path / "hub.sqlite") try: settings.ensure_defaults(conn) agents = settings.tokens(conn)["agents"] assert agents["jira"] == "T-agent" and agents["wiki"] and agents["wiki"] != agents["jira"] names = {r["agent"] for r in conn.execute("SELECT agent FROM agent_status")} assert names == {"jira", "wiki"} finally: conn.close() def test_agent_cannot_touch_foreign_command(hub_server_two_modules): """I-1: токен модуля не читает и не закрывает команды чужого модуля.""" h = hub_server_two_modules jira, stub = h["agents"]["jira"], h["agents"]["stub"] conn = h["hub"].conn() try: queue.create_batch(conn, "w", "t", [{"type": "stub_cmd", "params": {}}]) queue.create_batch(conn, "j", "t", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}]) finally: conn.close() jc = jira.post("/agent/claim", {})["command"] sc = stub.post("/agent/claim", {})["command"] assert jc["type"] == "ensure_issue" and sc["type"] == "stub_cmd" for call in (lambda: stub.get(f"/agent/commands/{jc['id']}"), lambda: stub.post(f"/agent/commands/{jc['id']}/result", {"status": "failed"}), lambda: jira.get(f"/agent/commands/{sc['id']}"), lambda: jira.post(f"/agent/commands/{sc['id']}/result", {"status": "failed"})): with pytest.raises(HubError) as e: call() assert e.value.status == 404 assert jira.get(f"/agent/commands/{jc['id']}")["id"] == jc["id"] assert stub.post(f"/agent/commands/{sc['id']}/result", {"status": "done", "result": {}})["status"] == "done" def test_v14_agent_over_http_on_v6_base(tmp_path): """I-3: база v6 → HTTP-сервер → прежний токен T-agent в форме запросов jira-agent v14, без прямых вызовов обработчиков.""" import json import threading from pathlib import Path from sdlc_hub.core.client import HubClient make_v6_db(tmp_path / "hub.sqlite") old = tmp_path / "release" # прежняя раскладка v14: release/agent.py old.mkdir() (old / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8") release.publish(old, old / "agent.py") h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups", release_root=old) h.claim_wait = 0 srv = api.make_server(h, "127.0.0.1", 0) th = threading.Thread(target=srv.serve_forever, daemon=True) th.start() try: a = HubClient(f"http://127.0.0.1:{srv.server_port}", "T-agent") out = a.post("/agent/claim", {"agent_version": 14, "settings_version": 1, "last_jira_call_at": "2026-10-24T10:00:00+00:00", "update_state": None, "launcher_version": 2}) assert out["command"] is None and out["agent_release"]["version"] == 14 conn = h.conn() try: row = conn.execute("SELECT last_call_at, agent_version FROM agent_status WHERE agent = 'jira'").fetchone() assert row["last_call_at"] == "2026-10-24T10:00:00+00:00" and row["agent_version"] == "14" finally: conn.close() expected = json.loads((Path(__file__).parent / "fixtures" / "settings_v6.json").read_text(encoding="utf-8"))["agent_keys"] got = a.get("/agent/settings") assert set(got) == set(expected) - {"board_id", "status_labels", "transitions_map"} # fact-status-removal assert a.get("/agent/release")["version"] == 14 assert a.post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "labels": []}], "links": [], "neighbor_keys": []}) == {"issues": 1, "links": 0} assert a.get("/agent/known-keys")["keys"] == ["A-1"] item = {"history_id": "5001", "seq": 0, "key": "A-1", "created": "2026-10-22T09:00:00.000+0300", "author": "П", "field_id": "status", "field_name": "status", "from": "3", "from_string": "a", "to": "4", "to_string": "b"} assert a.post("/agent/history", {"command_id": None, "changes": [item]}) == {"accepted": 1, "duplicates": 0} finally: srv.shutdown() srv.server_close() th.join(5)