Владелец спросил, откуда 25 каталогов профилей, когда заводил единицы. Проверено исполнением: обращение к get_profile_dir создавало каталог. Спросили четыре пути — появились четыре каталога. В коде зашит список «стандартных» слотов — ag-orch-primary, ag-orch-fallback, ag-1..ag-20, ag-w1..ag-w10 — в двух местах: agy_subprocess и model_discovery_service. Любой обход этого списка материализовал их все. Отсюда ag-w1..ag-w4, ag-cold-*, ag-spare-* — владелец их не создавал. Спросить, где профиль жил бы, и завести его — разные действия. Создание теперь запрашивается явно, create=True, и это делают только те, кто действительно заводит профиль. Тесты и сценарий проверки, опиравшиеся на побочное создание, приведены к новому поведению: они проверяют изоляцию пути, а не то, что каталог возник сам. 669 passed, 2 skipped; ruff чисто; релизный гейт пройден. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6 KiB
Python
176 lines
6 KiB
Python
"""Single Source of Truth for Hermes Hub Filesystem Paths.
|
||
|
||
Ensures zero hardcoded absolute developer directories or usernames.
|
||
Fully respects HERMES_HOME for complete hermetic test isolation.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
|
||
def get_hermes_home() -> Path:
|
||
"""Return base hermes home directory, respecting HERMES_HOME environment variable."""
|
||
env_home = os.environ.get("HERMES_HOME")
|
||
if env_home:
|
||
p = Path(env_home).resolve()
|
||
p.mkdir(parents=True, exist_ok=True)
|
||
return p
|
||
|
||
local_app = os.environ.get("LOCALAPPDATA", "")
|
||
if local_app:
|
||
p = Path(local_app) / "hermes"
|
||
else:
|
||
p = Path.home() / ".hermes"
|
||
p.mkdir(parents=True, exist_ok=True)
|
||
return p
|
||
|
||
|
||
def get_repo_root() -> Path:
|
||
"""Find repository / installation root containing assets and launcher."""
|
||
cur = Path(__file__).resolve()
|
||
for parent in [cur.parents[3], cur.parents[2], cur.parents[1]]:
|
||
if (parent / "assets").exists() or (parent / "pyproject.toml").exists():
|
||
return parent
|
||
# Fallback to plugin directory in hermes home
|
||
plugin_dir = get_hermes_home() / "plugins" / "antigravity-provider"
|
||
if plugin_dir.exists():
|
||
return plugin_dir
|
||
return cur.parents[2]
|
||
|
||
|
||
def get_assets_dir() -> Path:
|
||
return get_repo_root() / "assets"
|
||
|
||
|
||
def get_branding_dir() -> Path:
|
||
return get_assets_dir() / "branding"
|
||
|
||
|
||
def get_providers_assets_dir() -> Path:
|
||
return get_assets_dir() / "providers"
|
||
|
||
|
||
def get_logs_dir() -> Path:
|
||
d = get_hermes_home() / "logs"
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def get_log_file() -> Path:
|
||
return get_logs_dir() / "hermes-hub.log"
|
||
|
||
|
||
def get_startup_log_file() -> Path:
|
||
return get_logs_dir() / "startup.log"
|
||
|
||
|
||
def get_config_dir() -> Path:
|
||
d = get_hermes_home()
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def get_router_profiles_path() -> Path:
|
||
return get_config_dir() / "router_profiles.yaml"
|
||
|
||
|
||
def get_router_state_path() -> Path:
|
||
return get_config_dir() / "router_state.json"
|
||
|
||
|
||
def get_router_active_profile_path() -> Path:
|
||
return get_config_dir() / "router_active_profile.json"
|
||
|
||
|
||
def get_workflow_state_path() -> Path:
|
||
"""Return the persisted agent/workflow state sidecar.
|
||
|
||
Logical roles and their execution routes remain canonical in
|
||
``router_profiles.yaml``. This file stores only the extra agent metadata,
|
||
graph layout and execution checkpoints which do not belong to routing.
|
||
"""
|
||
return get_config_dir() / "workflow_state.json"
|
||
|
||
|
||
def get_workflow_run_state_path() -> Path:
|
||
"""Return the persisted active workflow run state file."""
|
||
return get_config_dir() / "workflow_run_state.json"
|
||
|
||
|
||
def get_agent_files_dir() -> Path:
|
||
"""Return the user-editable directory containing real Agent Files."""
|
||
directory = get_hermes_home() / "agents"
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
return directory
|
||
|
||
|
||
def get_compatibility_path() -> Path:
|
||
return get_config_dir() / "compatibility.json"
|
||
|
||
|
||
def get_profile_dir(
|
||
profile_id: str, provider: Optional[str] = None, create: bool = False
|
||
) -> Path:
|
||
"""Вернуть каталог профиля. По умолчанию НИЧЕГО не создаёт.
|
||
|
||
Раньше эта функция создавала каталог при каждом обращении. В коде зашит
|
||
список «стандартных» слотов — ag-orch-primary, ag-orch-fallback, ag-1..ag-20,
|
||
ag-w1..ag-w10, — и любой обход этого списка материализовал их на диске. У
|
||
владельца накопилось 25 каталогов, из которых он заводил единицы: остальные
|
||
появились сами, просто потому что кто-то спросил путь.
|
||
|
||
Спрашивать, где профиль жил бы, и создавать его — разные действия. Создание
|
||
теперь запрашивается явно через create=True: так делают те, кто
|
||
действительно заводит профиль.
|
||
|
||
Accepts both (profile_id) and (provider, profile_id) or (profile_id, provider) gracefully.
|
||
"""
|
||
# If first argument looks like a provider or swapped, resolve cleanly
|
||
p_id = profile_id
|
||
prov = provider
|
||
|
||
if prov is None:
|
||
p_lower = p_id.lower()
|
||
if p_lower.startswith("ag-") or "antigravity" in p_lower:
|
||
folder_prefix = "agy_profiles"
|
||
elif p_lower.startswith("codex-") or "codex" in p_lower:
|
||
folder_prefix = "codex_profiles"
|
||
elif p_lower.startswith("opengo-") or "opencode" in p_lower:
|
||
folder_prefix = "opengo_profiles"
|
||
elif p_lower.startswith("claude-") or "claude" in p_lower or "anthropic" in p_lower:
|
||
folder_prefix = "claude_profiles"
|
||
elif p_lower.startswith("grok-") or "grok" in p_lower or "xai" in p_lower:
|
||
folder_prefix = "grok_profiles"
|
||
else:
|
||
folder_prefix = f"{p_lower}_profiles"
|
||
else:
|
||
# Provider explicitly passed
|
||
prov_lower = prov.lower()
|
||
if "antigravity" in prov_lower or "agy" in prov_lower:
|
||
folder_prefix = "agy_profiles"
|
||
elif "codex" in prov_lower or "openai" in prov_lower:
|
||
folder_prefix = "codex_profiles"
|
||
elif "opencode" in prov_lower or "opengo" in prov_lower:
|
||
folder_prefix = "opengo_profiles"
|
||
elif "claude" in prov_lower or "anthropic" in prov_lower:
|
||
folder_prefix = "claude_profiles"
|
||
elif "grok" in prov_lower or "xai" in prov_lower:
|
||
folder_prefix = "grok_profiles"
|
||
else:
|
||
folder_prefix = f"{prov_lower}_profiles"
|
||
|
||
d = get_hermes_home() / folder_prefix / p_id
|
||
if create:
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def get_hermes_agent_dir() -> Path:
|
||
return get_hermes_home() / "hermes-agent"
|
||
|
||
|
||
def get_hermes_agent_venv() -> Path:
|
||
return get_hermes_agent_dir() / "venv"
|