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>
1137 lines
48 KiB
Markdown
1137 lines
48 KiB
Markdown
# 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<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)
|
||
|
||
```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<void>; // invoke('list_*_accounts')
|
||
runCurrentRefresh: () => Promise<void>; // 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<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-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<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)
|
||
|
||
```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("<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)
|
||
|
||
```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<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 дня)
|
||
|
||
```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 = логический вывод из видимых паттернов — явно помечен во всех местах.*
|