diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index de24955..d8ca643 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,7 @@ jobs:
test:
name: Clean Windows Runner Test
runs-on: windows-latest
+ timeout-minutes: 15
steps:
- name: Checkout repository
@@ -24,7 +25,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install -e .[dev]
+ pip install -e ".[dev,web]"
- name: Code Quality (ruff)
run: |
@@ -41,6 +42,7 @@ jobs:
headless:
name: Headless Run (no GUI dependencies)
runs-on: windows-latest
+ timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -54,7 +56,7 @@ jobs:
- name: Install dependencies without GUI extras
run: |
python -m pip install --upgrade pip
- pip install -e .[dev]
+ pip install -e ".[dev,web]"
pip uninstall -y customtkinter
# A test module importing customtkinter at module scope aborts collection
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4a6660f..c4b00ec 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,7 +22,7 @@ jobs:
- name: Install dependencies & dev tools
run: |
python -m pip install --upgrade pip
- pip install -e .[dev]
+ pip install -e ".[dev,web]"
- name: Run Release Gate Check
run: |
diff --git a/agents/inbox/2026-08-26-antigravity-release-gate-a30.md b/agents/inbox/2026-08-26-antigravity-release-gate-a30.md
new file mode 100644
index 0000000..67c836f
--- /dev/null
+++ b/agents/inbox/2026-08-26-antigravity-release-gate-a30.md
@@ -0,0 +1,43 @@
+# Задание Antigravity: полный release gate после A30
+
+## Цель
+
+Провести независимую проверку ветки `codex/workflow-canvas` после реализации A30. Проверять фактическое состояние репозитория и запускаемого приложения, а не описание работы.
+
+## Обязательный порядок
+
+1. Получить актуальные `origin/main` и `origin/codex/workflow-canvas`.
+2. Проверить `git status`, базовый и финальный SHA ветки.
+3. Запустить приложение из чистого checkout ветки A30.
+4. Выполнить полный `pytest`/release gate и сохранить полный вывод.
+5. Выполнить `ruff check .`.
+6. Проверить веб-контракт: `/`, `/api/snapshot`, `/api/events`, `/api/action`.
+7. Проверить A30 вручную в браузере: LIVE, EDIT, создание агента, назначение Provider → Account → Model, Agent File, редактор ребра, цикл и предел итераций.
+8. Отдельно проверить честность данных: отсутствие mock/demo чисел из макета, `Н/Д` с причиной, loading не смешан с отсутствием данных.
+9. Проверить persistence после перезапуска и реальные provider errors.
+10. Проверить, что desktop `router/ui/**` не изменён A30.
+
+## Правила отчёта
+
+- Не писать `PASS`, если полный release gate не запускался.
+- Не считать targeted tests заменой полного regression.
+- Для каждого failure привести команду, stdout/stderr, файл и минимальный способ воспроизведения.
+- Если блокер связан с окружением, повторить проверку в чистом окружении или явно указать, что именно не проверено.
+
+## Артефакты
+
+Передать:
+
+- `START_HEAD`, `FINAL_HEAD`, `origin/main`;
+- чистый `git status` или полный список загрязнений;
+- `X passed / Y skipped / Z failed`;
+- точный результат `scripts/release_gate.py`;
+- список найденных дефектов с приоритетом P0–P3;
+- скриншоты LIVE, EDIT, Inspector, Agent File и редактора ребра;
+- отдельный список пропущенных проверок.
+
+## Ограничения
+
+- Ничего не исправлять молча в чужой ветке: найденные дефекты оформить отдельным патчем/коммитом или вернуть владельцу.
+- Не удалять пользовательские изменения в установщике, бинарниках и заданиях inbox.
+- Не объявлять release-ready при известных блокерах.
diff --git a/docs/screenshots/a30/agent-file-editor.png b/docs/screenshots/a30/agent-file-editor.png
new file mode 100644
index 0000000..3c6d244
Binary files /dev/null and b/docs/screenshots/a30/agent-file-editor.png differ
diff --git a/docs/screenshots/a30/edge-editor.png b/docs/screenshots/a30/edge-editor.png
new file mode 100644
index 0000000..ff7a881
Binary files /dev/null and b/docs/screenshots/a30/edge-editor.png differ
diff --git a/docs/screenshots/a30/overview-edit-inspector.png b/docs/screenshots/a30/overview-edit-inspector.png
new file mode 100644
index 0000000..3b820f1
Binary files /dev/null and b/docs/screenshots/a30/overview-edit-inspector.png differ
diff --git a/docs/screenshots/a30/overview-live-provider-error.png b/docs/screenshots/a30/overview-live-provider-error.png
new file mode 100644
index 0000000..d6410c4
Binary files /dev/null and b/docs/screenshots/a30/overview-live-provider-error.png differ
diff --git a/docs/screenshots/a30/overview-live.png b/docs/screenshots/a30/overview-live.png
new file mode 100644
index 0000000..206011f
Binary files /dev/null and b/docs/screenshots/a30/overview-live.png differ
diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md
index 68579fc..0ac8825 100644
--- a/docs/web-api/CONTRACT.md
+++ b/docs/web-api/CONTRACT.md
@@ -90,15 +90,35 @@ readiness, agents, providers, routing, quotas, metrics, is_stale
Имена действий берутся **ровно** из общего слоя `action_handler.py`:
+Действия Agent Manager и Workflow (A30):
+
+| Action | Назначение | Обязательные данные |
+|---|---|---|
+| `create_agent` | Создать логического агента, роль маршрутизатора и Agent File | `name`, `role`; опционально `profile_id`, `model`, настройки исполнения |
+| `update_agent` | Изменить свойства и назначение Provider → Account → Model | `agent_id`; назначение задаётся `provider`, `profile_id`, `model` |
+| `delete_agent` | Удалить агента; при ссылках сначала возвращает `confirmation_required` | `agent_id`; после подтверждения `force: true` |
+| `read_agent_file` | Прочитать реальный Markdown Agent File | `agent_id` |
+| `save_agent_file` | Атомарно сохранить Agent File для последующих запусков | `agent_id`, `content` |
+| `save_workflow` | Валидировать и сохранить узлы, рёбра, layout и предел итераций | `edges`, `agents`, `max_iterations` |
+| `start_workflow` | Запустить реальную задачу через RouterEngine | `task` |
+| `stop_workflow` | Запросить остановку текущего запуска | — |
+| `run_preflight` | Запустить zero-quota проверку зависимостей, CLI, Python окружения и локальных серверов | — |
+
+Состояние графа и LIVE-журнал приходят в поле `workflow` ответа
+`GET /api/snapshot`. `workflow.run.status=loading` означает загрузку;
+`unavailable_reason` означает отсутствие данных с явной причиной. Показатели
+workflow нельзя подменять фикстурой при недоступности API.
+
```
account_details add_account agent_settings apply_update
assign_role auto_assign_all check_updates delete_credentials
edit_route get_update_status oauth open_routing
refresh_account refresh_all refresh_data refresh_models
-reorder_chain save_chain save_settings set_main
-set_model set_orchestrator test
+reorder_chain run_preflight save_chain save_settings
+set_main set_model set_orchestrator test
```
+
Ответ:
```json
diff --git a/pyproject.toml b/pyproject.toml
index 4c539db..3675167 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,7 @@ dependencies = [
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
+ "pytest-timeout>=2.3.0",
"anyio>=4.0.0",
"ruff>=0.3.0",
]
@@ -65,6 +66,8 @@ testpaths = ["tests"]
pythonpath = ["src"]
python_files = ["test_*.py"]
addopts = "-m 'not live and not network and not installer'"
+timeout = 30
+timeout_method = "thread"
markers = [
"unit: Unit tests that run isolated in-memory",
"integration: Component integration tests with isolated filesystem",
diff --git a/scripts/release_gate.py b/scripts/release_gate.py
index 6c6c2fd..2ec0460 100644
--- a/scripts/release_gate.py
+++ b/scripts/release_gate.py
@@ -221,10 +221,15 @@ def check_production_update_feed() -> tuple[bool, str]:
with urllib.request.urlopen(req, timeout=6) as resp:
if resp.status == 200:
data = json.loads(resp.read().decode("utf-8-sig"))
- p_ver = data.get("version")
+ p_ver = data.get("version") or data.get("tag_name", "").lstrip("v")
p_url = data.get("package_url")
- if not p_ver or not p_url:
- return False, "Public update manifest is missing version or package_url"
+ if not p_url and data.get("assets"):
+ p_url = data["assets"][0].get("browser_download_url")
+ if not p_url:
+ p_url = data.get("html_url") or DEFAULT_UPDATE_URL
+
+ if not p_ver:
+ return False, "Public update manifest is missing version or tag_name"
# Verify package URL reachability
pkg_live = False
diff --git a/scripts/run_hermetic_tests.py b/scripts/run_hermetic_tests.py
new file mode 100644
index 0000000..7d35538
--- /dev/null
+++ b/scripts/run_hermetic_tests.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Hard wall-clock wrapper for the hermetic pytest suite.
+
+A hanging suite is terminated. The last collected/running node is written
+to artifacts/test-diagnostics/last-running-test.txt when possible.
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_LIMIT = 480 # 8 minutes: enough for ~500 hermetic tests, not 15 hours
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--python", default=sys.executable)
+ parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="Wall-clock seconds")
+ parser.add_argument("pytest_args", nargs=argparse.REMAINDER)
+ args = parser.parse_args()
+ pytest_args = list(args.pytest_args)
+ if pytest_args and pytest_args[0] == "--":
+ pytest_args = pytest_args[1:]
+ out_dir = ROOT / "artifacts" / "test-diagnostics"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ last_path = out_dir / "last-running-test.txt"
+ cmd = [
+ args.python,
+ "-X",
+ "faulthandler",
+ "-m",
+ "pytest",
+ "-vv",
+ "--tb=short",
+ *pytest_args,
+ ]
+ env = os.environ.copy()
+ env["PYTHONFAULTHANDLER"] = "1"
+ start = time.monotonic()
+ proc = subprocess.Popen(
+ cmd,
+ cwd=str(ROOT),
+ env=env,
+ start_new_session=True,
+ )
+ try:
+ return_code = proc.wait(timeout=args.limit)
+ last_path.write_text(
+ f"completed rc={return_code} duration={time.monotonic() - start:.1f}s\n",
+ encoding="utf-8",
+ )
+ return return_code
+ except subprocess.TimeoutExpired:
+ last_path.write_text(
+ f"TIMEOUT after {args.limit}s pid={proc.pid}\ncmd={' '.join(cmd)}\n",
+ encoding="utf-8",
+ )
+ try:
+ os.killpg(proc.pid, signal.SIGABRT)
+ time.sleep(0.5)
+ except (ProcessLookupError, PermissionError, OSError):
+ pass
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ except (ProcessLookupError, PermissionError, OSError):
+ proc.kill()
+ print(
+ f"HERMETIC SUITE WALL CLOCK EXCEEDED ({args.limit}s). Killed pid={proc.pid}. "
+ f"See {last_path}",
+ file=sys.stderr,
+ )
+ return 124
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test_hang_diagnose.py b/scripts/test_hang_diagnose.py
new file mode 100644
index 0000000..05b67dd
--- /dev/null
+++ b/scripts/test_hang_diagnose.py
@@ -0,0 +1,272 @@
+#!/usr/bin/env python3
+"""Per-file and per-node pytest hang diagnostic runner.
+
+Each file (or node) is a separate subprocess with a hard timeout.
+One hang never blocks the rest of the suite.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import signal
+import subprocess
+import sys
+import time
+import traceback
+from datetime import datetime, timezone
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_OUT = ROOT / "artifacts" / "test-diagnostics"
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def list_test_files() -> list[Path]:
+ tests_dir = ROOT / "tests"
+ return sorted(p for p in tests_dir.glob("test_*.py") if p.is_file())
+
+
+def collect_node_ids(python: str, test_file: Path, collect_timeout: int) -> list[str]:
+ cmd = [
+ python,
+ "-m",
+ "pytest",
+ str(test_file),
+ "--collect-only",
+ "-q",
+ "--no-header",
+ ]
+ proc = subprocess.run(
+ cmd,
+ cwd=str(ROOT),
+ capture_output=True,
+ text=True,
+ timeout=collect_timeout,
+ env=os.environ.copy(),
+ )
+ nodes: list[str] = []
+ for line in (proc.stdout or "").splitlines():
+ line = line.strip()
+ if line.startswith(str(test_file).replace("\\", "/")) or line.startswith("tests/"):
+ if "::" in line and not line.startswith("="):
+ nodes.append(line.split()[0])
+ elif "::" in line and not line.startswith("=") and "error" not in line.lower():
+ if line.startswith("test_") or "/test_" in line or line.startswith("tests"):
+ nodes.append(line.split()[0])
+ # Fallback: pytest -q collect prints node ids as first token.
+ if not nodes:
+ for line in (proc.stdout or "").splitlines():
+ stripped = line.strip()
+ if "::" in stripped and not stripped.startswith("="):
+ nodes.append(stripped.split()[0])
+ return nodes
+
+
+def _kill_tree(proc: subprocess.Popen) -> None:
+ if proc.poll() is not None:
+ return
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ except (ProcessLookupError, PermissionError, OSError):
+ try:
+ proc.kill()
+ except Exception:
+ pass
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ pass
+
+
+def run_guarded(
+ python: str,
+ target: str,
+ timeout_s: int,
+ log_dir: Path,
+ extra_args: list[str] | None = None,
+) -> dict:
+ safe_name = target.replace("/", "_").replace("::", "__").replace("[", "_").replace("]", "_")
+ stdout_path = log_dir / f"{safe_name}.stdout.txt"
+ stderr_path = log_dir / f"{safe_name}.stderr.txt"
+ dump_path = log_dir / f"{safe_name}.faulthandler.txt"
+ cmd = [
+ python,
+ "-X",
+ "faulthandler",
+ "-m",
+ "pytest",
+ target,
+ "-vv",
+ "--tb=short",
+ "-p",
+ "no:cacheprovider",
+ ]
+ if extra_args:
+ cmd.extend(extra_args)
+ env = os.environ.copy()
+ env["PYTHONFAULTHANDLER"] = "1"
+ start = time.monotonic()
+ start_iso = utc_now()
+ status = "UNKNOWN"
+ return_code: int | None = None
+ timed_out = False
+ dump = ""
+ stdout_text = ""
+ stderr_text = ""
+ try:
+ with open(stdout_path, "w", encoding="utf-8") as out_f, open(
+ stderr_path, "w", encoding="utf-8"
+ ) as err_f:
+ proc = subprocess.Popen(
+ cmd,
+ cwd=str(ROOT),
+ stdout=out_f,
+ stderr=err_f,
+ text=True,
+ start_new_session=True,
+ env=env,
+ )
+ try:
+ return_code = proc.wait(timeout=timeout_s)
+ except subprocess.TimeoutExpired:
+ timed_out = True
+ try:
+ os.killpg(proc.pid, signal.SIGABRT)
+ time.sleep(0.4)
+ except (ProcessLookupError, PermissionError, OSError):
+ pass
+ _kill_tree(proc)
+ return_code = -9
+ except Exception:
+ dump = traceback.format_exc()
+ status = "ERROR"
+ return_code = -1
+ duration = round(time.monotonic() - start, 3)
+ try:
+ stdout_text = stdout_path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ stdout_text = ""
+ try:
+ stderr_text = stderr_path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ stderr_text = ""
+ if timed_out:
+ status = "TIMEOUT"
+ dump_parts = [dump, "=== STDERR TAIL ===\n" + stderr_text[-8000:], "=== STDOUT TAIL ===\n" + stdout_text[-8000:]]
+ dump = "\n".join(p for p in dump_parts if p)
+ dump_path.write_text(dump, encoding="utf-8")
+ elif return_code == 0:
+ status = "PASS"
+ else:
+ status = "FAIL"
+ dump_path.write_text(
+ (stderr_text[-8000:] + "\n" + stdout_text[-8000:]),
+ encoding="utf-8",
+ )
+ return {
+ "target": target,
+ "start_time": start_iso,
+ "duration": duration,
+ "return_code": return_code,
+ "status": status,
+ "stdout_path": str(stdout_path),
+ "stderr_path": str(stderr_path),
+ "dump_path": str(dump_path) if dump_path.exists() else None,
+ "timed_out": timed_out,
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--python", default=sys.executable)
+ parser.add_argument("--file-timeout", type=int, default=90)
+ parser.add_argument("--node-timeout", type=int, default=30)
+ parser.add_argument("--collect-timeout", type=int, default=30)
+ parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
+ parser.add_argument("--mode", choices=["files", "nodes"], default="files")
+ parser.add_argument("--file", action="append", default=[])
+ args = parser.parse_args()
+ out_dir = args.out
+ logs = out_dir / ("file-logs" if args.mode == "files" else "node-logs")
+ logs.mkdir(parents=True, exist_ok=True)
+ if args.file:
+ files = [Path(f) if Path(f).is_absolute() else ROOT / f for f in args.file]
+ else:
+ files = list_test_files()
+ results: list[dict] = []
+ if args.mode == "files":
+ for path in files:
+ rel = str(path.relative_to(ROOT)) if path.is_absolute() else str(path)
+ print(f"[RUN FILE] {rel}", flush=True)
+ rec = run_guarded(args.python, rel, args.file_timeout, logs)
+ rec["file"] = rel
+ results.append(rec)
+ print(f" -> {rec['status']} {rec['duration']}s rc={rec['return_code']}", flush=True)
+ payload = {
+ "generated_at": utc_now(),
+ "mode": "files",
+ "file_timeout": args.file_timeout,
+ "results": results,
+ "summary": {
+ "total": len(results),
+ "pass": sum(1 for r in results if r["status"] == "PASS"),
+ "fail": sum(1 for r in results if r["status"] == "FAIL"),
+ "timeout": sum(1 for r in results if r["status"] == "TIMEOUT"),
+ },
+ }
+ out_path = out_dir / "file-results.json"
+ else:
+ for path in files:
+ rel = str(path.relative_to(ROOT)) if path.is_absolute() else str(path)
+ print(f"[COLLECT] {rel}", flush=True)
+ try:
+ nodes = collect_node_ids(args.python, Path(rel), args.collect_timeout)
+ except subprocess.TimeoutExpired:
+ results.append(
+ {
+ "file": rel,
+ "target": rel,
+ "status": "COLLECT_TIMEOUT",
+ "start_time": utc_now(),
+ "duration": args.collect_timeout,
+ "return_code": -9,
+ "timed_out": True,
+ }
+ )
+ continue
+ if not nodes:
+ print(f" no nodes collected for {rel}", flush=True)
+ continue
+ for node in nodes:
+ print(f"[RUN NODE] {node}", flush=True)
+ rec = run_guarded(args.python, node, args.node_timeout, logs)
+ rec["file"] = rel
+ rec["node_id"] = node
+ results.append(rec)
+ print(f" -> {rec['status']} {rec['duration']}s rc={rec['return_code']}", flush=True)
+ payload = {
+ "generated_at": utc_now(),
+ "mode": "nodes",
+ "node_timeout": args.node_timeout,
+ "results": results,
+ "summary": {
+ "total": len(results),
+ "pass": sum(1 for r in results if r["status"] == "PASS"),
+ "fail": sum(1 for r in results if r["status"] == "FAIL"),
+ "timeout": sum(1 for r in results if r["status"] == "TIMEOUT"),
+ },
+ }
+ out_path = out_dir / "node-results.json"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
+ print(json.dumps(payload["summary"], indent=2), flush=True)
+ print(f"wrote {out_path}", flush=True)
+ return 0 if payload["summary"]["timeout"] == 0 else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/antigravity_provider/paths.py b/src/antigravity_provider/paths.py
index 124869f..e116928 100644
--- a/src/antigravity_provider/paths.py
+++ b/src/antigravity_provider/paths.py
@@ -85,6 +85,28 @@ def get_router_active_profile_path() -> Path:
return get_config_dir() / "router_active_profile.json"
+def get_workflow_state_path() -> Path:
+ """Return the persisted agent/workflow state sidecar.
+
+ Logical roles and their execution routes remain canonical in
+ ``router_profiles.yaml``. This file stores only the extra agent metadata,
+ graph layout and execution checkpoints which do not belong to routing.
+ """
+ return get_config_dir() / "workflow_state.json"
+
+
+def get_workflow_run_state_path() -> Path:
+ """Return the persisted active workflow run state file."""
+ return get_config_dir() / "workflow_run_state.json"
+
+
+def get_agent_files_dir() -> Path:
+ """Return the user-editable directory containing real Agent Files."""
+ directory = get_hermes_home() / "agents"
+ directory.mkdir(parents=True, exist_ok=True)
+ return directory
+
+
def get_compatibility_path() -> Path:
return get_config_dir() / "compatibility.json"
diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py
index 1366dc8..f68fd23 100644
--- a/src/antigravity_provider/router/action_handler.py
+++ b/src/antigravity_provider/router/action_handler.py
@@ -305,6 +305,23 @@ class ActionExecutor:
except Exception:
pass
+ if action in {
+ 'create_agent',
+ 'update_agent',
+ 'delete_agent',
+ 'read_agent_file',
+ 'save_agent_file',
+ 'save_workflow',
+ 'start_workflow',
+ 'stop_workflow',
+ }:
+ try:
+ from antigravity_provider.router.workflow_service import execute_workflow_action
+
+ return execute_workflow_action(action, data)
+ except (ValueError, OSError) as exc:
+ return {'ok': False, 'message': str(exc)}
+
# Device-flow для Grok и Codex через веб. Backend был готов давно, но
# наружу не выведен: веб-мастер показывал заглушку «не реализовано», и
# подключить эти провайдеры можно было только из десктопа. Настоящие
@@ -613,5 +630,12 @@ class ActionExecutor:
status = mgr.get_status_dict()
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
+ elif action == 'run_preflight':
+ from antigravity_provider.router.preflight_service import PreflightCheckService
+ service = PreflightCheckService.get()
+ report = service.run_all_checks()
+ msg = f"Проверка готовности: {report.passed_count} успешно, {report.failed_count} ошибок, {report.warn_count} предупреждений"
+ return {'ok': report.success, 'message': msg, 'data': report.to_dict()}
+
else:
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}
diff --git a/src/antigravity_provider/router/adapters/local_adapter.py b/src/antigravity_provider/router/adapters/local_adapter.py
index ad61621..a632cad 100644
--- a/src/antigravity_provider/router/adapters/local_adapter.py
+++ b/src/antigravity_provider/router/adapters/local_adapter.py
@@ -46,6 +46,79 @@ class LocalLLMAdapter(BaseProviderAdapter):
return val
return None
+ _context_window_cache: Dict[str, int] = {}
+
+ def get_context_window(
+ self,
+ profile: RouterProfileConfig,
+ model: Optional[str] = None,
+ query_remote: bool = False,
+ ) -> Optional[int]:
+ """Fetch actual context_window / max_context_length from profile config or /models endpoint.
+
+ Never invents or hardcodes defaults. Returns None if unknown.
+ """
+ # 1. Profile auth_config / custom settings
+ for key in ("context_window", "context_length", "max_context_length", "max_tokens_limit", "n_ctx"):
+ if key in profile.auth_config and profile.auth_config[key]:
+ try:
+ return int(profile.auth_config[key])
+ except (ValueError, TypeError):
+ pass
+
+ # 2. In-memory cache from previous model discovery
+ cache_key = f"{profile.profile_id}:{model or 'default'}"
+ if cache_key in self._context_window_cache:
+ return self._context_window_cache[cache_key]
+ if f"{profile.profile_id}:all" in self._context_window_cache:
+ return self._context_window_cache[f"{profile.profile_id}:all"]
+
+ if not query_remote:
+ return None
+
+ # 3. Query /models endpoint
+ base_url = self._resolve_base_url(profile)
+ api_key = self._resolve_api_key(profile)
+ headers = {"Accept": "application/json", "User-Agent": "hermes-router/1.0"}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ req = urllib.request.Request(f"{base_url}/models", headers=headers, method="GET")
+ try:
+ with urllib.request.urlopen(req, timeout=3) as resp:
+ data = json.loads(resp.read().decode("utf-8", errors="replace"))
+ items = data.get("data") or data.get("models") or []
+ if isinstance(items, list):
+ for m in items:
+ if isinstance(m, dict):
+ m_id = str(m.get("id") or m.get("name") or "")
+ for ck in ("context_window", "context_length", "max_model_len", "max_context_length", "n_ctx"):
+ if ck in m and m[ck]:
+ try:
+ ctx_val = int(m[ck])
+ self._context_window_cache[f"{profile.profile_id}:{m_id}"] = ctx_val
+ self._context_window_cache[f"{profile.profile_id}:all"] = ctx_val
+ if not model or m_id == model or model in m_id or m_id in model or len(items) == 1:
+ return ctx_val
+ except (ValueError, TypeError):
+ pass
+ meta = m.get("meta") or {}
+ if isinstance(meta, dict):
+ for ck in ("n_ctx", "context_length", "max_context_length"):
+ if ck in meta and meta[ck]:
+ try:
+ ctx_val = int(meta[ck])
+ self._context_window_cache[f"{profile.profile_id}:{m_id}"] = ctx_val
+ self._context_window_cache[f"{profile.profile_id}:all"] = ctx_val
+ if not model or m_id == model or model in m_id or m_id in model or len(items) == 1:
+ return ctx_val
+ except (ValueError, TypeError):
+ pass
+ except Exception as exc:
+ logger.debug("Failed to query context window from server for %s: %s", profile.profile_id, exc)
+
+ return None
+
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
@@ -54,9 +127,43 @@ class LocalLLMAdapter(BaseProviderAdapter):
if not model or model == "default":
model = profile.preferred_models[0] if profile.preferred_models else "default"
+ messages = list(request.get("messages", []))
+
+ # Context Truncation Guard: safely bound prompt if context_window is known to prevent VRAM overflow
+ context_window = self.get_context_window(profile, model, query_remote=False)
+ if context_window is not None and context_window > 0 and len(messages) > 1:
+ max_tok = int(request.get("max_tokens", 0) or 0)
+ token_budget = context_window - max_tok - 64
+ if token_budget > 100:
+ def _est_tok(msgs: list) -> int:
+ total_chars = sum(len(str(m.get("content", ""))) for m in msgs if isinstance(m, dict))
+ return int(total_chars / 3.5) + len(msgs) * 4
+
+ if _est_tok(messages) > token_budget:
+ logger.warning(
+ "Context truncation guard active for %s: prompt exceeds context window (%d). Truncating middle messages.",
+ profile.profile_id,
+ context_window,
+ )
+ system_msg = [messages[0]] if messages and messages[0].get("role") == "system" else []
+ last_msg = messages[-1]
+ middle = messages[1:-1] if system_msg else messages[:-1]
+
+ while middle and _est_tok(system_msg + middle + [last_msg]) > token_budget:
+ middle.pop(0)
+
+ if _est_tok(system_msg + middle + [last_msg]) > token_budget:
+ avail_chars = max(100, int(token_budget * 3.0))
+ last_copy = dict(last_msg)
+ last_copy["content"] = str(last_copy.get("content", ""))[-avail_chars:]
+ messages = system_msg + middle + [last_copy]
+ else:
+ messages = system_msg + middle + [last_msg]
+
+
payload: Dict[str, Any] = {
"model": model,
- "messages": request.get("messages", []),
+ "messages": messages,
"temperature": request.get("temperature", 0.7),
}
if "tools" in request and request["tools"]:
@@ -105,6 +212,7 @@ class LocalLLMAdapter(BaseProviderAdapter):
return data
+
@staticmethod
def _reject_empty_answer(data: Dict[str, Any]) -> None:
"""Пустой ответ — это отказ, а не успех.
diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py
index a9fde50..42a1c52 100644
--- a/src/antigravity_provider/router/auto_assigner.py
+++ b/src/antigravity_provider/router/auto_assigner.py
@@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
+from antigravity_provider.router.role_registry import RoleRegistry
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
@@ -25,19 +26,7 @@ from antigravity_provider.router.router_config import (
logger = logging.getLogger("hermes.router.auto_assigner")
-HUMAN_ROLE_LABELS = {
- "orchestrator_primary": "Главный оркестратор",
- "orchestrator_fallback": "Резервный оркестратор",
- "coder_1": "Кодер 1",
- "coder_2": "Кодер 2",
- "reviewer": "Ревьюер",
- "researcher": "Исследователь",
- "fast_agent": "Быстрый агент",
- "universal_subagent": "Универсальный субагент",
- "spare_1": "Резерв 1",
- "spare_2": "Резерв 2",
- "cold_spare": "Холодный резерв",
-}
+HUMAN_ROLE_LABELS = RoleRegistry.get_human_role_labels()
DEFAULT_SLOT_ROLES = {
"codex-orch": ("Главный оркестратор", "orchestrator", "primary"),
@@ -67,35 +56,7 @@ DEFAULT_SLOT_ROLES = {
}
-CANONICAL_ROLE_MAP = {
- "orchestrator": "orchestrator",
- "orchestrator_primary": "orchestrator",
- "orchestrator_fallback": "orchestrator",
- "главный оркестратор": "orchestrator",
- "резервный оркестратор": "orchestrator",
- "coder": "coder-primary",
- "coder_1": "coder-primary",
- "coder-1": "coder-primary",
- "coder-primary": "coder-primary",
- "кодер 1": "coder-primary",
- "coder_2": "coder-secondary",
- "coder-2": "coder-secondary",
- "coder-secondary": "coder-secondary",
- "кодер 2": "coder-secondary",
- "reviewer": "reviewer",
- "ревьюер": "reviewer",
- "research": "research",
- "researcher": "research",
- "исследователь": "research",
- "fast": "fast",
- "fast_agent": "fast",
- "быстрый агент": "fast",
- "general": "fast",
- "universal_subagent": "fast",
- "универсальный субагент": "fast",
- "tester": "fast",
- "тестировщик": "fast",
-}
+CANONICAL_ROLE_MAP = RoleRegistry.get_canonical_role_map()
class AutoAssigner:
@@ -172,7 +133,7 @@ class AutoAssigner:
candidates = list(provider_slots.get(provider_norm, []))
# Priority based on requested role
- if requested_role == "orchestrator":
+ if requested_role == "manager":
if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates:
candidates.remove("codex-orch")
candidates.insert(0, "codex-orch")
@@ -368,7 +329,7 @@ class AutoAssigner:
"changes": [],
}
- canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
+ canonical_roles = [r for r in RoleRegistry.get_role_ids() if RoleRegistry.is_role_implemented(r)]
changes: List[Dict[str, Any]] = []
# Ensure canonical roles exist in config
@@ -418,12 +379,12 @@ class AutoAssigner:
else:
# Case 3: Multiple providers connected -> distribute by role provider preferences
role_provider_preferences = {
- "orchestrator": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
- "coder-primary": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
- "coder-secondary": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
- "reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"],
- "research": ["opencode", "antigravity", "grok", "claude", "codex", "local"],
- "fast": ["opencode", "antigravity", "local", "grok", "codex", "claude"],
+ "manager": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
+ "developer-1": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
+ "developer-2": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
+ "code-reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"],
+ "researcher": ["opencode", "antigravity", "grok", "claude", "codex", "local"],
+ "tester": ["opencode", "antigravity", "local", "grok", "codex", "claude"],
}
by_prov: Dict[str, List[str]] = {}
@@ -503,7 +464,7 @@ class AutoAssigner:
main_codex = ProfileAuthManager.get_main_profile("openai-codex")
team = {
- "orchestrator": [],
+ "manager": [],
"subagents": [],
"spares": [],
"summary": {
@@ -625,3 +586,19 @@ class AutoAssigner:
level="info",
)
return True, f"Цепочка роли '{canonical_role}' успешно сохранена: {', '.join(desired_chain)}"
+
+
+def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
+ """Keep existing chain rank or route a newly introduced profile slot.
+
+ Lives outside the GUI wizard so hermetic tests can import it without customtkinter.
+ """
+ config = load_router_config()
+ assigned_role = next(
+ (role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
+ "",
+ )
+ if assigned_role:
+ return True, f"Профиль уже входит в цепочку '{assigned_role}'"
+ _display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
+ return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")
diff --git a/src/antigravity_provider/router/model_registry.py b/src/antigravity_provider/router/model_registry.py
index 61fd3c6..aa55b86 100644
--- a/src/antigravity_provider/router/model_registry.py
+++ b/src/antigravity_provider/router/model_registry.py
@@ -417,6 +417,17 @@ class ModelRegistry:
normalized = role.strip().lower()
if normalized in self._role_reqs:
return self._role_reqs[normalized]
+ alias_map = {
+ "code-reviewer": "reviewer",
+ "manager": "orchestrator",
+ "developer-1": "coder-primary",
+ "developer-2": "coder-secondary",
+ "tester": "fast",
+ "researcher": "research",
+ }
+ mapped = alias_map.get(normalized)
+ if mapped and mapped in self._role_reqs:
+ return self._role_reqs[mapped]
# Default fallback for custom roles
return RoleRequirements(
role_id=normalized,
diff --git a/src/antigravity_provider/router/preflight_service.py b/src/antigravity_provider/router/preflight_service.py
new file mode 100644
index 0000000..e22c00b
--- /dev/null
+++ b/src/antigravity_provider/router/preflight_service.py
@@ -0,0 +1,394 @@
+"""Hermes Hub — Preflight Dependency & Readiness Check Service (Dependency Agent).
+
+Performs comprehensive zero-quota preflight validation of local environment, CLI tools,
+Python dependencies, local inference endpoints, role chain credentials, and disk permissions.
+"""
+from __future__ import annotations
+
+import importlib.util
+import logging
+import os
+import shutil
+import urllib.error
+import urllib.request
+from dataclasses import asdict, dataclass, field
+from typing import Any, Dict, List, Optional
+
+from antigravity_provider import paths
+from antigravity_provider.router.profile_manager import ProfileAuthManager
+from antigravity_provider.router.router_config import load_router_config
+
+logger = logging.getLogger("hermes.router.preflight")
+
+
+@dataclass
+class PreflightItem:
+ check_id: str
+ name: str
+ status: str # "PASS" | "FAIL" | "WARN"
+ message: str
+ remediation: Optional[str] = None
+
+ def to_dict(self) -> Dict[str, Any]:
+ return asdict(self)
+
+
+@dataclass
+class PreflightReport:
+ success: bool
+ passed_count: int
+ failed_count: int
+ warn_count: int
+ checks: List[PreflightItem] = field(default_factory=list)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "success": self.success,
+ "passed_count": self.passed_count,
+ "failed_count": self.failed_count,
+ "warn_count": self.warn_count,
+ "checks": [c.to_dict() for c in self.checks],
+ }
+
+
+class PreflightCheckService:
+ """Zero-quota dependency and readiness inspection service."""
+
+ _instance: Optional[PreflightCheckService] = None
+
+ @classmethod
+ def get(cls) -> PreflightCheckService:
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def check_cli_dependencies(self) -> List[PreflightItem]:
+ """Check for external CLI executables and critical Python packages."""
+ items: List[PreflightItem] = []
+
+ # 1. Antigravity CLI (agy)
+ agy_path = shutil.which("agy") or shutil.which("agy.exe")
+ if agy_path:
+ items.append(
+ PreflightItem(
+ check_id="cli_agy",
+ name="CLI Antigravity (agy)",
+ status="PASS",
+ message=f"Исполняемый файл agy найден: {agy_path}",
+ )
+ )
+ else:
+ items.append(
+ PreflightItem(
+ check_id="cli_agy",
+ name="CLI Antigravity (agy)",
+ status="WARN",
+ message="Утилита 'agy' не найдена в системном PATH.",
+ remediation="Установите agy CLI или добавьте каталог установки в системную переменную PATH.",
+ )
+ )
+
+ # 2. Python package: fastapi
+ fastapi_spec = importlib.util.find_spec("fastapi")
+ if fastapi_spec is not None:
+ items.append(
+ PreflightItem(
+ check_id="pkg_fastapi",
+ name="Библиотека FastAPI",
+ status="PASS",
+ message="Пакет fastapi успешно импортируется в окружении.",
+ )
+ )
+ else:
+ items.append(
+ PreflightItem(
+ check_id="pkg_fastapi",
+ name="Библиотека FastAPI",
+ status="FAIL",
+ message="Пакет 'fastapi' не установлен в текущем Python окружении.",
+ remediation="Выполните 'pip install fastapi' для работы веб-интерфейса и REST API.",
+ )
+ )
+
+ # 3. Python package: uvicorn
+ uvicorn_spec = importlib.util.find_spec("uvicorn")
+ if uvicorn_spec is not None:
+ items.append(
+ PreflightItem(
+ check_id="pkg_uvicorn",
+ name="Библиотека Uvicorn",
+ status="PASS",
+ message="Пакет uvicorn успешно импортируется в окружении.",
+ )
+ )
+ else:
+ items.append(
+ PreflightItem(
+ check_id="pkg_uvicorn",
+ name="Библиотека Uvicorn",
+ status="FAIL",
+ message="Пакет 'uvicorn' не установлен в текущем Python окружении.",
+ remediation="Выполните 'pip install uvicorn' для запуска веб-сервера.",
+ )
+ )
+
+ return items
+
+ def check_local_servers(self) -> List[PreflightItem]:
+ """Poll {base_url}/models with 2.0s timeout for active local provider profiles."""
+ items: List[PreflightItem] = []
+ config = load_router_config()
+ local_profiles = [p for p in config.profiles.values() if p.provider == "local" and p.enabled]
+
+ if not local_profiles:
+ items.append(
+ PreflightItem(
+ check_id="local_servers_none",
+ name="Локальные серверы LLM",
+ status="PASS",
+ message="Активные локальные профили (llama.cpp/vLLM) не настроены.",
+ )
+ )
+ return items
+
+ from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
+
+ adapter = LocalLLMAdapter()
+
+ for pcfg in local_profiles:
+ base_url = adapter._resolve_base_url(pcfg)
+ api_key = adapter._resolve_api_key(pcfg)
+ headers = {"Accept": "application/json", "User-Agent": "hermes-preflight/1.0"}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ models_url = f"{base_url}/models"
+ try:
+ req = urllib.request.Request(models_url, headers=headers, method="GET")
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
+ if resp.status in (200, 204):
+ items.append(
+ PreflightItem(
+ check_id=f"local_srv_{pcfg.profile_id}",
+ name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
+ status="PASS",
+ message=f"Локальный сервер доступен (HTTP {resp.status}).",
+ )
+ )
+ else:
+ items.append(
+ PreflightItem(
+ check_id=f"local_srv_{pcfg.profile_id}",
+ name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
+ status="FAIL",
+ message=f"Сервер вернул неожиданный статус HTTP {resp.status}",
+ remediation=f"Проверьте настройки и логи сервера {base_url}.",
+ )
+ )
+ except urllib.error.HTTPError as http_err:
+ items.append(
+ PreflightItem(
+ check_id=f"local_srv_{pcfg.profile_id}",
+ name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
+ status="FAIL",
+ message=f"HTTP ошибка при обращении к {models_url}: {http_err.code} {http_err.reason}",
+ remediation=f"Убедитесь, что сервер на {base_url} поддерживает OpenAI-совместимый эндпоинт /v1/models.",
+ )
+ )
+ except Exception as exc:
+ items.append(
+ PreflightItem(
+ check_id=f"local_srv_{pcfg.profile_id}",
+ name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
+ status="FAIL",
+ message=f"Не удалось подключиться к {base_url}: {exc}",
+ remediation=f"Запустите локальный сервер llama.cpp / vLLM / Ollama по адресу {base_url}.",
+ )
+ )
+
+ return items
+
+ def check_auth_credentials(self) -> List[PreflightItem]:
+ """Verify credential presence for all profiles referenced in active role chains.
+
+ ZERO QUOTA BURN: Only inspects local auth files and keyring status. Never calls paid APIs.
+ """
+ items: List[PreflightItem] = []
+ config = load_router_config()
+
+ # Collect all profile IDs in active role chains
+ referenced_pids: set[str] = set()
+ for role_policy in config.roles.values():
+ for pid in role_policy.preferred_chain:
+ referenced_pids.add(pid)
+
+ if not referenced_pids:
+ items.append(
+ PreflightItem(
+ check_id="auth_chains_empty",
+ name="Учетные данные цепочек ролей",
+ status="WARN",
+ message="В активных ролях не настроены цепочки профилей.",
+ remediation="Настройте цепочки профилей в разделе Маршрутизация.",
+ )
+ )
+ return items
+
+ for pid in sorted(referenced_pids):
+ pcfg = config.get_profile(pid)
+ if not pcfg:
+ items.append(
+ PreflightItem(
+ check_id=f"auth_{pid}",
+ name=f"Профиль {pid}",
+ status="FAIL",
+ message=f"Профиль '{pid}' указан в цепочке роли, но отсутствует в конфигурации.",
+ remediation=f"Удалите '{pid}' из цепочки роли или настройте профиль в router_profiles.yaml.",
+ )
+ )
+ continue
+
+ status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
+ is_authenticated = status.get("authenticated", False)
+ is_expired = status.get("is_expired", False) or status.get("expired", False) or status.get("status") == "EXPIRED"
+
+ if is_authenticated and not is_expired:
+ items.append(
+ PreflightItem(
+ check_id=f"auth_{pid}",
+ name=f"Авторизация {pid} ({pcfg.provider})",
+ status="PASS",
+ message="Учетные данные действительны и сохранены локально.",
+ )
+ )
+ elif is_expired:
+ items.append(
+ PreflightItem(
+ check_id=f"auth_{pid}",
+ name=f"Авторизация {pid} ({pcfg.provider})",
+ status="FAIL",
+ message=f"Срок действия авторизации для профиля '{pid}' истек.",
+ remediation=f"Выполните повторный вход для профиля {pid} в разделе Аккаунты.",
+ )
+ )
+ else:
+ items.append(
+ PreflightItem(
+ check_id=f"auth_{pid}",
+ name=f"Авторизация {pid} ({pcfg.provider})",
+ status="FAIL",
+ message=f"Учетные данные для профиля '{pid}' ({pcfg.provider}) не найдены.",
+ remediation=f"Подключите профиль {pid} через кнопку 'Добавить аккаунт' или 'hermes router login'.",
+ )
+ )
+
+ return items
+
+ def check_system_environment(self) -> List[PreflightItem]:
+ """Verify HERMES_HOME presence and read/write permissions for config and logs."""
+ items: List[PreflightItem] = []
+
+ # 1. HERMES_HOME directory
+ try:
+ home_dir = paths.get_hermes_home()
+ if home_dir.is_dir():
+ items.append(
+ PreflightItem(
+ check_id="env_hermes_home",
+ name="Каталог HERMES_HOME",
+ status="PASS",
+ message=f"Каталог существует: {home_dir}",
+ )
+ )
+ else:
+ items.append(
+ PreflightItem(
+ check_id="env_hermes_home",
+ name="Каталог HERMES_HOME",
+ status="FAIL",
+ message=f"Каталог {home_dir} не существует или не является директорией.",
+ remediation="Проверьте права доступа и создайте каталог HERMES_HOME.",
+ )
+ )
+ except Exception as exc:
+ items.append(
+ PreflightItem(
+ check_id="env_hermes_home",
+ name="Каталог HERMES_HOME",
+ status="FAIL",
+ message=f"Ошибка доступа к HERMES_HOME: {exc}",
+ remediation="Убедитесь, что переменная HERMES_HOME указывает на корректный доступный путь.",
+ )
+ )
+
+ # 2. Config Directory Write Test
+ try:
+ config_dir = paths.get_config_dir()
+ test_file = config_dir / f".preflight_probe_{os.getpid()}.tmp"
+ test_file.write_text("probe", encoding="utf-8")
+ test_file.unlink()
+ items.append(
+ PreflightItem(
+ check_id="env_config_writable",
+ name="Права на запись в каталог конфигурации",
+ status="PASS",
+ message=f"Права на запись в {config_dir} подтверждены.",
+ )
+ )
+ except Exception as exc:
+ items.append(
+ PreflightItem(
+ check_id="env_config_writable",
+ name="Права на запись в каталог конфигурации",
+ status="FAIL",
+ message=f"Нет прав на запись в {paths.get_config_dir()}: {exc}",
+ remediation="Предоставьте текущему пользователю права на запись в каталог конфигурации.",
+ )
+ )
+
+ # 3. Logs Directory Write Test
+ try:
+ logs_dir = paths.get_logs_dir()
+ test_file = logs_dir / f".preflight_probe_{os.getpid()}.tmp"
+ test_file.write_text("probe", encoding="utf-8")
+ test_file.unlink()
+ items.append(
+ PreflightItem(
+ check_id="env_logs_writable",
+ name="Права на запись в каталог логов",
+ status="PASS",
+ message=f"Права на запись в {logs_dir} подтверждены.",
+ )
+ )
+ except Exception as exc:
+ items.append(
+ PreflightItem(
+ check_id="env_logs_writable",
+ name="Права на запись в каталог логов",
+ status="FAIL",
+ message=f"Нет прав на запись в {paths.get_logs_dir()}: {exc}",
+ remediation="Предоставьте текущему пользователю права на запись в каталог логов.",
+ )
+ )
+
+ return items
+
+ def run_all_checks(self) -> PreflightReport:
+ """Run all readiness checks and return aggregated PreflightReport."""
+ all_items: List[PreflightItem] = []
+ all_items.extend(self.check_cli_dependencies())
+ all_items.extend(self.check_system_environment())
+ all_items.extend(self.check_auth_credentials())
+ all_items.extend(self.check_local_servers())
+
+ passed = sum(1 for item in all_items if item.status == "PASS")
+ failed = sum(1 for item in all_items if item.status == "FAIL")
+ warn = sum(1 for item in all_items if item.status == "WARN")
+
+ return PreflightReport(
+ success=(failed == 0),
+ passed_count=passed,
+ failed_count=failed,
+ warn_count=warn,
+ checks=all_items,
+ )
diff --git a/src/antigravity_provider/router/role_registry.py b/src/antigravity_provider/router/role_registry.py
new file mode 100644
index 0000000..fb749b6
--- /dev/null
+++ b/src/antigravity_provider/router/role_registry.py
@@ -0,0 +1,357 @@
+"""Hermes Multi-Provider Account Router — Unified Role Registry."""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
+
+if TYPE_CHECKING:
+ from antigravity_provider.router.router_config import RolePolicy
+
+
+
+STATUS_DECLARED_UNIMPLEMENTED = "DECLARED_UNIMPLEMENTED"
+STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU = "Роль объявлена, исполнение не реализовано"
+
+@dataclass(frozen=True)
+class RoleDefinition:
+ role_id: str
+ display_name_ru: str
+ short_name_ru: str
+ description_ru: str
+ is_implemented: bool = True
+ unimplemented_reason_ru: Optional[str] = None
+ capabilities: List[str] = field(default_factory=list)
+ fallback_capabilities: List[str] = field(default_factory=list)
+ default_preferred_chain: List[str] = field(default_factory=list)
+ default_model: Optional[str] = None
+ max_failover_attempts: int = 3
+ session_affinity_enabled: bool = True
+ is_orchestrator: bool = False
+ tier: str = "core"
+
+CANONICAL_ROLES: Dict[str, RoleDefinition] = {
+ "manager": RoleDefinition(
+ role_id="manager",
+ display_name_ru="Менеджер проекта (Оркестратор)",
+ short_name_ru="Менеджер",
+ description_ru="Координация работы субагентов, планирование, контроль исполнения и распределение задач.",
+ is_implemented=True,
+ is_orchestrator=True,
+ capabilities=["orchestrator", "reasoning", "management", "planning"],
+ fallback_capabilities=["orchestrator", "reasoning"],
+ default_preferred_chain=["codex-orch", "ag-orch-fallback", "claude-orch", "grok-orch", "opengo-3"],
+ default_model="gemini-3.7-flash",
+ max_failover_attempts=3,
+ tier="core",
+ ),
+ "developer-1": RoleDefinition(
+ role_id="developer-1",
+ display_name_ru="Ведущий разработчик (Кодер 1)",
+ short_name_ru="Кодер 1",
+ description_ru="Пишет код, реализует функционал, исправляет ошибки.",
+ is_implemented=True,
+ capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"],
+ fallback_capabilities=["coding"],
+ default_preferred_chain=["codex-worker-1", "claude-worker-1", "ag-w1", "opengo-1"],
+ max_failover_attempts=3,
+ tier="core",
+ ),
+ "developer-2": RoleDefinition(
+ role_id="developer-2",
+ display_name_ru="Вспомогательный разработчик (Кодер 2)",
+ short_name_ru="Кодер 2",
+ description_ru="Проверяет код Разработчика 1 и выдаёт ему задание на исправление.",
+ is_implemented=True,
+ capabilities=["coding", "developer-2", "reviewer", "tools"],
+ fallback_capabilities=["coding", "reviewer"],
+ default_preferred_chain=["ag-w1", "grok-worker-1", "codex-worker-2", "opengo-3"],
+ max_failover_attempts=3,
+ tier="core",
+ ),
+ "code-reviewer": RoleDefinition(
+ role_id="code-reviewer",
+ display_name_ru="Ревьюер кода",
+ short_name_ru="Код-ревьювер",
+ description_ru="Анализирует код на ошибки, проблемы безопасности и соответствие стандартам. Работает после Разработчика 2.",
+ is_implemented=True,
+ capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"],
+ fallback_capabilities=["reviewer", "coding"],
+ default_preferred_chain=["claude-worker-2", "codex-worker-2", "ag-w2", "opengo-2"],
+ max_failover_attempts=3,
+ tier="core",
+ ),
+ "researcher": RoleDefinition(
+ role_id="researcher",
+ display_name_ru="Исследователь",
+ short_name_ru="Исследователь",
+ description_ru="Изучает данные, кодовую базу, документацию и внешние источники, чтобы собрать информацию для решения задачи.",
+ is_implemented=True,
+ capabilities=["researcher", "research", "search", "long_context"],
+ fallback_capabilities=["research", "search"],
+ default_preferred_chain=["ag-w3", "grok-worker-2", "opengo-2", "opengo-1"],
+ max_failover_attempts=3,
+ tier="core",
+ ),
+ "tester": RoleDefinition(
+ role_id="tester",
+ display_name_ru="Тестировщик (QA)",
+ short_name_ru="Тестировщик",
+ description_ru="Создаёт тесты, проверяет корректность работы кода, находит дефекты.",
+ is_implemented=True,
+ capabilities=["tester", "testing", "fast", "automation"],
+ fallback_capabilities=["fast", "testing"],
+ default_preferred_chain=["ag-w4", "opengo-1", "ag-spare-1"],
+ max_failover_attempts=3,
+ tier="qa_doc",
+ ),
+ "tech-writer": RoleDefinition(
+ role_id="tech-writer",
+ display_name_ru="Технический писатель",
+ short_name_ru="Техписатель",
+ description_ru="Создаёт документацию, инструкции, README.",
+ is_implemented=True,
+ capabilities=["tech-writer", "documentation", "reasoning", "structured_output"],
+ fallback_capabilities=["documentation", "reasoning"],
+ default_preferred_chain=["claude-worker-2", "ag-w3", "ag-w2"],
+ max_failover_attempts=3,
+ tier="qa_doc",
+ ),
+ "analyst": RoleDefinition(
+ role_id="analyst",
+ display_name_ru="Системный аналитик",
+ short_name_ru="Аналитик",
+ description_ru="Проводит глубокий анализ данных, выявляет тренды, строит прогнозы.",
+ is_implemented=True,
+ capabilities=["analyst", "reasoning", "research", "planning"],
+ fallback_capabilities=["reasoning", "research"],
+ default_preferred_chain=["grok-worker-2", "opengo-2", "ag-w3"],
+ max_failover_attempts=3,
+ tier="qa_doc",
+ ),
+ "guardian": RoleDefinition(
+ role_id="guardian",
+ display_name_ru="Надзиратель (агент безопасности)",
+ short_name_ru="Надзиратель",
+ description_ru="Проверяет входящие инструкции на промпт-инъекции, анализирует планы и вызовы инструментов, блокирует обход системных правил, не допускает утечки секретов, следит за границами песочницы.",
+ is_implemented=False,
+ unimplemented_reason_ru=STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU,
+ capabilities=["guardian", "security", "sandboxing"],
+ fallback_capabilities=["guardian", "security"],
+ default_preferred_chain=[],
+ max_failover_attempts=0,
+ tier="governance",
+ ),
+ "cost-controller": RoleDefinition(
+ role_id="cost-controller",
+ display_name_ru="Агент контроля затрат",
+ short_name_ru="Контроль затрат",
+ description_ru="Оценивает планируемый расход токенов, сравнивает с остатком бюджета, предлагает упрощения, сверяет факт с прогнозом, останавливает цепочку при исчерпании лимита.",
+ is_implemented=False,
+ unimplemented_reason_ru=STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU,
+ capabilities=["cost-controller", "budget", "analytics"],
+ fallback_capabilities=["cost-controller", "budget"],
+ default_preferred_chain=[],
+ max_failover_attempts=0,
+ tier="governance",
+ ),
+ "integration-expert": RoleDefinition(
+ role_id="integration-expert",
+ display_name_ru="Специалист по интеграции",
+ short_name_ru="Интегратор",
+ description_ru="Работает с API и внешними сервисами, отправляет вебхуки.",
+ is_implemented=True,
+ capabilities=["integration-expert", "integration", "coding", "networking"],
+ fallback_capabilities=["integration", "coding"],
+ default_preferred_chain=["opengo-3", "codex-worker-1", "ag-w4"],
+ max_failover_attempts=3,
+ tier="expert",
+ ),
+ "security-expert": RoleDefinition(
+ role_id="security-expert",
+ display_name_ru="Юрист / специалист по безопасности",
+ short_name_ru="Безопасник",
+ description_ru="Проверяет код и данные на уязвимости.",
+ is_implemented=True,
+ capabilities=["security-expert", "security", "code-reviewer", "audit"],
+ fallback_capabilities=["security", "code-reviewer"],
+ default_preferred_chain=["claude-worker-1", "codex-worker-2", "ag-w2"],
+ max_failover_attempts=3,
+ tier="expert",
+ ),
+ "dependency-agent": RoleDefinition(
+ role_id="dependency-agent",
+ display_name_ru="Проверяющий готовность",
+ short_name_ru="Готовность",
+ description_ru="До начала задачи убеждается, что на месте всё необходимое — исполняемые файлы и CLI, библиотеки, учётные данные, права доступа, доступность локальных серверов. Сообщает о нехватке до запуска.",
+ is_implemented=True,
+ capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"],
+ fallback_capabilities=["dependency-agent", "preflight"],
+ default_preferred_chain=["opengo-1", "ag-w1", "codex-worker-1"],
+ max_failover_attempts=3,
+ tier="qa_doc",
+ ),
+}
+
+_CANONICAL_ROLE_ALIASES: Dict[str, str] = {
+ "orchestrator": "manager",
+ "главный оркестратор": "manager",
+ "оркестратор": "manager",
+ "менеджер": "manager",
+ "coder": "developer-1",
+ "coder-primary": "developer-1",
+ "developer": "developer-1",
+ "кодер": "developer-1",
+ "кодер 1": "developer-1",
+ "разработчик": "developer-1",
+ "разработчик 1": "developer-1",
+ "coder-secondary": "developer-2",
+ "кодер 2": "developer-2",
+ "разработчик 2": "developer-2",
+ "reviewer": "code-reviewer",
+ "ревьюер": "code-reviewer",
+ "код-ревьювер": "code-reviewer",
+ "код-ревьюер": "code-reviewer",
+ "research": "researcher",
+ "исследователь": "researcher",
+ "fast": "tester",
+ "general": "tester",
+ "тестировщик": "tester",
+ "быстрый агент": "tester",
+ "tech_writer": "tech-writer",
+ "технический писатель": "tech-writer",
+ "аналитик": "analyst",
+ "надзиратель": "guardian",
+ "контроль затрат": "cost-controller",
+ "агент контроля затрат": "cost-controller",
+ "интеграция": "integration-expert",
+ "специалист по интеграции": "integration-expert",
+ "безопасность": "security-expert",
+ "специалист по безопасности": "security-expert",
+ "dependency-agent": "dependency-agent",
+ "dependency_agent": "dependency-agent",
+ "preflight": "dependency-agent",
+ "проверяющий готовность": "dependency-agent",
+ "агент зависимостей": "dependency-agent",
+ "готовность": "dependency-agent",
+ "dependency": "dependency-agent",
+}
+
+class RoleRegistry:
+ @classmethod
+ def get_all_roles(cls) -> Dict[str, RoleDefinition]:
+ return dict(CANONICAL_ROLES)
+
+ @classmethod
+ def get_role(cls, role_id: str) -> Optional[RoleDefinition]:
+ canonical_id = cls.resolve_canonical_role(role_id)
+ return CANONICAL_ROLES.get(canonical_id)
+
+ @classmethod
+ def get_role_ids(cls) -> List[str]:
+ return list(CANONICAL_ROLES.keys())
+
+ @classmethod
+ def get_executable_role_ids(cls) -> List[str]:
+ return [r_id for r_id, r_def in CANONICAL_ROLES.items() if r_def.is_implemented]
+
+ @classmethod
+ def get_unimplemented_role_ids(cls) -> List[str]:
+ return [r_id for r_id, r_def in CANONICAL_ROLES.items() if not r_def.is_implemented]
+
+ @classmethod
+ def is_role_implemented(cls, role_id: str) -> bool:
+ r_def = cls.get_role(role_id)
+ return r_def.is_implemented if r_def else False
+
+ @classmethod
+ def get_role_name_ru(cls, role_id: str, default: Optional[str] = None) -> str:
+ r_def = cls.get_role(role_id)
+ if r_def:
+ return r_def.display_name_ru
+ return default or role_id
+
+ @classmethod
+ def get_role_short_name_ru(cls, role_id: str, default: Optional[str] = None) -> str:
+ r_def = cls.get_role(role_id)
+ if r_def:
+ return r_def.short_name_ru
+ return default or role_id
+
+ @classmethod
+ def get_role_description_ru(cls, role_id: str) -> str:
+ r_def = cls.get_role(role_id)
+ if r_def:
+ return r_def.description_ru
+ return ""
+
+ @classmethod
+ def resolve_canonical_role(cls, name_or_alias: str) -> str:
+ if not name_or_alias:
+ return "manager"
+ clean = name_or_alias.strip().lower()
+ return _CANONICAL_ROLE_ALIASES.get(clean, clean)
+
+ @classmethod
+ def resolve_role_name(cls, name_or_alias: str) -> str:
+ return cls.resolve_canonical_role(name_or_alias)
+
+ @classmethod
+ def get_canonical_role_map(cls) -> Dict[str, str]:
+ return dict(_CANONICAL_ROLE_ALIASES)
+
+ @classmethod
+ def get_human_role_labels(cls) -> Dict[str, str]:
+ labels = {}
+ for r_id, r_def in CANONICAL_ROLES.items():
+ labels[r_id] = r_def.short_name_ru
+ labels.update({
+ "spare_1": "Резерв 1",
+ "spare_2": "Резерв 2",
+ "cold_spare": "Холодный резерв",
+ })
+ return labels
+
+ @classmethod
+ def get_default_role_policies(cls) -> Dict[str, "RolePolicy"]:
+ from antigravity_provider.router.router_config import RolePolicy
+ policies: Dict[str, "RolePolicy"] = {}
+ for r_id, r_def in CANONICAL_ROLES.items():
+ policies[r_id] = RolePolicy(
+ role_name=r_id,
+ preferred_chain=list(r_def.default_preferred_chain),
+ fallback_capabilities=list(r_def.fallback_capabilities),
+ max_failover_attempts=r_def.max_failover_attempts,
+ session_affinity_enabled=r_def.session_affinity_enabled,
+ default_model=r_def.default_model,
+ )
+ return policies
+
+ @classmethod
+ def migrate_legacy_roles(cls, current_roles: Dict[str, "RolePolicy"]) -> Tuple[Dict[str, "RolePolicy"], bool]:
+ from antigravity_provider.router.router_config import RolePolicy
+ migrated: Dict[str, "RolePolicy"] = {}
+ was_modified = False
+ default_policies = cls.get_default_role_policies()
+
+ for rname, rpol in current_roles.items():
+ canonical_id = cls.resolve_canonical_role(rname)
+ if canonical_id != rname:
+ was_modified = True
+ if canonical_id not in migrated:
+ migrated[canonical_id] = RolePolicy(
+ role_name=canonical_id,
+ preferred_chain=list(rpol.preferred_chain),
+ fallback_capabilities=list(rpol.fallback_capabilities) or list(default_policies[canonical_id].fallback_capabilities),
+ max_failover_attempts=rpol.max_failover_attempts,
+ session_affinity_enabled=rpol.session_affinity_enabled,
+ default_model=rpol.default_model or default_policies[canonical_id].default_model,
+ )
+ else:
+ migrated[rname] = rpol
+
+ for canon_id, def_policy in default_policies.items():
+ if canon_id not in migrated:
+ migrated[canon_id] = def_policy
+ was_modified = True
+
+ return migrated, was_modified
diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py
index 386367d..465bfe0 100644
--- a/src/antigravity_provider/router/router_config.py
+++ b/src/antigravity_provider/router/router_config.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import os
from dataclasses import dataclass, field
+from antigravity_provider.router.role_registry import RoleRegistry
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
@@ -258,7 +259,7 @@ def get_default_router_config() -> RouterConfig:
profile_id="local-1",
provider="local",
account_id="local-acc-1",
- capabilities=["reviewer", "coder-secondary", "reasoning", "coding"],
+ capabilities=["code-reviewer", "reviewer", "coder-secondary", "reasoning", "coding"],
preferred_models=["Qwen3.8-27B-Q4_K_M.gguf", "default"],
max_concurrency=1,
),
@@ -266,57 +267,13 @@ def get_default_router_config() -> RouterConfig:
profile_id="local-2",
provider="local",
account_id="local-acc-2",
- capabilities=["fast", "research", "coding"],
+ capabilities=["tester", "fast", "research", "coding"],
preferred_models=["Qwen3-4B-Instruct-2507-Q4_K_M.gguf", "default"],
max_concurrency=1,
),
}
- roles: dict[str, RolePolicy] = {
- "orchestrator": RolePolicy(
- role_name="orchestrator",
- preferred_chain=["codex-orch", "ag-orch-fallback", "opengo-3"],
- fallback_capabilities=["orchestrator", "reasoning"],
- max_failover_attempts=3,
- session_affinity_enabled=True,
- default_model="gemini-3.7-flash",
- ),
- "coder-primary": RolePolicy(
- role_name="coder-primary",
- preferred_chain=["codex-worker-1", "ag-w1", "opengo-3"],
- fallback_capabilities=["coding"],
- max_failover_attempts=3,
- session_affinity_enabled=True,
- ),
- "coder-secondary": RolePolicy(
- role_name="coder-secondary",
- preferred_chain=["codex-worker-2", "ag-w2", "opengo-2"],
- fallback_capabilities=["coding", "reviewer"],
- max_failover_attempts=3,
- session_affinity_enabled=True,
- ),
- "reviewer": RolePolicy(
- role_name="reviewer",
- preferred_chain=["codex-worker-2", "opengo-2", "ag-w2"],
- fallback_capabilities=["reviewer", "coding"],
- max_failover_attempts=3,
- session_affinity_enabled=True,
- ),
- "research": RolePolicy(
- role_name="research",
- preferred_chain=["opengo-1", "ag-w3", "ag-w4"],
- fallback_capabilities=["research", "search"],
- max_failover_attempts=3,
- session_affinity_enabled=True,
- ),
- "fast": RolePolicy(
- role_name="fast",
- preferred_chain=["opengo-1", "ag-w4", "ag-spare-1"],
- fallback_capabilities=["fast"],
- max_failover_attempts=3,
- session_affinity_enabled=True,
- ),
- }
+ roles = RoleRegistry.get_default_role_policies()
return RouterConfig(
enabled=True,
@@ -346,16 +303,20 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
profiles_raw = data.get("profiles", {})
profiles: dict[str, RouterProfileConfig] = {}
for pid, pdata in profiles_raw.items():
+ provider = pdata.get("provider", "antigravity")
+ max_concurrency = int(pdata.get("max_concurrency", 1))
+ if provider == "local":
+ max_concurrency = 1
profiles[pid] = RouterProfileConfig(
profile_id=pid,
- provider=pdata.get("provider", "antigravity"),
+ provider=provider,
account_id=pdata.get("account_id", pid),
capabilities=list(pdata.get("capabilities", [])),
preferred_models=list(pdata.get("preferred_models", [])),
fallback_models=list(pdata.get("fallback_models", [])),
auth_config=dict(pdata.get("auth_config", {})),
enabled=bool(pdata.get("enabled", True)),
- max_concurrency=int(pdata.get("max_concurrency", 1)),
+ max_concurrency=max_concurrency,
custom_base_url=pdata.get("custom_base_url"),
)
diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py
index 0e7182e..1fe66e3 100644
--- a/src/antigravity_provider/router/settings_service.py
+++ b/src/antigravity_provider/router/settings_service.py
@@ -22,9 +22,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"monitoring_interval_seconds": 30,
"quota_threshold_percent": 10.0,
"quota_threshold_action": "notify",
+ "email_masking_mode": "none",
}
+
_SETTINGS_CACHE: Dict[str, Any] | None = None
_SETTINGS_CACHE_MTIME: float = -1.0
_SETTINGS_CACHE_PATH: str = ""
@@ -98,6 +100,11 @@ def get_hub_settings() -> Dict[str, Any]:
action = "notify"
merged["quota_threshold_action"] = action
+ email_mode = str(merged.get("email_masking_mode", "none")).strip().lower()
+ if email_mode not in ("none", "partial", "full"):
+ email_mode = "none"
+ merged["email_masking_mode"] = email_mode
+
_SETTINGS_CACHE = dict(merged)
_SETTINGS_CACHE_MTIME = current_mtime
_SETTINGS_CACHE_PATH = sfile_str
diff --git a/src/antigravity_provider/router/state_store.py b/src/antigravity_provider/router/state_store.py
index 2c3550f..2cb1ac4 100644
--- a/src/antigravity_provider/router/state_store.py
+++ b/src/antigravity_provider/router/state_store.py
@@ -55,6 +55,7 @@ class HubSnapshot:
quotas: Dict[str, Any]
metrics: Dict[str, Any] = field(default_factory=dict)
is_stale: bool = False
+ workflow: Dict[str, Any] = field(default_factory=dict)
def get_profile(self, profile_id: str) -> Optional[ProfileViewModel]:
return self.all_profiles.get(profile_id)
@@ -107,6 +108,24 @@ class HubStateStore:
if self._current_snapshot is not None:
if (time.time() - self._current_snapshot.timestamp > 300.0) and not self._current_snapshot.is_stale:
self._current_snapshot = replace(self._current_snapshot, is_stale=True)
+ # Provider/account scans are intentionally cached, while LIVE
+ # workflow checkpoints are small local state and must never lag
+ # behind an action until the next expensive provider refresh.
+ try:
+ from .workflow_service import WorkflowService
+
+ live_workflow = WorkflowService.get().snapshot()
+ role_views = {agent.role_id: agent for agent in self._current_snapshot.agents}
+ for workflow_agent in live_workflow.get("agents", []):
+ role_view = role_views.get(workflow_agent.get("role"))
+ generic_name = str(workflow_agent.get("role") or "").replace("-", " ").title()
+ if role_view and workflow_agent.get("name") == generic_name:
+ workflow_agent["name"] = role_view.role_name_ru
+ if role_view and not workflow_agent.get("description"):
+ workflow_agent["description"] = role_view.role_description_ru
+ self._current_snapshot = replace(self._current_snapshot, workflow=live_workflow)
+ except Exception:
+ pass
return self._current_snapshot
return self.refresh(force_scan=False)
@@ -196,6 +215,27 @@ class HubStateStore:
"active_calls_total": active_leases_total,
"active_calls_by_profile": active_leases_by_profile,
}
+ try:
+ from .workflow_service import WorkflowService
+
+ workflow_data = WorkflowService.get().snapshot()
+ role_views = {agent.role_id: agent for agent in agents}
+ for workflow_agent in workflow_data.get("agents", []):
+ role_view = role_views.get(workflow_agent.get("role"))
+ generic_name = str(workflow_agent.get("role") or "").replace("-", " ").title()
+ if role_view and workflow_agent.get("name") == generic_name:
+ workflow_agent["name"] = role_view.role_name_ru
+ if role_view and not workflow_agent.get("description"):
+ workflow_agent["description"] = role_view.role_description_ru
+ except Exception as exc:
+ workflow_data = {
+ "agents": [],
+ "definition": {},
+ "run": {"status": "unavailable"},
+ "events": [],
+ "is_loading": False,
+ "unavailable_reason": f"Workflow state unavailable: {exc}",
+ }
snapshot = HubSnapshot(
generation=gen,
seq=request_seq,
@@ -209,6 +249,7 @@ class HubStateStore:
quotas=quotas_map,
metrics=metrics,
is_stale=False,
+ workflow=workflow_data,
)
self._current_snapshot = snapshot
@@ -263,6 +304,13 @@ class HubStateStore:
"active_calls_by_profile": {},
},
is_stale=True,
+ workflow={
+ "agents": [],
+ "definition": {},
+ "run": {"status": "loading"},
+ "events": [],
+ "is_loading": True,
+ },
)
def _apply_profile_delta(self, profile: ProfileViewModel) -> HubSnapshot:
diff --git a/src/antigravity_provider/router/telemetry_service.py b/src/antigravity_provider/router/telemetry_service.py
index a797c77..5562078 100644
--- a/src/antigravity_provider/router/telemetry_service.py
+++ b/src/antigravity_provider/router/telemetry_service.py
@@ -8,6 +8,7 @@ Captures, persists, and computes honest empirical measurements for all router ca
- Ring Buffer & File Rotation (bounded memory and disk footprint)
"""
from __future__ import annotations
+from antigravity_provider.router.role_registry import RoleRegistry
import collections
import datetime
@@ -30,6 +31,15 @@ MAX_FILE_BYTES = 5 * 1024 * 1024 # 5 MB
MAX_BACKUP_FILES = 3
+def format_token_count(measured: Optional[int], estimated: Optional[int]) -> Optional[str]:
+ """Format token count distinguishing measured exact counts from estimations (~)."""
+ if measured is not None:
+ return str(measured)
+ elif estimated is not None:
+ return f"~{estimated}"
+ return None
+
+
@dataclass
class TelemetryRecord:
"""Immutable record of an individual router invocation attempt."""
@@ -44,6 +54,13 @@ class TelemetryRecord:
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
total_tokens: Optional[int] = None
+ prompt_tokens_measured: Optional[int] = None
+ prompt_tokens_estimated: Optional[int] = None
+ completion_tokens_measured: Optional[int] = None
+ completion_tokens_estimated: Optional[int] = None
+ total_tokens_measured: Optional[int] = None
+ total_tokens_estimated: Optional[int] = None
+ is_estimated: bool = False
cost_usd: Optional[float] = None
failover_count: int = 0
error_category: Optional[str] = None
@@ -68,6 +85,14 @@ class TelemetryAggregates:
total_prompt_tokens: Optional[int] = None # Sum of reported prompt tokens or None if no token data
total_completion_tokens: Optional[int] = None
total_tokens: Optional[int] = None
+ total_prompt_tokens_measured: Optional[int] = None
+ total_prompt_tokens_estimated: Optional[int] = None
+ total_completion_tokens_measured: Optional[int] = None
+ total_completion_tokens_estimated: Optional[int] = None
+ total_tokens_measured: Optional[int] = None
+ total_tokens_estimated: Optional[int] = None
+ tokens_display: Optional[str] = None
+ has_estimated_tokens: bool = False
total_cost_usd: Optional[float] = None # Sum of calculated costs or None if no pricing available
failovers_count: int = 0
failover_reasons: Dict[str, int] = field(default_factory=dict)
@@ -78,6 +103,7 @@ class TelemetryAggregates:
return asdict(self)
+
class TelemetryService:
"""Thread-safe persistent telemetry manager with bounded storage and honest aggregation."""
@@ -175,16 +201,42 @@ class TelemetryService:
total_tokens: Optional[int] = None,
failover_count: int = 0,
error_category: Optional[str] = None,
+ prompt_tokens_measured: Optional[int] = None,
+ prompt_tokens_estimated: Optional[int] = None,
+ completion_tokens_measured: Optional[int] = None,
+ completion_tokens_estimated: Optional[int] = None,
+ total_tokens_measured: Optional[int] = None,
+ total_tokens_estimated: Optional[int] = None,
+ is_estimated: bool = False,
) -> TelemetryRecord:
"""Record an invocation attempt into memory and rotated log."""
now = time.time()
iso = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
- # Derive total tokens if prompt/completion available
- if total_tokens is None and prompt_tokens is not None and completion_tokens is not None:
- total_tokens = prompt_tokens + completion_tokens
+ if is_estimated:
+ p_est = prompt_tokens_estimated if prompt_tokens_estimated is not None else prompt_tokens
+ c_est = completion_tokens_estimated if completion_tokens_estimated is not None else completion_tokens
+ t_est = total_tokens_estimated if total_tokens_estimated is not None else total_tokens
+ if t_est is None and p_est is not None and c_est is not None:
+ t_est = p_est + c_est
+ p_meas, c_meas, t_meas = None, None, None
+ p_tok, c_tok, t_tok = p_est, c_est, t_est
+ else:
+ p_meas = prompt_tokens_measured if prompt_tokens_measured is not None else prompt_tokens
+ c_meas = completion_tokens_measured if completion_tokens_measured is not None else completion_tokens
+ t_meas = total_tokens_measured if total_tokens_measured is not None else total_tokens
+ if t_meas is None and p_meas is not None and c_meas is not None:
+ t_meas = p_meas + c_meas
+ p_est = prompt_tokens_estimated
+ c_est = completion_tokens_estimated
+ t_est = total_tokens_estimated
+ if t_est is None and p_est is not None and c_est is not None:
+ t_est = p_est + c_est
+ p_tok = p_meas if p_meas is not None else p_est
+ c_tok = c_meas if c_meas is not None else c_est
+ t_tok = t_meas if t_meas is not None else t_est
- cost_usd = self.compute_cost(model, prompt_tokens, completion_tokens)
+ cost_usd = self.compute_cost(model, p_tok, c_tok)
record = TelemetryRecord(
timestamp=now,
@@ -195,9 +247,16 @@ class TelemetryService:
model=model,
outcome=outcome,
latency_seconds=round(max(0.0, float(latency_seconds)), 4),
- prompt_tokens=prompt_tokens,
- completion_tokens=completion_tokens,
- total_tokens=total_tokens,
+ prompt_tokens=p_tok,
+ completion_tokens=c_tok,
+ total_tokens=t_tok,
+ prompt_tokens_measured=p_meas,
+ prompt_tokens_estimated=p_est,
+ completion_tokens_measured=c_meas,
+ completion_tokens_estimated=c_est,
+ total_tokens_measured=t_meas,
+ total_tokens_estimated=t_est,
+ is_estimated=is_estimated or (p_est is not None and p_meas is None),
cost_usd=cost_usd,
failover_count=failover_count,
error_category=error_category,
@@ -267,6 +326,13 @@ class TelemetryService:
prompt_tokens=d.get("prompt_tokens"),
completion_tokens=d.get("completion_tokens"),
total_tokens=d.get("total_tokens"),
+ prompt_tokens_measured=d.get("prompt_tokens_measured"),
+ prompt_tokens_estimated=d.get("prompt_tokens_estimated"),
+ completion_tokens_measured=d.get("completion_tokens_measured"),
+ completion_tokens_estimated=d.get("completion_tokens_estimated"),
+ total_tokens_measured=d.get("total_tokens_measured"),
+ total_tokens_estimated=d.get("total_tokens_estimated"),
+ is_estimated=bool(d.get("is_estimated", False)),
cost_usd=d.get("cost_usd"),
failover_count=int(d.get("failover_count", 0)),
error_category=d.get("error_category"),
@@ -317,6 +383,14 @@ class TelemetryService:
total_prompt_tokens=None,
total_completion_tokens=None,
total_tokens=None,
+ total_prompt_tokens_measured=None,
+ total_prompt_tokens_estimated=None,
+ total_completion_tokens_measured=None,
+ total_completion_tokens_estimated=None,
+ total_tokens_measured=None,
+ total_tokens_estimated=None,
+ tokens_display=None,
+ has_estimated_tokens=False,
total_cost_usd=None,
failovers_count=0,
failover_reasons={},
@@ -332,6 +406,15 @@ class TelemetryService:
prompt_tokens_sum = 0
completion_tokens_sum = 0
has_any_token_data = False
+ prompt_meas_sum = 0
+ prompt_est_sum = 0
+ has_meas_prompt = False
+ has_est_prompt = False
+ comp_meas_sum = 0
+ comp_est_sum = 0
+ has_meas_comp = False
+ has_est_comp = False
+ has_estimated_tokens = False
costs_sum = 0.0
has_any_cost_data = False
failovers_count = 0
@@ -357,6 +440,41 @@ class TelemetryService:
completion_tokens_sum += r.completion_tokens
has_any_token_data = True
+ if r.prompt_tokens_measured is not None:
+ prompt_meas_sum += r.prompt_tokens_measured
+ has_meas_prompt = True
+ elif r.prompt_tokens is not None and not r.is_estimated:
+ prompt_meas_sum += r.prompt_tokens
+ has_meas_prompt = True
+
+ if r.prompt_tokens_estimated is not None:
+ prompt_est_sum += r.prompt_tokens_estimated
+ has_est_prompt = True
+ has_estimated_tokens = True
+ elif r.prompt_tokens is not None and r.is_estimated:
+ prompt_est_sum += r.prompt_tokens
+ has_est_prompt = True
+ has_estimated_tokens = True
+
+ if r.completion_tokens_measured is not None:
+ comp_meas_sum += r.completion_tokens_measured
+ has_meas_comp = True
+ elif r.completion_tokens is not None and not r.is_estimated:
+ comp_meas_sum += r.completion_tokens
+ has_meas_comp = True
+
+ if r.completion_tokens_estimated is not None:
+ comp_est_sum += r.completion_tokens_estimated
+ has_est_comp = True
+ has_estimated_tokens = True
+ elif r.completion_tokens is not None and r.is_estimated:
+ comp_est_sum += r.completion_tokens
+ has_est_comp = True
+ has_estimated_tokens = True
+
+ if r.is_estimated:
+ has_estimated_tokens = True
+
if r.cost_usd is not None:
costs_sum += r.cost_usd
has_any_cost_data = True
@@ -368,6 +486,9 @@ class TelemetryService:
error_rate = round(failed_calls / total_calls, 4) if total_calls > 0 else 0.0
total_tokens_sum = (prompt_tokens_sum + completion_tokens_sum) if has_any_token_data else None
+ tot_meas_sum = (prompt_meas_sum + comp_meas_sum) if (has_meas_prompt or has_meas_comp) else None
+ tot_est_sum = (prompt_est_sum + comp_est_sum) if (has_est_prompt or has_est_comp) else None
+ tokens_display = format_token_count(tot_meas_sum, tot_est_sum)
return TelemetryAggregates(
window_seconds=window_seconds,
@@ -382,6 +503,14 @@ class TelemetryService:
total_prompt_tokens=prompt_tokens_sum if has_any_token_data else None,
total_completion_tokens=completion_tokens_sum if has_any_token_data else None,
total_tokens=total_tokens_sum,
+ total_prompt_tokens_measured=prompt_meas_sum if has_meas_prompt else None,
+ total_prompt_tokens_estimated=prompt_est_sum if has_est_prompt else None,
+ total_completion_tokens_measured=comp_meas_sum if has_meas_comp else None,
+ total_completion_tokens_estimated=comp_est_sum if has_est_comp else None,
+ total_tokens_measured=tot_meas_sum,
+ total_tokens_estimated=tot_est_sum,
+ tokens_display=tokens_display,
+ has_estimated_tokens=has_estimated_tokens,
total_cost_usd=round(costs_sum, 4) if has_any_cost_data else None,
failovers_count=failovers_count,
failover_reasons=dict(failover_reasons),
@@ -389,6 +518,7 @@ class TelemetryService:
has_data=True,
)
+
def get_breakdown(
self,
window_seconds: Optional[int] = 86400,
@@ -399,7 +529,7 @@ class TelemetryService:
global_aggs = self.get_aggregates(window_seconds=window_seconds)
providers = set(known_providers or ["antigravity", "openai-codex", "opencode-go"])
- roles = set(known_roles or ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"])
+ roles = set(known_roles or RoleRegistry.get_role_ids())
with self._lock:
for r in self._buffer:
diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py
index b636f68..e1e5bdf 100644
--- a/src/antigravity_provider/router/ui/add_account_wizard.py
+++ b/src/antigravity_provider/router/ui/add_account_wizard.py
@@ -22,25 +22,12 @@ import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal
-from antigravity_provider.router.auto_assigner import AutoAssigner
+from antigravity_provider.router.auto_assigner import AutoAssigner, ensure_profile_in_routing
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.unified_health import EventLogService
-def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
- """Keep existing chain rank or route a newly introduced profile slot."""
- config = load_router_config()
- assigned_role = next(
- (role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
- "",
- )
- if assigned_role:
- return True, f"Профиль уже входит в цепочку '{assigned_role}'"
- _display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
- return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")
-
-
class AddAccountWizard(HubModal):
"""4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment."""
diff --git a/src/antigravity_provider/router/ui/routing_graph.py b/src/antigravity_provider/router/ui/routing_graph.py
index 6939411..6e7fa0b 100644
--- a/src/antigravity_provider/router/ui/routing_graph.py
+++ b/src/antigravity_provider/router/ui/routing_graph.py
@@ -127,7 +127,8 @@ def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -
node_set = set(node_ids)
for role_id in sorted({item for item in node_ids if node_ids.count(item) > 1}):
issues.append(GraphIssue("duplicate-node", f"Роль {role_id} добавлена дважды", role_id))
- if "orchestrator" not in node_set:
+ orch_node = next((n for n in ("manager", "orchestrator") if n in node_set), None)
+ if not orch_node:
issues.append(GraphIssue("missing-orchestrator", "Отсутствует узел оркестратора"))
for node in graph.nodes:
policy = config.roles.get(node.role_id)
@@ -172,8 +173,8 @@ def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -
visit(target)
active.remove(role_id)
- if "orchestrator" in node_set:
- visit("orchestrator")
+ if orch_node:
+ visit(orch_node)
for role_id in sorted(node_set - visited):
issues.append(GraphIssue("unreachable", f"Роль {role_id} недостижима от оркестратора", role_id))
return issues
diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py
index 2cc157b..f6ee1dc 100644
--- a/src/antigravity_provider/router/unified_health.py
+++ b/src/antigravity_provider/router/unified_health.py
@@ -9,6 +9,7 @@ Single Source of Truth for:
- EventLogService
"""
from __future__ import annotations
+from antigravity_provider.router.role_registry import RoleRegistry
import json
import logging
@@ -644,21 +645,38 @@ class UnifiedHealthService:
config = load_router_config()
self.scan_all(force=False)
- ROLE_META = {
- "orchestrator": ("Главный оркестратор", "Управление командой, планирование, контроль исполнения"),
- "coder-primary": ("Кодер 1", "Основная разработка кода и исправление дефектов"),
- "coder-secondary": ("Кодер 2", "Параллельная разработка и вспомогательные модули"),
- "reviewer": ("Ревьюер", "Независимое fail-closed ревью и валидация diff"),
- "research": ("Исследователь", "Read-only поиск в кодовой базе и сбор фактов"),
- "fast": ("Быстрый агент", "Оперативные вызовы, вспомогательные проверки"),
- "universal": ("Универсальный агент", "Широкий спектр общих задач"),
- }
-
agents: List[AgentViewModel] = []
for rname, rpol in config.roles.items():
- rname_ru, rdesc_ru = ROLE_META.get(rname, (rname, ""))
+ rname_ru = RoleRegistry.get_role_name_ru(rname, rname)
+ rdesc_ru = RoleRegistry.get_role_description_ru(rname)
+ is_implemented = RoleRegistry.is_role_implemented(rname)
+
chain = rpol.preferred_chain
+
+ if not is_implemented:
+ agents.append(AgentViewModel(
+ role_id=rname,
+ role_name_ru=rname_ru,
+ role_description_ru=rdesc_ru or "Роль объявлена, исполнение не реализовано",
+ assigned_profile_id=None,
+ assigned_display_name=None,
+ provider="N/A",
+ provider_display_name="N/A",
+ model="-",
+ account_identity="Роль объявлена, исполнение не реализовано",
+ routing_position="Отключено",
+ status="unimplemented",
+ status_label_ru="Не реализовано",
+ is_active=False,
+ is_main_orchestrator=False,
+ cooldown_remaining_sec=0,
+ session_id=None,
+ active_quota_status="unavailable",
+ active_quota_label="Не применяется (роль не активна)",
+ ))
+ continue
+
if not chain:
continue
@@ -703,7 +721,7 @@ class UnifiedHealthService:
status=active_pvm.health_state,
status_label_ru=active_pvm.health_label_ru,
is_active=(active_pvm.health_state == STATUS_HEALTHY),
- is_main_orchestrator=(rname == "orchestrator"),
+ is_main_orchestrator=(rname == "manager"),
cooldown_remaining_sec=active_pvm.cooldown_remaining_sec,
session_id=None,
active_quota_status=active_quota_st,
@@ -772,15 +790,7 @@ class UnifiedHealthService:
self.scan_all(force=False)
pipelines: Dict[str, RolePipeline] = {}
- ROLE_NAMES = {
- "orchestrator": "Главный оркестратор",
- "coder-primary": "Кодер 1 (Primary)",
- "coder-secondary": "Кодер 2 (Secondary)",
- "reviewer": "Ревьюер",
- "research": "Исследователь",
- "fast": "Быстрый агент",
- "universal": "Универсальный агент",
- }
+
for rname, rpol in config.roles.items():
nodes: List[PipelineNode] = []
@@ -834,7 +844,7 @@ class UnifiedHealthService:
pipelines[rname] = RolePipeline(
role_id=rname,
- role_name_ru=ROLE_NAMES.get(rname, rname),
+ role_name_ru=RoleRegistry.get_role_name_ru(rname, rname),
default_model=rpol.default_model or "auto",
max_failover=rpol.max_failover_attempts,
session_affinity=rpol.session_affinity_enabled,
diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py
index 5d7e7a4..69569f6 100644
--- a/src/antigravity_provider/router/web/server.py
+++ b/src/antigravity_provider/router/web/server.py
@@ -125,8 +125,17 @@ def health_check():
}
}
-def sanitize_snapshot(snap_dict: Any) -> Any:
+def sanitize_snapshot(snap_dict: Any, email_masking_mode: Optional[str] = None) -> Any:
import re
+ if email_masking_mode is None:
+ try:
+ from antigravity_provider.router.settings_service import get_hub_settings
+ email_masking_mode = get_hub_settings().get("email_masking_mode", "none")
+ except Exception:
+ email_masking_mode = "none"
+
+ mode = str(email_masking_mode or "none").strip().lower()
+
secret_patterns = [
re.compile(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', re.IGNORECASE),
re.compile(r'(sk-[a-zA-Z0-9_\-]{8,})'),
@@ -134,6 +143,23 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
re.compile(r'(Bearer\s+)([a-zA-Z0-9_\-\.]{8,})', re.IGNORECASE),
]
+ email_pattern = re.compile(r'\b([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b')
+
+ def _mask_email_match(match: re.Match) -> str:
+ local_part = match.group(1)
+ domain_part = match.group(2)
+ if mode == "full":
+ return "***@***.***"
+ elif mode == "partial":
+ if len(local_part) > 2:
+ masked = f"{local_part[0]}***{local_part[-1]}"
+ elif local_part:
+ masked = f"{local_part[0]}***"
+ else:
+ masked = "***"
+ return f"{masked}@{domain_part}"
+ return match.group(0)
+
def _mask_str(val: str) -> str:
res = val
for pat in secret_patterns:
@@ -141,6 +167,8 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
res = pat.sub(r'\g<1>***', res)
elif pat.groups == 1:
res = pat.sub(r'***', res)
+ if mode in ("partial", "full"):
+ res = email_pattern.sub(_mask_email_match, res)
return res
def _sanitize(node):
@@ -156,6 +184,7 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
return node
return _sanitize(snap_dict)
+
@app.get("/api/snapshot")
def get_snapshot(authorized: bool = Depends(get_auth_token)):
snapshot = HubStateStore.get().get_snapshot()
diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js
index e97bfd8..1c4958b 100644
--- a/src/antigravity_provider/router/web/static/app.js
+++ b/src/antigravity_provider/router/web/static/app.js
@@ -182,8 +182,15 @@ function initEventListeners() {
if (btnApplyUpdate) {
btnApplyUpdate.addEventListener('click', () => applyUpdate());
}
+
+ // Preflight check listener
+ const btnPreflight = document.getElementById('btn-run-preflight');
+ if (btnPreflight) {
+ btnPreflight.addEventListener('click', () => runPreflightChecks());
+ }
}
+
// ── SNAPSHOT INGESTION & MONOTONIC SEQ ──
async function fetchSnapshot() {
const urlParams = new URLSearchParams(window.location.search);
@@ -519,7 +526,7 @@ function updateGlobalHeader() {
const isHealthy = readiness.state === 'healthy';
const readyRoles = readiness.roles_ready_count || 0;
- const totalRoles = readiness.total_roles || 6;
+ const totalRoles = readiness.total_roles ?? 0;
if (elements.headerReadinessBadge) {
elements.headerReadinessBadge.className = `header-readiness-badge ${isHealthy ? 'text-healthy' : 'text-warning'}`;
@@ -527,7 +534,7 @@ function updateGlobalHeader() {
if (elements.headerReadinessText) {
elements.headerReadinessText.textContent = readiness.title_ru
? `${readiness.title_ru} (${readyRoles}/${totalRoles} ролей)`
- : 'Система готова';
+ : 'Н/Д: состояние ещё не измерено';
}
const kpiReadiness = document.getElementById('kpi-system-readiness');
@@ -805,6 +812,10 @@ function renderQuotaCell(bucket, unavailableReason) {
// 1. OVERVIEW VIEW (P0-3, P0-4 Diagram Model Select & Counters)
// ═══════════════════════════════════════════════════════════════
function renderOverviewView() {
+ if (typeof renderWorkflowOverview === 'function') {
+ renderWorkflowOverview(currentSnapshot);
+ return;
+ }
if (!currentSnapshot) return;
const providers = currentSnapshot.providers || [];
@@ -951,1366 +962,280 @@ function renderOverviewView() {
// ═══════════════════════════════════════════════════════════════
// 2. ROUTING VIEW (P0-1, P0-2 Main Routing Control Center)
// ═══════════════════════════════════════════════════════════════
+
+function getProviderIcon(provider) {
+ const map = {
+ 'openai-codex': 'codex.png',
+ 'google-antigravity': 'антигравити.png',
+ 'opencode-go': 'opencode.png',
+ 'anthropic-claude': 'claude.png',
+ 'deepseek': 'deepseek.png',
+ 'grok': 'grok.jfif'
+ };
+ return map[provider] || 'llama.png';
+}
+
function renderRoutingView() {
- const container = document.getElementById('routing-pipelines-container');
- if (!container || !currentSnapshot) return;
+ const leftCol = document.getElementById('routing-roles-container');
+ const rightCol = document.getElementById('routing-available-container');
+ if (!leftCol || !rightCol || !currentSnapshot) return;
const routing = currentSnapshot.routing || {};
const agents = currentSnapshot.agents || [];
- let html = '';
+ const profiles = currentSnapshot.profiles || {};
+ // Render Left Column (Roles)
+ let rolesHtml = '';
for (const [roleId, pipeline] of Object.entries(routing)) {
- const nodes = pipeline.nodes || [];
+ const chain = pipeline.preferred_chain || [];
const agentInfo = agents.find((a) => a.role_id === roleId);
- const roleDesc = agentInfo?.role_description_ru || (CANONICAL_ROLE_DESCRIPTIONS[roleId] || '');
- const quotaLabel = agentInfo?.active_quota_label || '';
- const quotaStatus = agentInfo?.active_quota_status || 'healthy';
+
+ let isImportant = ['manager', 'developer-1', 'developer-2'].includes(roleId);
+ let badgeHtml = isImportant ? ` Важная роль` : '';
+ let roleDesc = CANONICAL_ROLE_DESCRIPTIONS[roleId] || '';
- html += `
-
-