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 json
import os
import logging
import threading
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.updater import UpdateManager
from antigravity_provider import paths
from antigravity_provider.router.adapters import get_adapter
logger = logging.getLogger('hermes.router.actions')
@ -31,7 +33,6 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
return ok, msg
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
from antigravity_provider.router.adapters import get_adapter
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
@ -84,21 +85,30 @@ def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:
return True, 'Учетные данные отсутствовали'
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)
existing: Dict[str, Any] = {}
if settings_file.exists():
try:
existing = json.loads(settings_file.read_text(encoding='utf-8'))
existing = json.loads(settings_file.read_text(encoding="utf-8"))
except Exception:
pass
for k, v in settings.items():
existing[k] = v
existing = {}
existing.update(settings)
try:
settings_file.write_text(json.dumps(existing, indent=2), encoding='utf-8')
return True, 'Настройки сохранены'
# Запись через временный файл и os.replace: обрыв на середине не
# должен оставить настройки битыми. В домашнем каталоге 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:
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:
"""Shared execution layer for Desktop and Web actions."""

View file

@ -34,14 +34,10 @@ if sys.platform == "win32":
pass
# ── Ensure plugin and repo paths are on sys.path ──
_SRC_DIR = Path(__file__).resolve().parent.parent.parent
if str(_SRC_DIR) not in sys.path:
sys.path.insert(0, str(_SRC_DIR))
import antigravity_provider.paths as _paths
_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]:
_LOCAL = Path(os.environ.get("LOCALAPPDATA", ""))
_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src"
_AGENT_DIR = _LOCAL / "hermes" / "hermes-agent"
for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.parent]:
_ps = str(_p)
if _p.exists() and _ps not in sys.path:
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.auto_assigner import AutoAssigner
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.version import __version__
@ -89,78 +94,513 @@ def _load_saved_theme() -> str:
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 ───────
def _handle_action(self, action: str, data: dict):
def _handle_action(self, action: str, data: Dict[str, Any]):
pid = data.get("profile_id", "")
# 1. UI Navigation
if action in ["oauth", "add_account"]:
prov = data.get("provider", "")
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()
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":
self._open_assign_role_modal(pid, data.get("display_name", pid))
return
elif action == "account_details":
self._open_account_details_modal(pid)
return
elif action == "agent_settings":
self._open_agent_settings_modal(data.get("role_id", ""), pid)
return
self._open_agent_settings_modal(
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":
self._open_route_editor_modal(data.get("role_id", ""))
return
role_id = data.get("role_id", "")
self._open_route_editor_modal(role_id)
elif action == "open_routing":
self._show_view("routing")
routing = self._views.get("routing")
if routing and hasattr(routing, "focus_role"):
routing.focus_role(data.get("role_id", ""))
return
elif action == "refresh_data":
self._refresh_data()
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":
elif action == "save_settings":
def _settings_saved(result: Tuple[bool, str]) -> None:
requested_theme = str(data.get("theme", self._theme_name))
if requested_theme != self._theme_name:
self._apply_theme(requested_theme)
self._show_toast(f"{msg}")
elif action in ["refresh_all", "refresh_account"]:
self.after(0, self._refresh_data)
else:
if msg and msg not in ["Навигация", "запущено"]:
prefix = "" if ok else ""
self._show_toast(f"{prefix} {msg}")
if action in ["set_main", "set_orchestrator", "delete_credentials"]:
self._show_account_action_result(pid, msg, ok)
# Execute in a thread since Desktop shouldn't block UI
self._run_in_thread(
lambda: ActionExecutor.execute(action, data),
on_success=_on_success
)
self._show_toast(f"{result[1]}")
self._run_in_thread(
lambda: do_save_settings(data),
on_success=_settings_saved,
)
elif action == "check_updates":
from antigravity_provider.updater import UpdateManager
self._run_in_thread(
lambda: UpdateManager().check_for_updates(),
on_success=lambda result: self._show_toast(
f"Доступна версия {result.manifest.version}"
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):
modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420)

View file

@ -1,10 +1,13 @@
import json
import os
import sys
import threading
import dataclasses
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.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
@ -26,12 +29,28 @@ app.add_middleware(
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:
config = load_router_config()
server_host = config.hub.get('web_api_host', '127.0.0.1')
settings = _web_settings()
server_host = settings.get('web_api_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:
raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a 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():
return {
"ok": True,
"version": "1.0.0",
"version": __version__,
"auth_flows": {
"openai-codex": {"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():
import uvicorn
config = load_router_config()
host = config.hub.get('web_api_host', '127.0.0.1')
port = int(config.hub.get('web_api_port', 5800))
token = config.hub.get('web_api_token', '')
settings = _web_settings()
host = settings.get('web_api_host', '127.0.0.1')
port = int(settings.get('web_api_port', 5800))
token = settings.get('web_api_token', '')
if host != '127.0.0.1' and not 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
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.ui.add_account_wizard import ensure_profile_in_routing
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.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.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")
assert res_grok["success"] is True

View file

@ -8,6 +8,7 @@ import pytest
pytest.importorskip("customtkinter")
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.views import team_view as team_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):
raise AssertionError("profile test must never invoke inference")
monkeypatch.setattr(app_module, "load_router_config", lambda: config)
monkeypatch.setattr(app_module.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
monkeypatch.setattr(app_module.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
monkeypatch.setattr(app_module, "get_adapter", lambda _provider: Adapter())
monkeypatch.setattr(app_module.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter())
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 "runtime" in result["response"]