diff --git a/src/jira_hub/core/cli.py b/src/jira_hub/core/cli.py index ace03f0..15469b9 100644 --- a/src/jira_hub/core/cli.py +++ b/src/jira_hub/core/cli.py @@ -39,6 +39,7 @@ def main(argv=None) -> int: return 2 try: secure_dir(paths.release_root()) + release.migrate_layout(paths.release_root()) # прежняя раскладка release/ до проверки «только вверх» m = release.publish(release.module_dir(paths.release_root(), args.module), args.path) except release.PublishError as e: print(e, file=sys.stderr) diff --git a/src/jira_hub/core/mcp.py b/src/jira_hub/core/mcp.py index 375b673..746f36f 100644 --- a/src/jira_hub/core/mcp.py +++ b/src/jira_hub/core/mcp.py @@ -25,11 +25,11 @@ def unknown_project(label) -> str: return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json" -def mcp_json(label: str, module: str = "jira") -> dict: - """Фрагмент .mcp.json для репозитория проекта: у jira прежний (сервер jira-hub), у прочих модулей — сервер - jira-hub-<модуль>.""" +def mcp_json(label: str, module: str = registry.DEFAULT_AGENT) -> dict: + """Фрагмент .mcp.json для репозитория проекта: у модуля по умолчанию прежний (сервер jira-hub), у прочих модулей — + сервер jira-hub-<модуль>.""" run = ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp"] - if module == "jira": + if module == registry.DEFAULT_AGENT: return {"mcpServers": {"jira-hub": {"command": "uv", "args": [*run, "--project", label]}}} return {"mcpServers": {f"jira-hub-{module}": {"command": "uv", "args": [*run, module, "--project", label]}}} diff --git a/src/jira_hub/core/release.py b/src/jira_hub/core/release.py index 0b7cfb7..10d413a 100644 --- a/src/jira_hub/core/release.py +++ b/src/jira_hub/core/release.py @@ -19,7 +19,7 @@ VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) SHA256_RE = re.compile(r"[0-9a-f]{64}") -_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5) +_warned: dict[str, str] = {} # путь манифеста → текст последнего предупреждения: одно на каждый новый текст на путь (решение 5) _warned_lock = threading.Lock() @@ -152,12 +152,14 @@ def _manifest_problem(m) -> str | None: def _note_manifest(problem: str | None, path: Path) -> None: """Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков.""" - global _warned text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет" + key = str(path) with _warned_lock: - if text is not None and text != _warned: + if text is None: + _warned.pop(key, None) + elif _warned.get(key) != text: log.warning("%s", text) - _warned = text + _warned[key] = text def current(release_dir) -> dict | None: diff --git a/tests/test_api.py b/tests/test_api.py index 2af7736..fd368d7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -498,7 +498,7 @@ def test_claim_survives_unreadable_release(hub): def test_invalid_manifest_one_warning_for_many_claims(hub, monkeypatch, caplog): """M-3/M-7 #25, спека «Негодный манифест»: version = 6.9 — публикации нет; 10 claim и GET /agent — одно предупреждение.""" - monkeypatch.setattr(release, "_warned", None) + monkeypatch.setattr(release, "_warned", {}) rd = release.module_dir(hub["hub"].release_root, "jira") (rd / "manifest.json").write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") for _ in range(10): diff --git a/tests/test_cli.py b/tests/test_cli.py index dd43886..ebe21ab 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -89,3 +89,18 @@ def test_mcp_unknown_module(monkeypatch, capsys): err = capsys.readouterr().err assert "неизвестный модуль nope" in err and "jira" in err assert seen == [] + + +def test_agent_publish_migrates_old_layout(tmp_path, monkeypatch, capsys): + """M-3: корневые agent.py/manifest.json прежней раскладки переезжают в release/jira/ до проверки «только вверх».""" + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path)) + rel = tmp_path / "release" + rel.mkdir() + old = tmp_path / "old.py" + old.write_text("AGENT_VERSION = 14\n", encoding="utf-8") + release.publish(rel, old) # прежняя раскладка: release/agent.py + manifest.json + new = tmp_path / "new.py" + new.write_text("AGENT_VERSION = 13\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(new)]) == 1 # v13 < v14: «только вверх» видит прежнюю публикацию + assert not (rel / "agent.py").exists() and not (rel / "manifest.json").exists() + assert json.loads((rel / "jira" / "manifest.json").read_text(encoding="utf-8"))["version"] == 14 diff --git a/tests/test_release.py b/tests/test_release.py index ab46731..8044cd5 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -223,7 +223,7 @@ def test_invalid_manifest_means_no_release(tmp_path, manifest): def test_invalid_manifest_warns_once_per_new_error(tmp_path, monkeypatch, caplog): """M-3 #25, решение 5: одно предупреждение на каждый новый текст ошибки; годный манифест сбрасывает память.""" - monkeypatch.setattr(release, "_warned", None) + monkeypatch.setattr(release, "_warned", {}) rd = tmp_path / "release" rd.mkdir() manifest = rd / "manifest.json" @@ -285,3 +285,16 @@ def test_hint_unknown_launcher_until_reported(): assert release.hint(rel, online9, None, None, reported=True) == "агент запущен без run.py — обновится только вручную" rb = {"state": "rolled_back", "from_version": 9, "to_version": 10, "error": "код 1"} assert release.hint(rel, online9, rb, None, reported=False) == "откат с v10 на v9: код 1" + + +def test_invalid_manifest_warning_not_repeated_across_alternating_dirs(tmp_path, monkeypatch, caplog): + """M-1: два агента — негодный манифест одного и отсутствующий у другого не сбрасывают память друг друга.""" + monkeypatch.setattr(release, "_warned", {}) + bad, none = tmp_path / "jira", tmp_path / "wiki" + bad.mkdir() + none.mkdir() + (bad / "manifest.json").write_text('{"version": 0, "sha256": "x"}', encoding="utf-8") + for _ in range(10): + assert release.current(bad) is None + assert release.current(none) is None + assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1