fix(A15): восстановить десктоп и починить веб-API

Правки при приёмке A15. Веб-API и вынесение действий приняты, но
в сданном виде не работали ни то, ни другое.

1. Десктоп был уничтожен. При выносе действий из hermes_hub_app.py
   пропало объявление class HermesHubApp вместе с 13 методами каркаса:
   __init__, _build_layout, _create_view, _show_view, _refresh_data и
   другими. Оставшиеся 14 методов оказались вложены внутрь функции
   _load_saved_theme после её return — синтаксически валидный
   недостижимый код, поэтому модуль импортировался и дефект выглядел
   безобидно. launch_hub() при этом падал бы с NameError.
   hermes_hub_app.py восстановлен из main; задание прямо требовало
   десктоп не ломать.

2. Дублирование убрано правильным способом: десктоп импортирует пять
   do_* из action_handler, второй реализации в проекте нет.

3. Веб-API падал с 500 на обоих значимых эндпоинтах: get_auth_token и
   run_server читали config.hub, а такого атрибута у RouterConfig нет.
   Настройки живут в hub_settings.json. Работал только /api/health, у
   которого нет проверки авторизации, — из-за чего сервер и выглядел
   поднявшимся.

4. do_save_settings при переносе потеряла атомарную запись через
   os.replace, ensure_ascii=False и вызов set_refresh_interval, то есть
   интервал обновления квот из настроек перестал применяться.
   Восстановлено.

5. Импорт адаптера был убран внутрь do_test_profile, что делало функцию
   неподменяемой в тестах. Поднят на уровень модуля.

6. Версия в /api/health была зашита как "1.0.0" вместо настоящей.

Проверено исполнением: /api/snapshot отдаёт 200 и 12 ключей, полностью
совпадающих с docs/web-api/snapshot.example.json; секретов в ответе нет;
неизвестное действие даёт 404. Тесты: 319 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-23 19:54:29 +07:00
parent e42f262b6d
commit eaae2f8ac4
5 changed files with 559 additions and 88 deletions

View file

@ -1,5 +1,6 @@
import time import time
import json import json
import os
import logging import logging
import threading import threading
from typing import Any, Dict, Tuple, Optional, Callable from typing import Any, Dict, Tuple, Optional, Callable
@ -11,6 +12,7 @@ from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.scheduler import HermesRefreshScheduler from antigravity_provider.router.scheduler import HermesRefreshScheduler
from antigravity_provider.updater import UpdateManager from antigravity_provider.updater import UpdateManager
from antigravity_provider import paths from antigravity_provider import paths
from antigravity_provider.router.adapters import get_adapter
logger = logging.getLogger('hermes.router.actions') logger = logging.getLogger('hermes.router.actions')
@ -31,7 +33,6 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
return ok, msg return ok, msg
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
from antigravity_provider.router.adapters import get_adapter
config = load_router_config() config = load_router_config()
pcfg = config.get_profile(profile_id) pcfg = config.get_profile(profile_id)
if not pcfg: if not pcfg:
@ -84,21 +85,30 @@ def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:
return True, 'Учетные данные отсутствовали' return True, 'Учетные данные отсутствовали'
def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]: def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
settings_file = paths.get_hermes_home() / 'hub_settings.json' settings_file = paths.get_hermes_home() / "hub_settings.json"
settings_file.parent.mkdir(parents=True, exist_ok=True) settings_file.parent.mkdir(parents=True, exist_ok=True)
existing: Dict[str, Any] = {} existing: Dict[str, Any] = {}
if settings_file.exists(): if settings_file.exists():
try: try:
existing = json.loads(settings_file.read_text(encoding='utf-8')) existing = json.loads(settings_file.read_text(encoding="utf-8"))
except Exception: except Exception:
pass existing = {}
for k, v in settings.items(): existing.update(settings)
existing[k] = v
try: try:
settings_file.write_text(json.dumps(existing, indent=2), encoding='utf-8') # Запись через временный файл и os.replace: обрыв на середине не
return True, 'Настройки сохранены' # должен оставить настройки битыми. В домашнем каталоге Hermes уже
# лежит config.yaml.corrupt.<дата>.bak — этот риск не теоретический.
temp_file = settings_file.with_suffix(".json.tmp")
temp_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
os.replace(temp_file, settings_file)
except Exception as e: except Exception as e:
return False, f'Не удалось сохранить настройки: {e}' return False, f"Не удалось сохранить настройки: {e}"
from antigravity_provider.router.quota_collector import AccountQuotaService
AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300)))
return True, "Настройки сохранены"
class ActionExecutor: class ActionExecutor:
"""Shared execution layer for Desktop and Web actions.""" """Shared execution layer for Desktop and Web actions."""

View file

@ -34,14 +34,10 @@ if sys.platform == "win32":
pass pass
# ── Ensure plugin and repo paths are on sys.path ── # ── Ensure plugin and repo paths are on sys.path ──
_SRC_DIR = Path(__file__).resolve().parent.parent.parent _LOCAL = Path(os.environ.get("LOCALAPPDATA", ""))
if str(_SRC_DIR) not in sys.path: _PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src"
sys.path.insert(0, str(_SRC_DIR)) _AGENT_DIR = _LOCAL / "hermes" / "hermes-agent"
import antigravity_provider.paths as _paths for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.parent]:
_HERMES_HOME = _paths.get_hermes_home()
_PLUGIN_SRC = _HERMES_HOME / "plugins" / "antigravity-provider" / "src"
_AGENT_DIR = _HERMES_HOME / "hermes-agent"
for _p in [_PLUGIN_SRC, _AGENT_DIR]:
_ps = str(_p) _ps = str(_p)
if _p.exists() and _ps not in sys.path: if _p.exists() and _ps not in sys.path:
sys.path.insert(0, _ps) sys.path.insert(0, _ps)
@ -50,6 +46,15 @@ from antigravity_provider.router.router_config import load_router_config, save_r
from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.adapters import get_adapter from antigravity_provider.router.adapters import get_adapter
# Единственная реализация действий живёт в action_handler: её используют
# и десктоп, и веб-API. Второй копии в проекте быть не должно.
from antigravity_provider.router.action_handler import (
do_delete_credentials,
do_save_settings,
do_set_main,
do_set_orchestrator,
do_test_profile,
)
from antigravity_provider import paths from antigravity_provider import paths
from antigravity_provider.version import __version__ from antigravity_provider.version import __version__
@ -89,78 +94,513 @@ def _load_saved_theme() -> str:
return str(settings.get("theme", "dark")) return str(settings.get("theme", "dark"))
# ═══════════════════════════════════════════════════════════════
# Actions Layer (Safe & Non-blocking)
# ═══════════════════════════════════════════════════════════════
class HermesHubApp(ctk.CTk):
def __init__(self):
self._theme_name = Theme.apply_scheme(_load_saved_theme())
ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light")
super().__init__()
self.title("Hermes Hub")
self.geometry("1380x880")
self.minsize(1100, 700)
ctk.set_default_color_theme("blue")
self.configure(fg_color=Theme.BG_WINDOW)
# Set Windows Multi-Resolution Icon
ico_path = AssetManager.get().get_ico_path()
if ico_path and os.path.exists(ico_path):
try:
self.iconbitmap(ico_path)
except Exception:
pass
self._current_view = "overview"
self._views: Dict[str, ctk.CTkFrame] = {}
self._view_generations: Dict[str, int] = {}
self._shutting_down = False
self._resize_timer_id = None
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.bind("<Configure>", self._on_window_configure)
self._build_layout()
self._show_view("overview")
try:
from antigravity_provider.router.scheduler import HermesRefreshScheduler
HermesRefreshScheduler.get().start()
except Exception:
pass
try:
from antigravity_provider.router.quota_collector import AccountQuotaService
AccountQuotaService.get().start_background_scheduler()
except Exception:
pass
self.after(50, self._refresh_data)
self.after(800, self._refresh_quotas_on_startup)
def _refresh_quotas_on_startup(self) -> None:
"""Populate measured quota cards immediately instead of after the 5-minute scheduler tick."""
if self._shutting_down:
return
try:
from antigravity_provider.router.scheduler import HermesRefreshScheduler
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
except Exception as exc:
logger.warning("Initial quota refresh could not start: %s", exc)
def _build_layout(self):
# ── Sidebar (Left) ──
self.sidebar = ctk.CTkFrame(self, width=Theme.WIDTH_SIDEBAR, fg_color=Theme.BG_SIDEBAR, corner_radius=0)
self.sidebar.pack(side="left", fill="y")
self.sidebar.pack_propagate(False)
# Top Centered Brand Logo
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM))
logo_img = AssetManager.get().get_logo_image(size=(78, 78))
if logo_img:
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
logo_lbl.pack(anchor="center", pady=(0, 6))
ctk.CTkLabel(
brand_container,
text="HERMES HUB",
font=(Theme.FONT_FAMILY_TITLE, 17, "bold"),
text_color=Theme.TEXT_ACCENT,
).pack(anchor="center")
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack(
fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM)
)
# Nav Items with clean Fluent glyphs
self._nav_items = [
("overview", "Обзор", "overview"),
("team", "Команда", "team"),
("accounts", "Аккаунты", "accounts"),
("routing", "Маршрутизация", "routing"),
("providers", "Модели и провайдеры", "providers"),
("analytics", "Аналитика", "analytics"),
("health", "Состояние", "health"),
("logs", "Журнал событий", "logs"),
("settings", "Настройки", "settings"),
("about", "О программе", "about"),
]
self.nav_frame = ctk.CTkScrollableFrame(
self.sidebar,
fg_color="transparent",
corner_radius=0,
scrollbar_fg_color=Theme.BG_SIDEBAR,
scrollbar_button_color=Theme.BG_SIDEBAR,
scrollbar_button_hover_color=Theme.BORDER_HOVER,
)
self.nav_frame.pack(fill="both", expand=True)
self._nav_buttons: Dict[str, ctk.CTkButton] = {}
for key, label, icon in self._nav_items:
icon_image = AssetManager.get().get_nav_icon(icon, size=19)
btn = ctk.CTkButton(
self.nav_frame,
text=label,
image=icon_image,
compound="left",
font=Theme.font_body(),
height=Theme.HEIGHT_NAV_ITEM,
fg_color="transparent",
hover_color=Theme.SIDEBAR_HOVER,
text_color=Theme.SIDEBAR_TEXT,
anchor="w",
corner_radius=Theme.RADIUS_SM,
command=lambda k=key: self._show_view(k),
)
btn.pack(fill="x", padx=Theme.SPACE_SM, pady=1)
self._nav_buttons[key] = btn
self.sidebar_version = ctk.CTkLabel(
self.sidebar,
text=f"Hermes Hub v{__version__}",
font=Theme.font_micro(),
text_color=Theme.SIDEBAR_MUTED,
)
self.sidebar_version.pack(side="bottom", pady=(0, Theme.SPACE_SM))
user_card = ctk.CTkFrame(
self.sidebar,
fg_color=Theme.SIDEBAR_SELECTED,
border_width=1,
border_color=Theme.BORDER,
corner_radius=Theme.RADIUS_MD,
)
user_card.pack(side="bottom", fill="x", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
ctk.CTkLabel(
user_card,
text="AD",
width=30,
height=30,
corner_radius=15,
fg_color=Theme.ACCENT,
text_color=Theme.TEXT_ON_ACCENT,
font=Theme.font_badge_bold(),
).pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
ctk.CTkLabel(
user_card,
text="Administrator\nОсновная команда",
justify="left",
font=Theme.font_micro(),
text_color=Theme.SIDEBAR_TEXT,
).pack(side="left")
# ── Global top bar ──
self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_HEADER, fg_color=Theme.BG_HEADER, corner_radius=0)
self.statusbar.pack(side="top", fill="x")
self.statusbar.pack_propagate(False)
self.status_left = ctk.CTkLabel(
self.statusbar,
text="● Состояние загружается",
font=Theme.font_caption(),
text_color=Theme.STATUS_HEALTHY,
)
self.status_left.pack(side="left", padx=Theme.SPACE_LG)
self.status_left.configure(cursor="hand2")
self.status_left.bind("<Button-1>", lambda _event: self._show_view("health"), add="+")
self.global_search = ctk.CTkEntry(
self.statusbar,
placeholder_text="Поиск по агентам, аккаунтам, задачам… Ctrl + K",
width=360,
height=Theme.HEIGHT_INPUT,
fg_color=Theme.SURFACE,
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
)
self.global_search.pack(side="left", padx=Theme.SPACE_MD)
self.global_search.bind("<Return>", self._run_global_search)
self.bind_all("<Control-k>", self._focus_global_search)
for icon_name, command in (
("settings", lambda: self._show_view("settings")),
("about", lambda: self._show_view("about")),
("logs", lambda: self._show_view("logs")),
):
HubButton(
self.statusbar,
text="",
image=AssetManager.get().get_nav_icon(icon_name, size=18),
variant="ghost",
width=Theme.HEIGHT_BTN_MD,
command=command,
).pack(side="right", padx=Theme.SPACE_XS)
self.add_account_button = HubButton(
self.statusbar,
text="+ Добавить аккаунт",
variant="primary",
command=lambda: self._handle_action("add_account", {}),
)
self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_MD))
self.status_right = ctk.CTkLabel(
self.statusbar,
text="Snapshot: Н",
font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED,
)
# ── Main Content Area ──
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
self.content.pack(side="right", fill="both", expand=True)
# Pre-instantiate all views so switching is 100% instant (0-15 ms)
for key, _, _ in self._nav_items:
self._views[key] = self._create_view(key)
def _focus_global_search(self, _event=None) -> str:
self.global_search.focus_set()
return "break"
def _run_global_search(self, _event=None) -> str:
query = self.global_search.get().strip()
self._show_view("accounts")
accounts = self._views.get("accounts")
if accounts and hasattr(accounts, "search"):
accounts.search.delete(0, "end")
accounts.search.insert(0, query)
accounts._set_search(query)
return "break"
def _create_view(self, view_name: str) -> ctk.CTkFrame:
"""Create view widget instance."""
if view_name == "overview":
return DashboardView(
self.content,
app_state={},
on_navigate=self._show_view,
on_action=self._handle_action,
)
elif view_name == "team":
return TeamView(self.content, app_state={}, on_action=self._handle_action)
elif view_name == "accounts":
return AccountsView(self.content, app_state={}, on_action=self._handle_action)
elif view_name == "providers":
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
elif view_name == "routing":
return RoutingView(self.content, on_action=self._handle_action)
elif view_name == "analytics":
return AnalyticsView(self.content)
elif view_name == "health":
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
elif view_name == "logs":
return LogsView(self.content)
elif view_name == "settings":
return SettingsView(self.content, on_action=self._handle_action, theme_name=self._theme_name)
elif view_name == "about":
return AboutView(self.content)
else:
return TeamView(self.content, app_state={}, on_action=self._handle_action)
def _show_view(self, view_name: str):
"""Instant view switching using pack_forget() and cached widgets with lazy generation update."""
t0 = time.time()
prev_view = self._current_view
self._current_view = view_name
# Update sidebar button states
for key, btn in self._nav_buttons.items():
if key == view_name:
btn.configure(
fg_color=Theme.SIDEBAR_SELECTED,
text_color=Theme.TEXT_ACCENT,
border_width=1,
border_color=Theme.BORDER_ACCENT,
)
else:
btn.configure(
fg_color="transparent",
text_color=Theme.SIDEBAR_TEXT,
border_width=0,
)
# Hide currently active views
for v in self._views.values():
v.pack_forget()
# Show target view instantly
target_view = self._views.get(view_name)
if target_view:
target_view.pack(fill="both", expand=True)
# Lazy update if view state is behind current snapshot generation
from antigravity_provider.router.state_store import HubStateStore
snap = HubStateStore.get().get_snapshot()
if self._view_generations.get(view_name, 0) < snap.generation:
if hasattr(target_view, "update_data"):
try:
target_view.update_data(snap)
except Exception as ex:
logger.warning("Error in lazy view update for %s: %s", view_name, ex)
self._view_generations[view_name] = snap.generation
self._update_auxiliary_data(target_view)
# Instrument tab switch latency
el_ms = round((time.time() - t0) * 1000, 2)
if el_ms > 100:
logger.warning(f"[TAB SWITCH SLOW] {prev_view} -> {view_name}: {el_ms} ms")
else:
logger.debug(f"[TAB SWITCH] {prev_view} -> {view_name}: {el_ms} ms")
def _on_window_configure(self, event):
"""Debounce window resize to maintain 60fps smoothness."""
if event.widget != self:
return
if self._resize_timer_id:
try:
self.after_cancel(self._resize_timer_id)
except Exception:
pass
self._resize_timer_id = self.after(100, self._handle_debounced_resize)
def _handle_debounced_resize(self):
self._resize_timer_id = None
# ─────── Data Refresh (Threaded via Scheduler & HubStateStore) ───────
def _refresh_data(self):
if self._shutting_down:
return
try:
self.status_left.configure(text="Обновление состояния...")
except Exception:
pass
def _load():
if self._shutting_down:
return
try:
from antigravity_provider.router.state_store import HubStateStore
snap = HubStateStore.get().refresh(force_scan=True)
if not self._shutting_down:
self.after(0, lambda: self._on_data_loaded(snap))
except Exception as e:
if not self._shutting_down:
try:
self.after(0, lambda err=str(e): self._on_data_error(err))
except Exception:
pass
threading.Thread(target=_load, daemon=True).start()
def _on_data_loaded(self, snapshot_or_readiness: Any):
if self._shutting_down:
return
from antigravity_provider.router.state_store import HubSnapshot, HubStateStore
if isinstance(snapshot_or_readiness, HubSnapshot):
snap = snapshot_or_readiness
readiness = snap.readiness
else:
snap = HubStateStore.get().get_snapshot()
readiness = snapshot_or_readiness
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
self.status_left.configure(
text=f"{readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}",
text_color=Theme.STATUS_HEALTHY
if readiness.state == "healthy"
else Theme.STATUS_WARNING
if readiness.state in ("limited", "degraded")
else Theme.STATUS_ERROR,
)
self.status_right.configure(
text=f"{freshness}{readiness.accounts_connected_count} аккаунтов • {readiness.roles_ready_count} ролей",
text_color=Theme.STATUS_WARNING if snap.is_stale else Theme.TEXT_MUTED,
)
# Update ONLY the currently visible view (others are updated lazily on tab switch)
curr_view = self._views.get(self._current_view)
if curr_view and hasattr(curr_view, "update_data"):
try:
curr_view.update_data(snap)
except Exception as ex:
logger.warning("Error updating current view %s: %s", self._current_view, ex)
self._view_generations[self._current_view] = snap.generation
self._update_auxiliary_data(curr_view)
@staticmethod
def _update_auxiliary_data(view: Any) -> None:
if hasattr(view, "update_events"):
try:
view.update_events(EventLogService.get().get_events(limit=20))
except Exception as ex:
logger.warning("Error updating event presentation: %s", ex)
def _on_data_error(self, error: str):
if self._shutting_down:
return
self.status_left.configure(text=f"Ошибка: {error}")
# ─────── Action Handler ─────── # ─────── Action Handler ───────
def _handle_action(self, action: str, data: dict): def _handle_action(self, action: str, data: Dict[str, Any]):
pid = data.get("profile_id", "") pid = data.get("profile_id", "")
prov = data.get("provider", "")
# 1. UI Navigation
if action in ["oauth", "add_account"]: if action == "set_main":
self._run_in_thread(
lambda: do_set_main(prov, pid),
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
)
elif action == "set_orchestrator":
self._run_in_thread(
lambda: do_set_orchestrator(pid),
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
)
elif action == "test":
self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}", None)
self._run_in_thread(
lambda: do_test_profile(prov, pid),
on_success=lambda result: self._show_test_result(result, pid),
)
elif action == "oauth" or action == "add_account":
self._open_add_account_wizard() self._open_add_account_wizard()
return elif action == "delete_credentials":
self._run_in_thread(
lambda: do_delete_credentials(prov, pid),
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
)
elif action == "assign_role": elif action == "assign_role":
self._open_assign_role_modal(pid, data.get("display_name", pid)) self._open_assign_role_modal(pid, data.get("display_name", pid))
return
elif action == "account_details": elif action == "account_details":
self._open_account_details_modal(pid) self._open_account_details_modal(pid)
return
elif action == "agent_settings": elif action == "agent_settings":
self._open_agent_settings_modal(data.get("role_id", ""), pid) self._open_agent_settings_modal(
return data.get("role_id", ""),
pid,
)
elif action == "auto_assign_all":
self._show_toast("⚡ Автоматическое распределение ролей...")
self._run_in_thread(
lambda: AutoAssigner.auto_assign_all(),
on_success=lambda r: self._show_toast("✅ Роли успешно распределены"),
)
elif action == "refresh_data":
self._refresh_data()
elif action == "refresh_all":
from antigravity_provider.router.scheduler import HermesRefreshScheduler
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
elif action == "refresh_account":
from antigravity_provider.router.scheduler import HermesRefreshScheduler
HermesRefreshScheduler.get().trigger_refresh_account(
prov,
pid,
on_complete=lambda: self.after(0, self._refresh_data),
)
elif action == "edit_route": elif action == "edit_route":
self._open_route_editor_modal(data.get("role_id", "")) role_id = data.get("role_id", "")
return self._open_route_editor_modal(role_id)
elif action == "open_routing": elif action == "open_routing":
self._show_view("routing") self._show_view("routing")
routing = self._views.get("routing") routing = self._views.get("routing")
if routing and hasattr(routing, "focus_role"): if routing and hasattr(routing, "focus_role"):
routing.focus_role(data.get("role_id", "")) routing.focus_role(data.get("role_id", ""))
return elif action == "save_settings":
elif action == "refresh_data":
self._refresh_data() def _settings_saved(result: Tuple[bool, str]) -> None:
return
# 2. Execution logic via shared handler
from antigravity_provider.router.action_handler import ActionExecutor
if action == "test":
self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}", None)
elif action == "auto_assign_all":
self._show_toast("⚡ Автоматическое распределение ролей...")
def _on_success(res: dict):
ok = res.get("ok", False)
msg = res.get("message", "")
if action == "test":
self._show_test_result(res.get("data", {}), pid)
elif action == "check_updates":
upd_res = res.get("data")
if upd_res:
self._show_toast(
f"Доступна версия {upd_res.manifest.version}"
if upd_res.update_available and upd_res.manifest
else (f"Ошибка: {upd_res.error}" if upd_res.error else "Установлена актуальная версия")
)
elif action == "save_settings":
requested_theme = str(data.get("theme", self._theme_name)) requested_theme = str(data.get("theme", self._theme_name))
if requested_theme != self._theme_name: if requested_theme != self._theme_name:
self._apply_theme(requested_theme) self._apply_theme(requested_theme)
self._show_toast(f"{msg}") self._show_toast(f"{result[1]}")
elif action in ["refresh_all", "refresh_account"]:
self.after(0, self._refresh_data) self._run_in_thread(
else: lambda: do_save_settings(data),
if msg and msg not in ["Навигация", "запущено"]: on_success=_settings_saved,
prefix = "" if ok else "" )
self._show_toast(f"{prefix} {msg}") elif action == "check_updates":
if action in ["set_main", "set_orchestrator", "delete_credentials"]: from antigravity_provider.updater import UpdateManager
self._show_account_action_result(pid, msg, ok)
self._run_in_thread(
# Execute in a thread since Desktop shouldn't block UI lambda: UpdateManager().check_for_updates(),
self._run_in_thread( on_success=lambda result: self._show_toast(
lambda: ActionExecutor.execute(action, data), f"Доступна версия {result.manifest.version}"
on_success=_on_success if result.update_available and result.manifest
) else (f"Ошибка: {result.error}" if result.error else "Установлена актуальная версия")
),
)
def _open_assign_role_modal(self, profile_id: str, display_name: str): def _open_assign_role_modal(self, profile_id: str, display_name: str):
modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420) modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420)

View file

@ -1,10 +1,13 @@
import json
import os import os
import sys import sys
import threading import threading
import dataclasses import dataclasses
import logging import logging
from typing import Dict, Any from typing import Any, Dict
from antigravity_provider import paths
from antigravity_provider.version import __version__
from fastapi import FastAPI, Request, HTTPException, Depends, Header from fastapi import FastAPI, Request, HTTPException, Depends, Header
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -26,12 +29,28 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
def _web_settings() -> Dict[str, Any]:
"""Настройки веб-API живут в hub_settings.json, а не в RouterConfig.
Прежняя версия читала config.hub такого атрибута у RouterConfig нет,
поэтому /api/snapshot и /api/action падали с 500, а run_server не
поднимался вовсе. Работал только /api/health, у которого нет проверки
авторизации, из-за чего дефект и выглядел как рабочий сервер.
"""
settings_file = paths.get_hermes_home() / "hub_settings.json"
try:
return json.loads(settings_file.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return {}
def get_auth_token(x_hub_token: str = Header(None)) -> bool: def get_auth_token(x_hub_token: str = Header(None)) -> bool:
config = load_router_config() settings = _web_settings()
server_host = config.hub.get('web_api_host', '127.0.0.1') server_host = settings.get('web_api_host', '127.0.0.1')
if server_host != '127.0.0.1': if server_host != '127.0.0.1':
required_token = config.hub.get('web_api_token', '') required_token = settings.get('web_api_token', '')
if not required_token: if not required_token:
raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a token") raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a token")
if x_hub_token != required_token: if x_hub_token != required_token:
@ -42,7 +61,7 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
def health_check(): def health_check():
return { return {
"ok": True, "ok": True,
"version": "1.0.0", "version": __version__,
"auth_flows": { "auth_flows": {
"openai-codex": {"supported": True, "reason": "device-code"}, "openai-codex": {"supported": True, "reason": "device-code"},
"grok": {"supported": True, "reason": "device-code"}, "grok": {"supported": True, "reason": "device-code"},
@ -101,10 +120,10 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
def run_server(): def run_server():
import uvicorn import uvicorn
config = load_router_config() settings = _web_settings()
host = config.hub.get('web_api_host', '127.0.0.1') host = settings.get('web_api_host', '127.0.0.1')
port = int(config.hub.get('web_api_port', 5800)) port = int(settings.get('web_api_port', 5800))
token = config.hub.get('web_api_token', '') token = settings.get('web_api_token', '')
if host != '127.0.0.1' and not token: if host != '127.0.0.1' and not token:
logger.error("Cannot bind Web API externally without web_api_token") logger.error("Cannot bind Web API externally without web_api_token")

View file

@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router import action_handler
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy
from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing
from antigravity_provider.router.hermes_hub_app import do_test_profile from antigravity_provider.router.hermes_hub_app import do_test_profile
@ -83,10 +84,10 @@ def test_do_test_profile_for_grok_and_claude():
mock_adapter = MagicMock() mock_adapter = MagicMock()
mock_adapter.health_check.return_value = True mock_adapter.health_check.return_value = True
with patch("antigravity_provider.router.hermes_hub_app.load_router_config", return_value=config), \ with patch("antigravity_provider.router.action_handler.load_router_config", return_value=config), \
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \ patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \ patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \
patch("antigravity_provider.router.hermes_hub_app.get_adapter", return_value=mock_adapter): patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter):
res_grok = do_test_profile("grok", "grok-worker-1") res_grok = do_test_profile("grok", "grok-worker-1")
assert res_grok["success"] is True assert res_grok["success"] is True

View file

@ -8,6 +8,7 @@ import pytest
pytest.importorskip("customtkinter") pytest.importorskip("customtkinter")
from antigravity_provider.router.ui import routing_graph as graph_module from antigravity_provider.router.ui import routing_graph as graph_module
from antigravity_provider.router import action_handler
from antigravity_provider.router.ui import add_account_wizard as wizard_module from antigravity_provider.router.ui import add_account_wizard as wizard_module
from antigravity_provider.router.ui.views import team_view as team_module from antigravity_provider.router.ui.views import team_view as team_module
from antigravity_provider.router import hermes_hub_app as app_module from antigravity_provider.router import hermes_hub_app as app_module
@ -164,13 +165,13 @@ def test_profile_test_does_not_invoke_model_or_oauth(monkeypatch):
def invoke(*_args, **_kwargs): def invoke(*_args, **_kwargs):
raise AssertionError("profile test must never invoke inference") raise AssertionError("profile test must never invoke inference")
monkeypatch.setattr(app_module, "load_router_config", lambda: config) monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
monkeypatch.setattr(app_module.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True}) monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
monkeypatch.setattr(app_module.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"}) monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
monkeypatch.setattr(app_module, "get_adapter", lambda _provider: Adapter()) monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter())
monkeypatch.setattr(app_module.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None)) monkeypatch.setattr(action_handler.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
result = app_module.do_test_profile("antigravity", "connected") result = action_handler.do_test_profile("antigravity", "connected")
assert result["success"] is True assert result["success"] is True
assert "runtime" in result["response"] assert "runtime" in result["response"]