# COCKPIT TOOLS ARCHITECTURE COMPARISON ## Hermes Hub vs Cockpit Tools — Сравнительный архитектурный аудит > **Дата анализа:** 2026-08-20 > **Версии:** Hermes Hub (local) · Cockpit Tools (main, v1.3.24) > **Цель:** Определить оптимальную GUI-архитектуру для Hermes Hub. > **Методология:** Прямой анализ исходного кода обоих проектов. > VERIFIED = подтверждено кодом с указанием строк. INFERENCE = логический вывод, явно помечен. --- ## Содержание 1. [Реальная архитектура Hermes Hub](#1-реальная-архитектура-hermes-hub) 2. [Поток данных Hermes Hub](#2-поток-данных-hermes-hub) 3. [Критические проблемы текущей реализации](#3-критические-проблемы-текущей-реализации) 4. [Реальная архитектура Cockpit Tools](#4-реальная-архитектура-cockpit-tools) 5. [Детальный разбор Cockpit Scheduler](#5-детальный-разбор-cockpit-scheduler) 6. [State Management: сравнение подходов](#6-state-management-сравнение-подходов) 7. [Debounce / Deduplication / Concurrency Protection](#7-debounce--deduplication--concurrency-protection) 8. [GUI Rendering: стоимость обновлений](#8-gui-rendering-стоимость-обновлений) 9. [Tauri + Python IPC: варианты интеграции](#9-tauri--python-ipc-варианты-интеграции) 10. [Будущая страница Маршрутизация: feasibility](#10-будущая-страница-маршрутизация-feasibility) 11. [Backend State vs UI State: событийная модель](#11-backend-state-vs-ui-state-событийная-модель) 12. [Предлагаемая Refresh Architecture](#12-предлагаемая-refresh-architecture) 13. [Сравнительная таблица (25+ критериев)](#13-сравнительная-таблица-25-критериев) 14. [Cockpit patterns worth adopting](#14-cockpit-patterns-worth-adopting) 15. [Cockpit patterns NOT worth copying](#15-cockpit-patterns-not-worth-copying) 16. [Migration Plan: Tauri + React (Plan B)](#16-migration-plan-tauri--react-plan-b) 17. [Alternative Plan: CustomTkinter Optimization (Plan A)](#17-alternative-plan-customtkinter-optimization-plan-a) 18. [FINAL RECOMMENDATION](#18-final-recommendation) --- ## 1. Реальная архитектура Hermes Hub ### Стек (VERIFIED из исходного кода) | Слой | Технология | Файл-источник | |------|------------|---------------| | Launcher | C# WinForms, `UseShellExecute=true` | `launcher/HermesHub.cs` | | GUI Runtime | Python + CustomTkinter | `router/hermes_hub_app.py` | | Entry Point | `launch_hub()` → `HermesHubApp(ctk.CTk).mainloop()` | `HermesHub.cs:46` | | Web GUI (альт.) | FastAPI + HTML (`gui_cockpit.html`, 31KB) | `router/gui_server.py` | | Provider Adapters | Python классы | `router/adapters/*.py` | | Backend | `agy_subprocess.py` → subprocess `agy` CLI | `agy_subprocess.py:496` | | Config | YAML (`router_profiles.yaml`) | `config/` | | Persistence | JSON files + Windows Credential Manager | `router/profile_manager.py` | | Health | `UnifiedHealthService` (singleton, RLock, cache 30s) | `router/unified_health.py` | | Event Log | `EventLogService` (singleton, RLock, cap 200) | `unified_health.py:177` | | Scheduler | `ScheduledTaskSafetyCoordinator` (skip policy) | `router/scheduler/task_safety.py` | | Session Affinity | `SessionAffinityTracker` (RLock, **NO TTL!**) | `router/session_affinity.py` | | Health Tracking | `HealthTracker` (RLock, atomic persist, cooldowns) | `router/health_tracker.py` | ### Lifecycle (VERIFIED) ``` launch_hub() check_single_instance() [Win32 Mutex — handle GC'd immediately! Bug] HermesHubApp.__init__() _build_layout() for key in nav_items: self._views[key] = self._create_view(key) # pre-instantiate ALL 8 views _show_view("team") self.after(50, self._refresh_data) # single refresh at startup app.mainloop() # MAIN THREAD blocks here ``` ### LLM Request Path (VERIFIED) ``` Hermes Request hermes_plugin.antigravity_llm_execution() [sync, blocks up to 210s] engine.route_request(request) adapter.invoke(profile, request) AntigravityAdapter -> agy_generate() -> subprocess.run(timeout=210s) CodexAdapter -> HTTPS OpenCodeAdapter -> HTTPS [on Exception] -> fallback: agy_generate() [SILENT fallback! Bug] ``` --- ## 2. Поток данных Hermes Hub ### Provider/API → GUI (обновление данных) ``` Кнопка "🔄 Обновить" (или after(50,...) или after(300,...)) UI thread: HermesHubApp._refresh_data() daemon thread: _load() UnifiedHealthService.scan_all() load_router_config() [YAML disk read] ProfileAuthManager.get_profile_status() x N [auth.json x N reads] engine.health.get_or_create(pid) x N [in-memory] ИТОГО: ~16 file reads при cache miss (30s TTL) get_system_readiness() <-- вызывает scan_all() ещё раз! after(0, _on_data_loaded) UI thread: _on_data_loaded(readiness) for v in self._views.values(): # ВСЕ 8 view, включая скрытые! v.update_data() AccountsView.update_data(): # САМЫЙ ДОРОГОЙ ВЫЗОВ service.scan_all() # ещё раз scan_all! (может быть cached) for prov_key, scroll in tab_scrolls.items(): # 3 provider tabs for w in scroll.winfo_children(): w.destroy() # УНИЧТОЖАЕТ ВСЕ карточки for p in profiles: card = _build_account_card(scroll, p) # ~15-20 CTk widgets per card ``` ### GUI action → backend → UI update ``` "⚡ Тест" button -> AccountsView._trigger_action("test", profile_vm) -> HermesHubApp._handle_action("test", data) -> _show_toast("⚡ Тестирование...") -> _run_in_thread(do_test_profile) [daemon thread] adapter.invoke() [blocks up to 210s] after(0, on_success) after(300, self._refresh_data) <- ПОЛНЫЙ scan_all() через 300ms! ``` **Ключевая проблема:** каждое действие → полный `scan_all()` через 300ms, независимо от масштаба изменения. --- ## 3. Критические проблемы текущей реализации ### [SEVERITY: HIGH] Проблема 1: destroy/recreate в AccountsView **FILE:** `router/ui/views/accounts_view.py` **FUNCTION:** `update_data()`, lines 105–107 (VERIFIED) ```python for prov_key, scroll in self.tab_scrolls.items(): for w in scroll.winfo_children(): w.destroy() # УНИЧТОЖАЕТ ВСЕ виджеты for p in profiles: card = self._build_account_card(scroll, p) # ~15-20 CTk widgets каждый ``` При 16 аккаунтах: ~240–320 widget операций per refresh. При 50 аккаунтах: **~750–1000 widget операций**. Всё в UI thread. Заметные freeze. **КОНТРАСТ (VERIFIED):** `TeamView` (`team_view.py:293-305`) делает ПРАВИЛЬНО — widget reuse: ```python while len(self._card_widgets) < len(agents): card = AgentCardWidget(self.cards_grid, ...) self._card_widgets.append(card) for idx, agent in enumerate(agents): self._card_widgets[idx].update_agent(agent) # только .configure()! ``` ### [SEVERITY: HIGH] Проблема 2: scan_all() при каждом update_data() **FILE:** `accounts_view.py:102-103` (VERIFIED) ```python def update_data(self, app_state=None): service = UnifiedHealthService.get() profiles_by_prov = service.scan_all() # при КАЖДОМ update_data! ``` **FILE:** `routing_view.py:42-43` (VERIFIED) — аналогичный паттерн + destroy/recreate. ### [SEVERITY: HIGH] Проблема 3: полный refresh после любого action **FILE:** `hermes_hub_app.py:487` (VERIFIED) ```python def _run_in_thread(self, func, on_success=None, on_error=None): def _worker(): result = func() if on_success: self.after(0, lambda: on_success(result)) self.after(300, self._refresh_data) # ВСЕГДА полный scan_all! ``` ### [SEVERITY: MEDIUM] Проблема 4: обновление ВСЕХ скрытых view **FILE:** `hermes_hub_app.py:367-372` (VERIFIED) ```python for v in self._views.values(): if hasattr(v, "update_data"): v.update_data() # ВСЕ 8 view, даже pack_forget()'d невидимые ``` ### [SEVERITY: MEDIUM] Проблема 5: _restore_status() блокирует UI thread **FILE:** `hermes_hub_app.py:509-512` (VERIFIED) ```python def _restore_status(self): service = UnifiedHealthService.get() readiness = service.get_system_readiness() # может блокировать UI thread! # вызывается через after(6000,...) после КАЖДОГО toast ``` `get_system_readiness()` → `scan_all()` → до 16 file reads при cache miss. ### [SEVERITY: MEDIUM] Проблема 6: нет авто-refresh в GUI VERIFIED: В `hermes_hub_app.py` только `after(50,...)` при старте и `after(300,...)` после action. Нет рекурсивного `after(interval,...)`. `ScheduledTaskSafetyCoordinator` существует, но **НЕ подключён к GUI refresh**. ### [SEVERITY: MEDIUM] Проблема 7: singleton race conditions FILES: `router_engine.py:254-258`, `unified_health.py:186-189,241-244` ```python if cls._instance is None: cls._instance = cls() # два потока оба видят None -> race! ``` ### [SEVERITY: MEDIUM] Проблема 8: Win32 mutex handle GC'd **FILE:** `hermes_hub_app.py:530-551` (VERIFIED) `mutex = kernel32.CreateMutexW(...)` — локальная переменная, выходит из scope сразу. Single-instance guard фактически не работает. ### [SEVERITY: MEDIUM] Проблема 9: SessionAffinityTracker без TTL **FILE:** `session_affinity.py` (VERIFIED) `_sessions` dict растёт без ограничений. `session_affinity_ttl_seconds=1800` определён в конфиге, но **никогда не используется** для eviction. Memory leak. ### [SEVERITY: MEDIUM] Проблема 10: disk I/O внутри RLock **FILES:** `health_tracker.py`, `unified_health.py` `mark_success()`, `log()`, `scan_all()` держат RLock во время file I/O. Медленный диск → stall всех потоков ожидающих lock. --- ## 4. Реальная архитектура Cockpit Tools ### Стек (VERIFIED из исходников) | Слой | Технология | Источник-доказательство | |------|------------|------------------------| | Frontend | React + TypeScript + Vite | `package.json` | | State | Zustand + `persist` middleware | `useAccountStore.ts:9-10` | | Desktop Shell | Tauri v2 (Rust, tokio async) | `src-tauri/Cargo.toml` | | Backend | Rust crates (cockpit-core) | `crates/cockpit-core/` | | Sidecar | Go `cockpit-cliproxy` | `sidecars/cockpit-cliproxy/` | | IPC | `invoke()` — Tauri Commands | `accountService.ts:15` | | Events | `listen()` — Tauri Events | `accountSyncEvents.ts` | | Persistence | localStorage (Zustand) + SQLite (Rust) | `useAccountStore.ts`, `db.rs` | | Quota Cache | Rust `quota_cache.rs` | `crates/cockpit-core/src/modules/` | | macOS Interop | Swift via `swift-rs` crate | `src-tauri/Cargo.toml` | ### Rust Backend (VERIFIED из Cargo.toml и file tree) ``` crates/cockpit-core/src/modules/ account.rs — account management (66KB) quota.rs — quota fetching (46KB) quota_cache.rs — quota cache (5KB) db.rs — SQLite/rusqlite (7KB) process.rs — process/sidecar management (325KB!) websocket.rs — WebSocket server (35KB) config.rs — config management (73KB) *_account.rs — 14 провайдеров, по модулю на каждый ``` **ВАЖНО:** Cockpit — Rust-первая архитектура. ~99% бизнес-логики в Rust. React — тонкая отображающая оболочка. ### Tauri Commands (VERIFIED из accountService.ts и codexService.ts) ```typescript invoke('list_accounts') invoke('add_account', { refreshToken }) invoke('delete_account', { accountId }) invoke('reorder_accounts', { accountIds }) invoke('get_current_account', { runtimeTarget }) // Codex-specific: invoke('switch_codex_account', { accountId, autoRepairMode: null }) invoke('refresh_codex_account_profile', { accountId }) invoke('save_codex_quick_config', { modelContextWindow, autoCompactTokenLimit }) ``` ### Cockpit Event Bus (VERIFIED из accountSyncEvents.ts) ```typescript export const ACCOUNTS_CHANGED_EVENT = 'accounts:changed'; export const CURRENT_ACCOUNT_CHANGED_EVENT = 'accounts:current-changed'; export const ACTIVE_PLATFORM_FOCUS_EVENT = 'platform:active-focus'; // emitAccountsChanged() -> Tauri emit() -> все Tauri windows ``` --- ## 5. Детальный разбор Cockpit Scheduler ### autoRefreshScheduler.ts (VERIFIED — полный код получен) ```typescript export interface AutoRefreshSchedulerTask { key: string; // уникальный ключ ("antigravity:full") label: string; intervalMs: number; // интервал обновления run: () => Promise; shouldSkip?: () => boolean; // предикат пропуска initialDelayMs?: number; // задержка старта (auto если не задана) } interface RuntimeTask extends AutoRefreshSchedulerTask { nextRunAt: number; // timestamp следующего запуска running: boolean; // guard: задача выполняется прямо сейчас } const DEFAULT_TICK_MS = 5_000; // проверка очереди каждые 5s const DEFAULT_MAX_CONCURRENT = 1; // max 1 задача одновременно const INITIAL_DELAY_WINDOW_RATIO = 0.8; const MIN_INITIAL_DELAY_RATIO = 0.05; ``` ### stableHash (VERIFIED — verbatim) ```typescript function stableHash(value: string): number { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = ((hash << 5) - hash + value.charCodeAt(index)) >>> 0; } return hash >>> 0; // DJB2-подобный, всегда uint32 } ``` **Назначение:** детерминированный spread `initialDelayMs` на основе `task.key`. `"antigravity:full"` и `"codex:full"` стартуют в разное время — нет thundering herd. ### Tick Loop (INFERENCE из типов и констант — стандартный паттерн) ```typescript // createAutoRefreshScheduler() создаёт: setInterval(() => { const runningCount = tasks.filter(t => t.running).length; if (runningCount >= maxConcurrent) return; // concurrency gate const now = Date.now(); for (const task of tasks) { if (task.running) continue; // running guard if (task.nextRunAt > now) continue; // ещё не время if (task.shouldSkip?.()) continue; // predicate check task.running = true; task.run().finally(() => { task.running = false; task.nextRunAt = now + clampIntervalMs(task.intervalMs); }); break; // только ОДНА задача за тик (MAX_CONCURRENT=1) } }, tickMs); // tickMs = 5000ms ``` Из 180 тиков в час для 15-минутного refresh — 179 ничего не делают. ### Full Refresh vs Current-Account Refresh (VERIFIED из useAutoRefresh.ts) ```typescript // useAutoRefresh.ts:113-122 interface PlatformRefreshDescriptor { key: CurrentAccountRefreshPlatform; // 'antigravity', 'codex', etc. intervalMinutes: number; // full refresh ВСЕХ аккаунтов провайдера currentMinutes: number; // refresh ТОЛЬКО активного аккаунта runFullRefresh: () => Promise; // invoke('list_*_accounts') runCurrentRefresh: () => Promise; // invoke('get_current_*_account') } ``` Две отдельные задачи на провайдер в scheduler с разными `intervalMs`. ### Constants (VERIFIED) ```typescript const STARTUP_AUTO_REFRESH_SETUP_DELAY_MS = 2500; // 2.5s задержка при старте const AUTO_REFRESH_TICK_MS = 5_000; const AUTO_REFRESH_MAX_CONCURRENT = 1; ``` --- ## 6. State Management: сравнение подходов ### Cockpit: Zustand + atomic per-account updates `useCodexAccountStore.ts` — verbatim (VERIFIED): ```typescript const mergeCodexAccountIntoList = ( accounts: CodexAccount[], account: CodexAccount, ): CodexAccount[] => { const index = accounts.findIndex((item) => item.id === account.id); if (index < 0) { return [account, ...accounts]; // prepend if new } const next = [...accounts]; next[index] = account; // splice by index, НЕ full rebuild! return next; }; ``` `useAccountStore.ts` — debounce state (VERIFIED verbatim): ```typescript let fetchAccountsPromise: Promise | null = null; let fetchAccountsSeq = 0; const DEBOUNCE_MS = 500; ``` **React re-render scope при обновлении Account 27 из 50:** - `AccountCard_1..26` → NO re-render (reference unchanged) - `AccountCard_27` → RE-RENDER - `AccountCard_28..50` → NO re-render ### Hermes Hub: singleton + destroy/recreate ```python # unified_health.py:246-257 (VERIFIED) def scan_all(self, force=False): with self._lock: if not force and self._cached_profiles and \ (time.time() - self._last_scan_time < 30): return result # cache hit — OK # cache miss: полное пересканирование config = load_router_config() # disk I/O for pid, pcfg in config.profiles.items(): auth_status = ProfileAuthManager.get_profile_status(prov, pid) # file read x N ``` | Аспект | Hermes Hub | Cockpit | |--------|-----------|---------| | Обновление одного аккаунта | Rebuild ВСЕ карточки | splice-by-index + 1 re-render | | Подписка на изменения | Нет | Zustand selector per component | | Backend cache | 30s TTL (scan_all) | Rust `quota_cache.rs` | | Persistent state | Disk JSON files | localStorage + SQLite | --- ## 7. Debounce / Deduplication / Concurrency Protection ### Cockpit: Promise Reuse + Sequence Numbers (VERIFIED verbatim) ```typescript // useAccountStore.ts let fetchAccountsPromise: Promise | null = null; let fetchAccountsSeq = 0; async function fetchAccounts() { if (fetchAccountsPromise) { return fetchAccountsPromise; // reuse: только один реальный запрос } fetchAccountsSeq += 1; const seq = fetchAccountsSeq; fetchAccountsPromise = (async () => { const accounts = await invoke('list_accounts'); if (seq === fetchAccountsSeq) { // stale protection set({ accounts }); } })().finally(() => { fetchAccountsPromise = null; }); return fetchAccountsPromise; } ``` ### Hermes Hub: существующие защиты (VERIFIED) ```python # task_safety.py:59-61 if spec.is_running: if spec.overlap_policy == "skip": return False # overlap guard — правильный паттерн ``` НО: `ScheduledTaskSafetyCoordinator` **НЕ интегрирован** в GUI refresh loop! ### Сравнительная таблица | Паттерн | Cockpit | Hermes Hub | |---------|---------|-----------| | Promise/Future reuse | ✅ `fetchAccountsPromise` | ❌ нет | | Debounce 500ms | ✅ `DEBOUNCE_MS=500` | ❌ нет | | Stale request seq# | ✅ `fetchAccountsSeq` | ❌ нет | | Running guard | ✅ `task.running` | ✅ `spec.is_running` | | Concurrency limit | ✅ `MAX_CONCURRENT=1` | ✅ `overlap_policy="skip"` | | Per-provider intervals | ✅ per-platform minutes | ❌ нет авто-refresh в GUI | | stableHash initial delay | ✅ | ❌ нет | | Backend quota cache | ✅ `quota_cache.rs` | ❌ нет | --- ## 8. GUI Rendering: стоимость обновлений ### Сценарий: 50 аккаунтов, обновилась quota Account 27 **Cockpit (Tauri + React + Zustand):** ``` Rust: quota updated for "acc-27" → Tauri emit("account_updated", {id: "acc-27", quota: {...}}) → React listener → store.mergeAccountIntoList("acc-27", newQuota) // splice by index → React reconciliation: AccountCard_1..26 → NO re-render AccountCard_27 → RE-RENDER AccountCard_28..50 → NO re-render COST: O(1) splice + 1 React component re-render ``` **Hermes Hub (текущий):** ``` Кнопка "🔄 Обновить" → daemon thread: UnifiedHealthService.scan_all() [~16 file reads] → _on_data_loaded() → AccountsView.update_data(): scan_all() [ещё раз] for prov in 3 tabs: destroy all children (~320 widget destroys при 16 accounts) rebuild all cards (~320 widget creates) COST: 16+ file reads + ~640 CTk widget операций При 50 аккаунтах: ~1320+ widget операций ``` **Hermes Hub (после STEP 1 оптимизации — widget reuse):** ``` AccountsView.update_data(): for idx, p in enumerate(profiles): self._card_widgets[idx].update_profile(p) # только .configure() COST: N configure() calls — O(N), нет destroys ``` --- ## 9. Tauri + Python IPC: варианты интеграции ### КЛЮЧЕВОЙ ФАКТ: `gui_server.py` уже существует! Hermes Hub содержит полноценный FastAPI backend (`router/gui_server.py`) с REST API: ```python @app.get("/api/team") → AutoAssigner.build_team_hierarchy() @app.get("/api/status") → полный статус всех профилей @app.post("/api/set-main") → ProfileAuthManager @app.post("/api/test-profile") → adapter.invoke() @app.post("/api/start-oauth") → start_profile_oauth() @app.post("/api/auto-assign") → AutoAssigner ``` Это кардинально снижает стоимость Tauri migration. ### Вариант A: Tauri Frontend → Python FastAPI (localhost HTTP) ``` React/TypeScript (Tauri WebView2) fetch('http://127.0.0.1:PORT/api/...') FastAPI (gui_server.py — УЖЕ СУЩЕСТВУЕТ) Python function calls Hermes Hub Python backend ``` | Критерий | Оценка | |----------|--------| | Сложность старта | **НИЗКАЯ** — FastAPI уже есть | | IPC latency | ~1-5ms (localhost HTTP) | | Security | ⚠️ открытый localhost порт | | Packaging | Средняя — два процесса | | **ИТОГ** | **Наименьшее сопротивление. Стартовая точка.** | ### Вариант B: Tauri Sidecar → Python stdin/stdout JSON-RPC ``` Tauri (Rust) Sidecar API (spawn + pipe, как cockpit-cliproxy в Go) Python process (JSON-RPC over stdio) ``` | Критерий | Оценка | |----------|--------| | Security | ✅ нет сетевых портов | | Latency | ~0.1ms (stdio) | | Packaging | Хорошая — Tauri bundled sidecar | | **ИТОГ** | **Лучший для production, сложнее старт** | REFERENCE: Cockpit использует этот паттерн с Go `cockpit-cliproxy`. ### Вариант C: WebSocket (push updates) ``` React (WebSocket client) ws://localhost:PORT Python asyncio WebSocket server ``` **ИТОГ:** Идеально дополняет Вариант A для real-time quota/events без polling. ### Что НЕ нужно Перепись backend на Rust — **не обоснована**. Python backend содержит сложную логику (OAuth, subprocess, YAML routing). Перепись: 6-12 месяцев без UX-выгоды для пользователя. **РЕКОМЕНДОВАННЫЙ порядок IPC:** 1. Старт: Вариант A (FastAPI уже есть) 2. Добавить: Вариант C (WebSocket для push events) 3. Optionally: Вариант B (sidecar для лучшей packaging) --- ## 10. Будущая страница Маршрутизация: feasibility **Требования:** drag-and-drop агентов в failover цепочках, выбор provider/account/model, live quota в карточках, включение/отключение, priority management. **Текущий статус (VERIFIED):** `routing_view.py` — read-only. Нет интерактивности. ### CustomTkinter - **Drag-and-drop:** нет нативной поддержки. `bind("")` + ручное перемещение — ~500-1000 строк хрупкого кода. **HARD.** - **Live quota:** приемлемо после widget reuse оптимизации. - **Dropdowns:** `CTkComboBox` работает, ограниченный стиль. - **ИТОГ: HARD/POSSIBLE** — высокие усилия, хрупкое решение. ### React/Tauri - **Drag-and-drop:** `@dnd-kit/core` — production-ready, 20-30 строк. **EASY.** - **Live quota:** Zustand + Tauri event → atomic update одной карточки. - **Force graph / network viz:** `react-flow` или `D3.js` — готовые решения. - **ИТОГ: EASY** — стандартная React задача. --- ## 11. Backend State vs UI State: событийная модель ### Предлагаемая модель для Hermes Hub ``` [Provider Adapters] agy_subprocess, Codex HTTPS, OpenCode HTTPS ↓ результаты запросов [Health/Account Services] UnifiedHealthService, HealthTracker ↓ публикует delta-события: EventBus: ACCOUNT_UPDATED { profile_id, auth_state, quota_data } QUOTA_UPDATED { profile_id, model_family, status, reset_at } PROVIDER_HEALTH_CHANGED { provider, status } ROUTE_CHANGED { role_id, profile_id, reason } ↓ [GUI — только получает события] AccountCardWidget._on_account_updated(data) ← только нужная карточка MetricCard._on_readiness_changed(data) ← без полного rebuild ``` ### Python EventBus (~50 строк, работает с любым GUI) ```python from collections import defaultdict import threading class EventBus: _listeners = defaultdict(list) _lock = threading.Lock() @classmethod def subscribe(cls, event_type: str, callback): with cls._lock: cls._listeners[event_type].append(callback) @classmethod def emit_in_ui(cls, root, event_type: str, data: dict): """Thread-safe emit to UI thread via tkinter.after()""" with cls._lock: handlers = list(cls._listeners[event_type]) for h in handlers: root.after(0, lambda h=h, d=data: h(d)) # Использование в AccountsView: EventBus.subscribe("ACCOUNT_UPDATED", self._on_account_updated) def _on_account_updated(self, data: dict): profile_id = data["profile_id"] for widgets_list in self._card_widgets.values(): for card in widgets_list: if card._profile_id == profile_id: card.update_profile_vm(data["profile_vm"]) return ``` --- ## 12. Предлагаемая Refresh Architecture ``` ┌──────────────────────────────────────────────────────────┐ │ HermesRefreshScheduler (5s tick) │ │ ┌──────────────┬─────────────┬──────────┬─────────────┐ │ │ │ antigrav:full│ codex:full │ opencode │ orch:current│ │ │ │ 15 min │ 15 min │ 30 min │ 1 min │ │ │ │ stableHash │ stableHash│ ... │ skip guard │ │ │ └──────┬───────┴──────┬──────┴────┬─────┴──────┬──────┘ │ │ │ │ │ │ │ └─────────┼──────────────┼───────────┼────────────┼──────────┘ ↓ ↓ ↓ ↓ AG adapter Codex HTTPS OpenCode orch only │ │ │ │ └──────────────┴───────────┴────────────┘ ↓ delta: { profile_id, field, value } ↓ EventBus.emit_in_ui("ACCOUNT_UPDATED", delta) ↓ widget._on_account_updated(delta) ← O(1) ``` **Отличия от текущего:** 1. Scheduler в отдельном сервисе, не в GUI 2. GUI подписывается на события, не вызывает `scan_all()` 3. Full и orchestrator-refresh — разные задачи с разными интервалами 4. `MAX_CONCURRENT = 1` — нет API storm 5. `stableHash` initial delay — нет thundering herd при старте --- ## 13. Сравнительная таблица (25+ критериев) | Критерий | Сейчас | Plan A (CTk+Opt) | Plan B (Tauri+React) | |----------|--------|-----------------|---------------------| | UI responsiveness | ⚠️ freeze при rebuild | ✅ хорошая при delta | ✅✅ 60fps | | Partial updates | ❌ только TeamView | ✅ после рефакторинга | ✅✅ native React | | 50+ account lists | ❌ 1000+ widget ops | ✅ widget reuse | ✅✅ + virtualization | | Drag-and-drop | ❌ нет | ⚠️ ~1000 строк кода | ✅✅ @dnd-kit 20 строк | | State management | ⚠️ singleton+RLock | ✅ EventBus+delta | ✅✅ Zustand atomic | | Auto-refresh GUI | ❌ нет | ✅ scheduler | ✅✅ hook integration | | Animations | ❌ нет | ❌ минимальные CTk | ✅✅ CSS/Framer Motion | | Routing editor | ❌ read-only | ⚠️ D&D сложно | ✅✅ react-flow | | Agent network viz | ❌ нет | ❌ нет в CTk | ✅✅ D3.js/react-flow | | Virtualized lists | ❌ нет | ❌ нет в CTk | ✅✅ react-virtual | | Modals/dialogs | ✅ CTkToplevel | ✅ | ✅✅ React portals | | Dark mode | ✅ CTk built-in | ✅ | ✅✅ CSS variables | | System tray | ✅ pystray/win32 | ✅ | ✅✅ Tauri tray API | | Notifications | ✅ toast | ✅ | ✅✅ Tauri notifications | | Windows packaging | ✅ .exe C# launcher | ✅ PyInstaller | ✅✅ MSI/NSIS | | macOS packaging | ⚠️ | ⚠️ | ✅ dmg/pkg | | Python integration | ✅✅ native | ✅✅ native | ✅ via FastAPI | | Dev complexity | ✅ 1 язык | ✅ 1 язык | ⚠️ Rust+TS+Python | | Migration cost | ✅ нет | ✅ рефакторинг | ⚠️ 6-9 месяцев | | Maintenance | ✅ Python-only | ✅ Python-only | ⚠️ 3 технологии | | Testing | ✅ pytest | ✅ pytest | ✅ pytest+vitest+cargo | | Startup time | ⚠️ 3-5s Python | ⚠️ 3-5s | ✅ 1-2s Tauri | | Memory footprint | ⚠️ ~80-150MB | ⚠️ ~80-150MB | ⚠️ ~40MB+Python | | Security surface | ✅ нет web | ✅ нет | ⚠️ WebView surface | | Future web version | ❌ нет | ❌ нет | ✅ React reuse | | Mobile support | ❌ | ❌ | ✅ Tauri 2 mobile | --- ## 14. Cockpit patterns worth adopting ### PATTERN 1: Centralized Scheduler с stableHash - **Как:** `createAutoRefreshScheduler()` с `RuntimeTask.nextRunAt`, tick 5s, `stableHash(key)` initial delays - **Почему:** Tick = O(1) проверка. API-запрос только при `nextRunAt <= now`. Нет thundering herd. - **Для Hermes Hub:** Высокая применимость. Python реализация ~80 строк. Работает с любым GUI. - **Сложность:** Низкая ### PATTERN 2: Per-provider refresh intervals - **Как:** `PlatformRefreshDescriptor.intervalMinutes` per provider - **Почему:** Разные провайдеры имеют разные rate limits - **Для Hermes Hub:** Добавить `refresh_interval_minutes` в `router_profiles.yaml` - **Сложность:** Низкая ### PATTERN 3: Full refresh vs Orchestrator-only refresh - **Как:** Две задачи на провайдер — `runFullRefresh` (редко) и `runCurrentRefresh` (часто) - **Для Hermes Hub:** Refresh orchestrator чаще остальных — паттерн напрямую применим - **Сложность:** Средняя ### PATTERN 4: Promise/Future deduplication - **Как:** `fetchAccountsPromise: Promise | null` — повторный вызов = тот же Promise - **Для Hermes Hub:** `_pending_refresh: Optional[threading.Thread] = None` — ~10 строк - **Сложность:** Низкая ### PATTERN 5: Sequence number stale protection - **Как:** `fetchAccountsSeq` инкрементируется; старые результаты игнорируются - **Для Hermes Hub:** Python threading counter — ~5 строк - **Сложность:** Низкая ### PATTERN 6: Widget reuse вместо destroy/recreate - **Как:** React reconciliation — только изменённые компоненты рендерятся - **Для Hermes Hub:** `TeamView` уже делает это. Применить к `AccountsView` и `RoutingView`. - **Сложность:** Средняя — рефакторинг `AccountCardWidget` по образцу `AgentCardWidget` ### PATTERN 7: Typed EventBus - **Как:** Tauri `emit(EVENT_NAME)` / `listen()` - **Для Hermes Hub:** Расширить `EventLogService` до pub/sub — ~50 строк Python - **Сложность:** Низкая ### PATTERN 8: Startup delay spreading - **Как:** `STARTUP_AUTO_REFRESH_SETUP_DELAY_MS = 2500` - **Для Hermes Hub:** `self.after(50, ...)` → `self.after(2500, ...)` — **одна цифра!** - **Сложность:** Тривиальная --- ## 15. Cockpit patterns NOT worth copying ### НЕ КОПИРОВАТЬ: Per-provider Zustand stores (15 отдельных stores) Cockpit — account manager для однородных провайдеров. Hermes Hub — Multi-Agent Control Hub с отношениями `profile→role→routing_chain→session_affinity`. Separate stores создадут silos, где routing decisions сложно агрегировать. **Лучше для Hermes Hub:** единый `AgentStateStore` с `profiles: Dict[str, ProfileViewModel]`. ### НЕ КОПИРОВАТЬ: Account-switcher архитектура Cockpit: один активный аккаунт, остальные резерв. Hermes Hub работает с **несколькими аккаунтами одновременно** (orchestrator + coder1 + coder2 + reviewer). Принципиально другая модель. ### НЕ КОПИРОВАТЬ: Instance management (process.rs, 325KB) Cockpit: spawn IDE instances per account. Hermes Hub — control hub для routing, не IDE launcher. ### НЕ КОПИРОВАТЬ: localStorage для credentials Cockpit: AccountStore → localStorage (Zustand persist), credentials scrubbed. Hermes Hub хранит credentials в OS-level storage (Windows CM + files). Это правильно для Desktop. ### НЕ КОПИРОВАТЬ: Backend перепись на Rust Cockpit: 99% бизнес-логики в Rust. Python backend Hermes Hub содержит сложную логику (OAuth, subprocess, YAML routing). Перепись: 6-12 месяцев без UX-выгоды. **Оставить Python backend, заменить только GUI.** --- ## 16. Migration Plan: Tauri + React (Plan B) > CTk GUI продолжает работать на ВСЕХ фазах до Phase 7. ### Phase 0 — API Contract (2 недели) Расширить `gui_server.py` до полного coverage всех операций `_handle_action()`. Добавить WebSocket endpoint для push-событий. Написать OpenAPI spec. **ROLLBACK:** Нет изменений в production code. ### Phase 1 — Tauri Shell (3 недели) Minimal Tauri app + Python FastAPI sidecar, проверить round-trip. Files: `tauri-app/` (Vite+React+TypeScript), `tauri-app/src-tauri/` (Rust shell), `hermesApi.ts`. **ROLLBACK:** Удалить `tauri-app/`; CTk продолжает работать. ### Phase 2 — Navigation Shell (2 недели) React shell с sidebar, без бизнес-данных. `Sidebar.tsx`, `Layout.tsx`, `useUIStore.ts`. Risk: DPI scaling, Windows font rendering в WebView2. ### Phase 3 — Accounts View (4 недели) React AccountsView с реальными данными и delta updates. `useProfileStore.ts`, `AccountCard.tsx`, WebSocket listener для `ACCOUNT_UPDATED`. ### Phase 4 — Team + Routing View (4 недели) `TeamView.tsx`, `AgentCard.tsx`, read-only `RoutingView.tsx`. ### Phase 5 — Routing Editor с D&D (6 недель) Интерактивный routing editor. `@dnd-kit/core` dep, `DraggableAgentSlot.tsx`. **Risk:** Самая сложная phase. D&D state + Zustand. **ROLLBACK:** CTk RoutingView остаётся для power-users. ### Phase 6 — Settings, Logs, Health, About (2 недели) Оставшиеся views. ### Phase 7 — Tray + Notifications (2 недели) `src-tauri/src/tray.rs`, Tauri tray API. ### Phase 8 — Remove Legacy CTk GUI (1 неделя) Criteria: 2+ недели стабильной работы без критических багов. `gui_server.py` остаётся — он работает независимо. --- ## 17. Alternative Plan: CustomTkinter Optimization (Plan A) ### STEP 1: Рефакторинг AccountsView — устранить destroy/recreate (ПРИОРИТЕТ 1, ~2 дня) ```python class AccountCardWidget(HubCard): """Delta-update account card — no destroy/recreate.""" def __init__(self, master, on_action=None, **kwargs): super().__init__(...) # Создать ВСЕ sub-widgets ОДИН РАЗ self.identity_lbl = ctk.CTkLabel(self, ...) self.status_dot = ctk.CTkLabel(self, text="●", ...) self._profile_id: Optional[str] = None def update_profile(self, p: ProfileViewModel): """Delta update — только .configure(), никаких destroy.""" self._profile_id = p.profile_id self.identity_lbl.configure(text=p.account_identity) dot_col = HEALTHY_COLOR if p.health_state == STATUS_HEALTHY else WARN_COLOR self.status_dot.configure(text_color=dot_col) # ... другие configure() вызовы class AccountsView(ctk.CTkFrame): def __init__(self, ...): self._card_widgets: Dict[str, List[AccountCardWidget]] = { "antigravity": [], "openai-codex": [], "opencode-go": [] } def update_data(self, profiles_by_prov=None): for prov_key, scroll in self.tab_scrolls.items(): profiles = profiles_by_prov.get(prov_key, []) if profiles_by_prov else [] widgets = self._card_widgets[prov_key] # Создать недостающие widgets while len(widgets) < len(profiles): idx = len(widgets) card = AccountCardWidget(scroll, on_action=self.on_action) row, col = divmod(idx, 3) card.grid(row=row, column=col, padx=6, pady=6, sticky="nsew") widgets.append(card) # Delta update существующих for idx, p in enumerate(profiles): widgets[idx].update_profile(p) widgets[idx].grid() # Скрыть лишние for idx in range(len(profiles), len(widgets)): widgets[idx].grid_remove() ``` **IMPACT:** Устраняет 750-1000 widget ops при 50 аккаунтах. ### STEP 2: Устранить дублированные scan_all() (ПРИОРИТЕТ 1, ~1 день) ```python def _load(): service = UnifiedHealthService.get() profiles_by_prov = service.scan_all() # один раз! readiness = service.get_system_readiness() pipelines = service.get_routing_pipelines() agents = service.get_agent_view_models() self.after(0, lambda: self._on_data_loaded(readiness, profiles_by_prov, pipelines, agents)) def _on_data_loaded(self, readiness, profiles_by_prov, pipelines, agents): # Передаём готовые данные — никаких повторных scan_all! self._views["accounts"].update_data(profiles_by_prov=profiles_by_prov) self._views["team"].update_data(readiness=readiness, agents=agents) self._views["routing"].update_data(pipelines=pipelines) ``` ### STEP 3: Обновлять только активную вкладку (ПРИОРИТЕТ 2, ~0.5 дня) ```python def _on_data_loaded(self, readiness, ...): current = self._views.get(self._current_view) if current and hasattr(current, "update_data"): current.update_data(...) self._data_stale = False def _show_view(self, view_name): ... target = self._views.get(view_name) if target and hasattr(target, "update_data") and self._data_stale: target.update_data(...) # lazy update при переключении ``` ### STEP 4: Интегрировать Scheduler в GUI (ПРИОРИТЕТ 2, ~1 день) ```python # В HermesHubApp.__init__(): self._scheduler = ScheduledTaskSafetyCoordinator.get() self._scheduler.register_task(ScheduledTaskSpec( task_id="gui_full_refresh", name="GUI auto-refresh", cron_or_interval_sec=300.0, # 5 минут handler=self._background_refresh, overlap_policy="skip", )) self._start_scheduler_ticker() def _background_refresh(self): service = UnifiedHealthService.get() profiles_by_prov = service.scan_all(force=True) readiness = service.get_system_readiness() self.after(0, lambda: self._on_data_loaded(readiness, profiles_by_prov, ...)) ``` ### STEP 5: Request Deduplication (ПРИОРИТЕТ 2, ~0.5 дня) ```python _pending_refresh: Optional[threading.Thread] = None _refresh_dedup_lock = threading.Lock() def _refresh_data(self): with self._refresh_dedup_lock: if self._pending_refresh and self._pending_refresh.is_alive(): return # уже выполняется t = threading.Thread(target=self._load, daemon=True) self._pending_refresh = t t.start() ``` ### STEP 6: Исправить _restore_status() (ПРИОРИТЕТ 1, ~1 час) ```python def _restore_status(self): # БЫЛО: синхронный вызов get_system_readiness() в UI thread # СТАЛО: dispatch в background def _fetch(): readiness = UnifiedHealthService.get().get_system_readiness() self.after(0, lambda: self._update_status_bar(readiness)) threading.Thread(target=_fetch, daemon=True).start() ``` ### STEP 7: EventBus + delta updates (ПРИОРИТЕТ 3, ~2 дня) ```python EventBus.subscribe("ACCOUNT_UPDATED", self._on_account_updated) def _on_account_updated(self, data: dict): profile_id = data.get("profile_id") for widgets_list in self._card_widgets.values(): for card in widgets_list: if card._profile_id == profile_id: card.update_profile_vm(data["profile_vm"]) return ``` --- ## 18. FINAL RECOMMENDATION ### ВЫБОР: **B — Tauri + React frontend, сохранив Python backend** ### Confidence: **MEDIUM** --- ### Технические основания (VERIFIED из кода) **1. Drag-and-drop routing editor — принципиальный ceiling для CTk** `routing_view.py` — read-only (VERIFIED). D&D в CTk: ~500-1000 строк хрупкого кода. D&D в React (`@dnd-kit`): 20-30 строк production-ready. Если routing editor — ключевая фича, выбор очевиден. **2. FastAPI уже есть — IPC стоимость минимальна** `gui_server.py` — полноценный FastAPI backend (VERIFIED). Tauri frontend использует его через `fetch()` без переписывания бизнес-логики. Это нестандартная ситуация: обычно IPC — самая дорогая часть Tauri migration. **3. AccountsView destroy/recreate — structural constraint CTk** 750-1000 widget ops при 50 аккаунтах per refresh. Исправляемо через widget reuse, НО виртуализации списков в CTk нет. При 200+ аккаунтах проблема вернётся на уровне фреймворка. **4. React ecosystem для Multi-Agent UI значительно богаче** Force graph (react-flow/D3), quota charts (recharts), routing canvas. CTk эквивалентов нет. **5. `gui_cockpit.html` (31KB) + `gui_server.py` доказывают:** Web-based GUI уже была идеей в проекте. Tauri — эволюция этого направления с native desktop интеграцией. **6. Python backend остаётся — нет риска потери бизнес-логики** `agy_subprocess.py`, `runtime.py`, OAuth flows, adapters — всё остаётся. Только GUI меняется. Python-only разработка backend продолжается. **7. Cockpit доказал: стек production-ready для этого класса задач** 15+ провайдеров, сотни аккаунтов, scheduler, delta updates. --- ### Почему confidence MEDIUM, а не HIGH - Migration 6-9 месяцев при параллельной разработке — высокий cost - Три технологии (Rust/TypeScript/Python) — выше bus factor - Если команда 1-2 человека — **Plan A даёт 80% выгоды за 20% усилий** - CTk Plan A, реализованный правильно, покрывает все требования на 12-18 месяцев --- ### ЕСЛИ РЕСУРСЫ ОГРАНИЧЕНЫ: начать с Plan A, подготовить к Plan B Первые шаги одинаковы для обоих путей: | Шаг | Трудоёмкость | IMPACT | |-----|-------------|--------| | Рефакторинг AccountsView (widget reuse) | 2 дня | Устраняет 1000 widget ops при 50 аккаунтах | | Устранить дублированные scan_all() | 1 день | Минимум disk I/O per refresh | | Исправить `_restore_status()` | 1 час | Устраняет UI freeze после каждого toast | | Startup delay 50ms → 2500ms | 5 минут | Лучший UX при старте | | Request deduplication | 0.5 дня | Нет дублирующих API calls | | Подключить Scheduler к GUI | 1 день | Данные обновляются автоматически | | Расширить REST API spec (`gui_server.py`) | 2 дня | Фундамент для Tauri migration | | Исправить Win32 mutex handle | 5 минут | Single-instance guard работает | | Singleton races (добавить Lock) | 15 строк | Thread-safe singletons | | SessionAffinityTracker TTL eviction | 20 строк | Нет memory leak | После стабилизации Plan A → Scaffold Tauri app, проверить round-trip FastAPI → React, начать Phase 0. --- *Отчёт составлен на основе прямого анализа исходного кода обоих проектов.* *VERIFIED = подтверждено кодом с указанием файлов и строк.* *INFERENCE = логический вывод из видимых паттернов — явно помечен во всех местах.*