A9 (Antigravity): миграция профилей в существующий router_profiles.yaml — корень жалобы «при подключении грока ошибка»; квоты для codex и opencode; служба обнаружения моделей с кэшем и фоновым обновлением; отказ от выдуманных списков моделей. Плюс четыре утверждения отчёта A8, не подтвердившиеся проверкой: профили claude/grok до пользователя не дошли, проверка is_expired была мертва, dist в .gitignore, флаг /reinstall не используется. B8 (Codex): выбор модели агента, компактные карточки аккаунтов без раскрытия, кликабельная карточка целиком, окно настроек роли, снятие правой панели, устранение жёстких срезов диаграммы, решение по дублирующим разделам, причина у каждого Н/Д. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
48 KiB
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 = логический вывод, явно помечен.
Содержание
- Реальная архитектура Hermes Hub
- Поток данных Hermes Hub
- Критические проблемы текущей реализации
- Реальная архитектура Cockpit Tools
- Детальный разбор Cockpit Scheduler
- State Management: сравнение подходов
- Debounce / Deduplication / Concurrency Protection
- GUI Rendering: стоимость обновлений
- Tauri + Python IPC: варианты интеграции
- Будущая страница Маршрутизация: feasibility
- Backend State vs UI State: событийная модель
- Предлагаемая Refresh Architecture
- Сравнительная таблица (25+ критериев)
- Cockpit patterns worth adopting
- Cockpit patterns NOT worth copying
- Migration Plan: Tauri + React (Plan B)
- Alternative Plan: CustomTkinter Optimization (Plan A)
- 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)
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:
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)
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)
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)
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)
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
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)
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)
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 — полный код получен)
export interface AutoRefreshSchedulerTask {
key: string; // уникальный ключ ("antigravity:full")
label: string;
intervalMs: number; // интервал обновления
run: () => Promise<void>;
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)
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 из типов и констант — стандартный паттерн)
// 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)
// useAutoRefresh.ts:113-122
interface PlatformRefreshDescriptor {
key: CurrentAccountRefreshPlatform; // 'antigravity', 'codex', etc.
intervalMinutes: number; // full refresh ВСЕХ аккаунтов провайдера
currentMinutes: number; // refresh ТОЛЬКО активного аккаунта
runFullRefresh: () => Promise<void>; // invoke('list_*_accounts')
runCurrentRefresh: () => Promise<void>; // invoke('get_current_*_account')
}
Две отдельные задачи на провайдер в scheduler с разными intervalMs.
Constants (VERIFIED)
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):
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):
let fetchAccountsPromise: Promise<void> | 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-RENDERAccountCard_28..50→ NO re-render
Hermes Hub: singleton + destroy/recreate
# 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)
// useAccountStore.ts
let fetchAccountsPromise: Promise<void> | 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)
# 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:
@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:
- Старт: Вариант A (FastAPI уже есть)
- Добавить: Вариант C (WebSocket для push events)
- 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("<B1-Motion>")+ ручное перемещение — ~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)
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)
Отличия от текущего:
- Scheduler в отдельном сервисе, не в GUI
- GUI подписывается на события, не вызывает
scan_all() - Full и orchestrator-refresh — разные задачи с разными интервалами
MAX_CONCURRENT = 1— нет API stormstableHashinitial 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.intervalMinutesper 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<void> | 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 дня)
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 день)
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 дня)
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 день)
# В 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 дня)
_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 час)
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 дня)
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 = логический вывод из видимых паттернов — явно помечен во всех местах.