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 += ` -
-
+ rolesHtml += ` +
+
-
- ${escapeHtml(pipeline.role_name_ru || roleId)} - ${quotaLabel ? `Квота: ${escapeHtml(quotaLabel)}` : ''} - ${pipeline.session_affinity ? 'Session Affinity' : 'Без affinity'} -
- ${roleDesc ? `
${escapeHtml(roleDesc)}
` : ''} +

${escapeHtml(pipeline.role_name_ru || roleId)} ${badgeHtml}

+
${escapeHtml(roleDesc)}
-
+
+ ${chain.length} аккаунта +
- -
- ${nodes.map((node, index) => { - const profile = (currentSnapshot.all_profiles || {})[node.profile_id]; - const provId = profile?.provider || getProviderIdFromName(node.provider); - const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === provId || p.provider_name === node.provider); - const discoveredModels = (provSummary && provSummary.discovered_models && provSummary.discovered_models.length > 0) ? provSummary.discovered_models : []; - const currentModel = node.model || (profile && profile.preferred_models && profile.preferred_models[0]) || ''; - const identity = node.account_identity && node.account_identity !== 'Аккаунт не добавлен' ? node.account_identity : (profile?.email || node.display_name || node.profile_id); - - let modelControlHtml = ''; - if (discoveredModels.length > 0) { - modelControlHtml = ` -
- - -
- `; - } else { - modelControlHtml = ` -
- Список моделей ещё не получен - -
- `; - } - - return ` -
-
- ${index === 0 ? '★ Основной' : `Резерв ${index}`} -
- ${node.is_active ? '● АКТИВЕН' : ''} - -
-
-
${escapeHtml(identity)}
-
- ${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.profile_id)}) • ${escapeHtml(node.provider)} -
- ${modelControlHtml} - ${node.failover_reason ? `
⚠ ${escapeHtml(node.failover_reason)}
` : ''} -
- `; - }).join('') || '
Цепочка не настроена. Нажмите «+ Добавить профиль».
'} +
+
+
Приоритет
+
Аккаунт
+
Модель
+
Провайдер
+
Квоты
+
Сброс
+
Статус
+
-
+
`; - } - container.innerHTML = html || '
Маршруты отсутствуют.
'; -} - -// ═══════════════════════════════════════════════════════════════ -// 3. ANALYTICS VIEW (P0-1, P0-5 Telemetry & Honesty) -// ═══════════════════════════════════════════════════════════════ -function renderAnalyticsView() { - if (!currentSnapshot) return; - const metrics = currentSnapshot.metrics || {}; - const telemetry = metrics.telemetry || {}; - const global = telemetry.global || {}; - - // KPI 1: Total Calls (24h) - const totalCallsEl = document.getElementById('analytics-total-calls'); - const callsBreakdownEl = document.getElementById('analytics-calls-breakdown'); - if (totalCallsEl) { - totalCallsEl.textContent = (global.total_calls !== null && global.total_calls !== undefined) ? global.total_calls : 'Н/Д'; - } - if (callsBreakdownEl) { - const succ = global.successful_calls ?? 0; - const fail = global.failed_calls ?? 0; - callsBreakdownEl.textContent = `Успешно: ${succ} • Сбоев: ${fail} (окно: 24ч)`; - } - - // KPI 2: Error Rate (24h) - const errorRateEl = document.getElementById('analytics-error-rate'); - const errorRateSubEl = document.getElementById('analytics-error-rate-sub'); - if (errorRateEl) { - if (global.error_rate !== null && global.error_rate !== undefined) { - const pct = (global.error_rate * 100).toFixed(1); - errorRateEl.textContent = `${pct}%`; - errorRateEl.className = `kpi-value ${global.error_rate > 0.5 ? 'text-error' : (global.error_rate > 0.2 ? 'text-warning' : 'text-healthy')}`; - } else { - errorRateEl.textContent = 'Н/Д'; - errorRateEl.className = 'kpi-value text-muted'; - } - } - if (errorRateSubEl) { - errorRateSubEl.textContent = (global.failed_calls !== null && global.failed_calls !== undefined && global.failed_calls > 0) - ? `${global.failed_calls} отказов из ${global.total_calls || 0} вызовов (24ч)` - : 'Отказов за 24ч не зафиксировано'; - } - - // KPI 3: Latency (24h) with Fast-fail Explanation - const latencyEl = document.getElementById('analytics-latency-p50'); - const latencySubEl = document.getElementById('analytics-latency-sub'); - if (latencyEl) { - if (global.latency_p50_ms !== null && global.latency_p50_ms !== undefined) { - latencyEl.textContent = `${global.latency_p50_ms.toFixed(1)} ms`; - } else { - latencyEl.textContent = 'Н/Д'; - } - } - if (latencySubEl) { - const p95Str = global.latency_p95_ms != null - ? (global.latency_p95_ms >= 1000 ? `${(global.latency_p95_ms / 1000).toFixed(1)} s` : `${global.latency_p95_ms.toFixed(1)} ms`) - : 'Н/Д'; - const maxStr = global.latency_max_ms != null - ? (global.latency_max_ms >= 1000 ? `${(global.latency_max_ms / 1000).toFixed(1)} s` : `${global.latency_max_ms.toFixed(1)} ms`) - : 'Н/Д'; - - let note = `p95: ${p95Str} • max: ${maxStr} (окно: 24ч)`; - // P0-5: Explain discrepancy if p50 is low while error rate is non-zero (fast-fail) - if (global.failed_calls > 0 && (global.latency_p50_ms == null || global.latency_p50_ms < 50 || (global.latency_max_ms && global.latency_max_ms > 10 * Math.max(1, global.latency_p50_ms || 0)))) { - note += ' • Низкий p50 вызван быстрыми отказами (fast-fail)'; - } - latencySubEl.textContent = note; - } - - // KPI 4: Tokens (Honesty rule: null means N/D, never 0) - const tokensEl = document.getElementById('analytics-tokens-total'); - const tokensSubEl = document.getElementById('analytics-tokens-sub'); - const hasTokens = global.total_tokens !== null && global.total_tokens !== undefined; - if (tokensEl) { - if (hasTokens) { - tokensEl.textContent = global.total_tokens.toLocaleString('ru-RU'); - } else { - tokensEl.textContent = 'Н/Д'; - } - } - if (tokensSubEl) { - if (hasTokens) { - tokensSubEl.textContent = 'Учитывается провайдером (24ч)'; - } else { - tokensSubEl.textContent = 'Н/Д: провайдеры не отдают данные о токенах'; - } - } - - // Providers Table (P0-5 Honesty: Unconnected providers labeled "Не подключён") - const provTableBox = document.getElementById('analytics-providers-table'); - if (provTableBox) { - const byProv = telemetry.by_provider || {}; - const providersList = currentSnapshot.providers || []; - const allKnownProvIds = Array.from(new Set([...providersList.map((p) => p.provider_id), ...Object.keys(byProv)])); - - if (allKnownProvIds.length === 0) { - provTableBox.innerHTML = '
Нет данных телеметрии по провайдерам.
'; - } else { - const rowsHtml = allKnownProvIds.map((pId) => { - const pData = byProv[pId] || {}; - const provSummary = providersList.find((p) => p.provider_id === pId); - const provName = provSummary?.provider_name || pId; - const isConnected = provSummary ? ((provSummary.connected_count || 0) > 0) : ((pData.total_calls || 0) > 0); - - if (!isConnected && (!pData.total_calls || pData.total_calls === 0)) { - return ` - - ${escapeHtml(provName)} (${escapeHtml(pId)}) - Не подключён - — - — - Аккаунт не добавлен - — - — - Н/Д - - `; - } - - const errPct = pData.error_rate != null ? (pData.error_rate * 100).toFixed(1) : '0.0'; - const p50 = pData.latency_p50_ms != null ? `${pData.latency_p50_ms.toFixed(1)} ms` : 'Н/Д'; - const p95 = pData.latency_p95_ms != null - ? (pData.latency_p95_ms >= 1000 ? `${(pData.latency_p95_ms / 1000).toFixed(1)} s` : `${pData.latency_p95_ms.toFixed(1)} ms`) - : 'Н/Д'; - const barColor = (pData.error_rate || 0) > 0.5 ? 'var(--status-error)' : 'var(--status-healthy)'; - const barW = Math.min(100, Math.max(0, (pData.error_rate || 0) * 100)); - - return ` - - ${escapeHtml(provName)} (${escapeHtml(pId)}) - ${pData.total_calls ?? 0} - ${pData.successful_calls ?? 0} - ${pData.failed_calls ?? 0} - -
- ${errPct}% -
-
-
-
- - ${p50} - ${p95} - Н/Д (не отдаются) - - `; - }).join(''); - - provTableBox.innerHTML = ` - - - - - - - - - - - - - - - ${rowsHtml} - -
ПровайдерВсего вызововУспешноСбоиДоля ошибокp50p95Токены
- `; - } - } - - // Roles Table - const rolesTableBox = document.getElementById('analytics-roles-table'); - if (rolesTableBox) { - const byRole = telemetry.by_role || {}; - const roleKeys = Object.keys(byRole); - if (roleKeys.length === 0) { - rolesTableBox.innerHTML = '
Нет данных телеметрии по ролям агентов.
'; - } else { - const rowsHtml = roleKeys.map((rId) => { - const rData = byRole[rId] || {}; - const errPct = rData.error_rate != null ? (rData.error_rate * 100).toFixed(1) : '0.0'; - const p50 = rData.latency_p50_ms != null ? `${rData.latency_p50_ms.toFixed(1)} ms` : 'Н/Д'; - const p95 = rData.latency_p95_ms != null - ? (rData.latency_p95_ms >= 1000 ? `${(rData.latency_p95_ms / 1000).toFixed(1)} s` : `${rData.latency_p95_ms.toFixed(1)} ms`) - : 'Н/Д'; - const roleInfo = (currentSnapshot.routing || {})[rId]; - const rName = (roleInfo && roleInfo.role_name_ru) || rId; - - return ` - - ${escapeHtml(rName)} (${escapeHtml(rId)}) - ${rData.total_calls ?? 0} - ${(rData.total_calls ?? 0) - (rData.failed_calls ?? 0)} - ${rData.failed_calls ?? 0} - ${errPct}% - ${p50} - ${p95} - - `; - }).join(''); - - rolesTableBox.innerHTML = ` - - - - - - - - - - - - - - ${rowsHtml} - -
Роль агентаВсего вызововУспешноСбоиДоля ошибокp50p95
- `; - } - } -} - -// ═══════════════════════════════════════════════════════════════ -// 7. HEALTH VIEW (P0-2 Host & System Diagnostics) -// ═══════════════════════════════════════════════════════════════ -function renderHealthView() { - if (!currentSnapshot) return; - const readiness = currentSnapshot.readiness || {}; - const metrics = currentSnapshot.metrics || {}; - const host = metrics.host || {}; - - // 1. Readiness Banner - const bannerBox = document.getElementById('health-readiness-banner'); - if (bannerBox) { - const st = (readiness.state || 'healthy').toLowerCase(); - bannerBox.className = `readiness-banner ${st}`; - bannerBox.innerHTML = ` -
-
- - ${escapeHtml(readiness.title_ru || 'Система готова к работе')} -
- ${escapeHtml(readiness.state || 'HEALTHY')} -
-
- ${escapeHtml(readiness.summary_ru || 'Все настроенные маршруты и профили доступны.')} -
-
- Ролей в строю: ${readiness.roles_ready_count ?? 0} / ${readiness.total_roles ?? 6} - Аккаунтов подключено: ${readiness.accounts_connected_count ?? 0} / ${readiness.total_accounts ?? 0} - Провайдеров онлайн: ${readiness.providers_ready_count ?? 5} / ${readiness.total_providers ?? 5} -
- `; - } - - // 2. Host Resources Grid - const hostBox = document.getElementById('health-host-resources'); - if (hostBox) { - const cpuPct = host.cpu_percent != null ? host.cpu_percent.toFixed(1) : 'Н/Д'; - const cpuVal = host.cpu_percent != null ? host.cpu_percent : 0; - - const memPct = host.memory_percent != null ? host.memory_percent.toFixed(1) : 'Н/Д'; - const memMb = host.memory_used_mb != null ? (host.memory_used_mb >= 1024 ? `${(host.memory_used_mb / 1024).toFixed(1)} GB` : `${host.memory_used_mb.toFixed(0)} MB`) : ''; - const memVal = host.memory_percent != null ? host.memory_percent : 0; - - const diskPct = host.disk_percent != null ? host.disk_percent.toFixed(1) : 'Н/Д'; - const diskGb = host.disk_used_gb != null ? `${host.disk_used_gb.toFixed(1)} GB` : ''; - const diskVal = host.disk_percent != null ? host.disk_percent : 0; - - const netSpeed = host.net_speed_mbps != null ? `${host.net_speed_mbps.toFixed(1)} Mbps` : 'Н/Д'; - const netSub = host.net_speed_mbps != null ? 'Активное соединение' : 'Н/Д: замер скорости сети отключён'; - - hostBox.innerHTML = ` -
-
- CPU (Процессор) - ${cpuPct}${cpuPct !== 'Н/Д' ? '%' : ''} -
-
-
-
-
Нагрузка хост-системы
-
- -
-
- RAM (Оперативная память) - ${memPct}${memPct !== 'Н/Д' ? '%' : ''} -
-
-
-
-
${memMb ? `Использовано: ${memMb}` : 'Статус использования RAM'}
-
- -
-
- Диск (Хранилище) - ${diskPct}${diskPct !== 'Н/Д' ? '%' : ''} -
-
-
-
-
${diskGb ? `Занято: ${diskGb}` : 'Статус дискового пространства'}
-
- -
-
- Сеть (Пропускная способность) - ${netSpeed} -
-
-
-
-
${netSub}
-
- `; - } - - // 3. Warnings List - const warningsBox = document.getElementById('health-warnings-list'); - if (warningsBox) { - const warnings = readiness.warnings || []; - if (warnings.length === 0) { - warningsBox.innerHTML = ` -
- - Все системы работают штатно: сбоев конфигурации и деградации маршрутов не обнаружено. -
- `; - } else { - warningsBox.innerHTML = warnings.map((w) => ` -
- ⚠️ -
${escapeHtml(w)}
-
- `).join(''); - } - } -} - -// ═══════════════════════════════════════════════════════════════ -// 8. LOGS VIEW (P0-3 GET /api/events with Filtering & Search) -// ═══════════════════════════════════════════════════════════════ -async function fetchLogs() { - const container = document.getElementById('logs-container'); - if (container && cachedEvents.length === 0) { - container.innerHTML = '
⏳ Загрузка журнала событий...
'; - } - - try { - const headers = {}; - if (authToken) headers['X-Hub-Token'] = authToken; - - const res = await fetch('/api/events?limit=100', { headers }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - cachedEvents = data.events || []; - renderLogsList(); - } catch (err) { - console.error('Failed to fetch events:', err); - if (container) { - container.innerHTML = `
Не удалось получить события: ${escapeHtml(err.message)}
`; - } - } -} - -function renderLogsView() { - fetchLogs(); -} - -function renderLogsList() { - const container = document.getElementById('logs-container'); - if (!container) return; - - const searchInput = document.getElementById('logs-search'); - const levelSelect = document.getElementById('logs-filter-level'); - const catSelect = document.getElementById('logs-filter-category'); - - const q = (searchInput ? searchInput.value : '').trim().toLowerCase(); - const levelFilter = levelSelect ? levelSelect.value : 'all'; - const catFilter = catSelect ? catSelect.value : 'all'; - - let filtered = cachedEvents.filter((ev) => { - if (levelFilter !== 'all' && (ev.level || 'info').toLowerCase() !== levelFilter.toLowerCase()) { - return false; - } - if (catFilter !== 'all' && (ev.category || '').toLowerCase() !== catFilter.toLowerCase()) { - return false; - } - if (q) { - const msg = (ev.message || '').toLowerCase(); - const det = (ev.details || '').toLowerCase(); - if (!msg.includes(q) && !det.includes(q)) return false; - } - return true; - }); - - if (filtered.length === 0) { - container.innerHTML = '
Нет событий, соответствующих выбранным фильтрам.
'; - return; - } - - container.innerHTML = filtered.map((ev) => { - const lvl = (ev.level || 'info').toLowerCase(); - return ` -
- ${escapeHtml(ev.timestamp || '—')} - ${escapeHtml(lvl.toUpperCase())} - ${escapeHtml((ev.category || 'system').toUpperCase())} -
-
${escapeHtml(ev.message || '')}
- ${ev.details ? `
${escapeHtml(ev.details)}
` : ''} -
-
- `; - }).join(''); -} - -// ═══════════════════════════════════════════════════════════════ -// 9. SETTINGS VIEW (P0-4 GET /api/settings & save_settings) -// ═══════════════════════════════════════════════════════════════ -async function loadServerSettings() { - try { - const headers = {}; - if (authToken) headers['X-Hub-Token'] = authToken; - - const res = await fetch('/api/settings', { headers }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - currentSettings = data; - populateSettingsForm(data); - } catch (err) { - console.error('Failed to load settings:', err); - } -} - -function populateSettingsForm(s) { - const hostInput = document.getElementById('setting-server-host'); - const portInput = document.getElementById('setting-server-port'); - const tokenBadge = document.getElementById('setting-token-status-badge'); - const quotaSel = document.getElementById('setting-quota-interval'); - const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); - const quotaActionSel = document.getElementById('setting-quota-threshold-action'); - const themeSel = document.getElementById('setting-theme'); - - const pathHome = document.getElementById('path-hermes-home'); - const pathConfig = document.getElementById('path-config-dir'); - const pathLog = document.getElementById('path-log-file'); - - if (hostInput) hostInput.value = s.web_api_host || '127.0.0.1'; - if (portInput) portInput.value = s.web_api_port || 5800; - if (tokenBadge) { - tokenBadge.textContent = s.web_api_token_configured ? '✓ Токен задан' : 'Токен не задан'; - tokenBadge.className = `badge ${s.web_api_token_configured ? 'healthy' : ''}`; - } - if (quotaSel && s.quota_refresh_interval_sec) { - quotaSel.value = String(s.quota_refresh_interval_sec); - } - if (quotaThresholdSel && s.quota_threshold_percent !== undefined) { - quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent)); - } - if (quotaActionSel && s.quota_threshold_action) { - quotaActionSel.value = s.quota_threshold_action; - } - if (themeSel && s.theme) { - themeSel.value = s.theme; - applyTheme(s.theme); - } - - if (pathHome) pathHome.textContent = s.hermes_home || '~/.hermes'; - if (pathConfig) pathConfig.textContent = s.config_dir || '~/.hermes/config'; - if (pathLog) pathLog.textContent = s.log_file || '~/.hermes/logs/hermes-hub.log'; - - if (s.last_update_check && !latestUpdateInfo) { - latestUpdateInfo = s.last_update_check; - } - renderUpdateUI(); -} - -function applyTheme(theme) { - if (theme === 'light') { - document.body.setAttribute('data-theme', 'light'); - document.body.classList.add('theme-light'); - } else { - document.body.removeAttribute('data-theme'); - document.body.classList.remove('theme-light'); - } -} - -function renderSettingsView() { - loadServerSettings(); - renderUpdateUI(); -} - -async function saveHubServerSettings() { - const hostInput = document.getElementById('setting-server-host'); - const portInput = document.getElementById('setting-server-port'); - const tokenInput = document.getElementById('setting-server-token-input'); - const quotaSel = document.getElementById('setting-quota-interval'); - const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); - const quotaActionSel = document.getElementById('setting-quota-threshold-action'); - const themeSel = document.getElementById('setting-theme'); - - const payload = { - web_api_host: hostInput ? hostInput.value.trim() : '127.0.0.1', - web_api_port: portInput ? parseInt(portInput.value, 10) || 5800 : 5800, - quota_refresh_interval_sec: quotaSel ? parseInt(quotaSel.value, 10) || 300 : 300, - quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0, - quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify', - theme: themeSel ? themeSel.value : 'system', - }; - - if (tokenInput && tokenInput.value.trim()) { - payload.web_api_token = tokenInput.value.trim(); - } - - const res = await executeAction('save_settings', payload); - if (res.ok) { - if (themeSel) applyTheme(themeSel.value); - showToast('Настройки сервера успешно сохранены', 'success'); - if (tokenInput) tokenInput.value = ''; - loadServerSettings(); - } -} - -// ── IN-APP UPDATES (P0-1 — P0-4) ── -async function checkUpdates(silent = false) { - if (!silent) { - showToast('Проверка обновлений...', 'info'); - } - try { - const res = await executeAction('check_updates', {}); - if (res.ok && res.data) { - latestUpdateInfo = res.data; - renderUpdateUI(); - if (!silent) { - if (res.data.update_available) { - const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new'); - showToast(`Доступно обновление (сборка ${c})`, 'info'); - } else { - showToast(res.data.message || 'Установлена последняя сборка', 'success'); - } - } - } else { - if (res.data) { - latestUpdateInfo = res.data; - renderUpdateUI(); - } - if (!silent) { - showToast(res.message || 'Ошибка проверки обновлений', 'error'); - } - } - } catch (err) { - if (!silent) { - showToast(`Ошибка проверки обновлений: ${err.message}`, 'error'); - } - } -} - -function renderUpdateUI() { - const badge = document.getElementById('header-update-badge'); - const badgeText = document.getElementById('header-update-text'); - const commitTag = document.getElementById('commit-tag'); - - const installedCommit = (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown') - ? latestUpdateInfo.installed_commit - : (currentSettings && currentSettings.installed_commit ? currentSettings.installed_commit : ''); - - if (commitTag) { - commitTag.textContent = installedCommit ? `Сборка: ${installedCommit.slice(0, 7)}` : 'Сборка: —'; - } - - if (badge && badgeText) { - if (latestUpdateInfo && latestUpdateInfo.update_available) { - badge.classList.remove('hidden'); - const c = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || 'new'); - badgeText.textContent = `Доступно обновление (${c})`; - } else { - badge.classList.add('hidden'); - } - } - - // Populate settings view updates block if elements exist - const updateInfoDesc = document.getElementById('update-installed-info'); - const statusBadge = document.getElementById('update-status-badge'); - const lastCheckedDesc = document.getElementById('update-last-checked-desc'); - const btnApply = document.getElementById('btn-apply-update'); - const detailsBlock = document.getElementById('update-details-block'); - const releaseTitle = document.getElementById('update-release-title'); - const releaseMeta = document.getElementById('update-release-meta'); - const releaseNotes = document.getElementById('update-release-notes'); - - const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '0.1.1'; - const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно'; - if (updateInfoDesc) { - updateInfoDesc.textContent = `Hermes Hub v${curVer} (сборка: ${cDisplay})`; - } - - if (statusBadge) { - if (latestUpdateInfo && latestUpdateInfo.error) { - statusBadge.textContent = 'Ошибка проверки'; - statusBadge.className = 'badge badge-status warning'; - statusBadge.title = latestUpdateInfo.error; - } else if (latestUpdateInfo && latestUpdateInfo.update_available) { - statusBadge.textContent = 'Доступно обновление'; - statusBadge.className = 'badge badge-status warning'; - statusBadge.title = ''; - } else if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) { - statusBadge.textContent = 'Актуально'; - statusBadge.className = 'badge healthy'; - statusBadge.title = ''; - } else { - statusBadge.textContent = 'Не проверялось'; - statusBadge.className = 'badge'; - statusBadge.title = ''; - } - } - - if (lastCheckedDesc) { - if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) { - const tStr = new Date(latestUpdateInfo.checked_at * 1000).toLocaleTimeString('ru-RU'); - const errNote = latestUpdateInfo.error ? ` — Ошибка: ${latestUpdateInfo.error}` : ''; - lastCheckedDesc.textContent = `Последняя проверка: сегодня в ${tStr}${errNote}`; - } else { - lastCheckedDesc.textContent = 'Последняя проверка: еще не выполнялась'; - } - } - - if (btnApply) { - btnApply.disabled = !(latestUpdateInfo && latestUpdateInfo.update_available); - } - - if (detailsBlock && releaseTitle && releaseMeta && releaseNotes) { - if (latestUpdateInfo && latestUpdateInfo.update_available) { - detailsBlock.classList.remove('hidden'); - const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : '—'; - releaseTitle.textContent = `Релиз: ${latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || 'Новая сборка'} (коммит: ${latC})`; - releaseMeta.textContent = latestUpdateInfo.published_at ? `Опубликован: ${latestUpdateInfo.published_at}` : ''; - releaseNotes.textContent = latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.'; - } else { - detailsBlock.classList.add('hidden'); - } - } -} - -function openUpdateModal() { - if (!latestUpdateInfo) { - checkUpdates(false); - return; - } - - const instC = (latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown') - ? latestUpdateInfo.installed_commit.slice(0, 7) - : 'неизвестно'; - const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—'); - - if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub'; - if (elements.modalBody) { - elements.modalBody.innerHTML = ` -
-
+ chain.forEach((pid, index) => { + const prof = profiles[pid] || {}; + const prov = prof.provider || 'unknown'; + const icon = getProviderIcon(prov); + + rolesHtml += ` + - `; - } - if (elements.modalFooter) { - elements.modalFooter.innerHTML = ` - - - `; - } - showModal(); -} - -async function handleInstallUpdateFromModal() { - const btn = document.getElementById('btn-modal-install-update'); - if (btn) { - btn.disabled = true; - btn.textContent = 'Установка...'; - } - await applyUpdate(); - closeModal(); -} - -async function applyUpdate() { - showToast('Загрузка и запуск обновления...', 'info'); - try { - const res = await executeAction('apply_update', {}); - if (res.ok) { - showToast(res.message || 'Обновление запущено успешно!', 'success'); - } else { - showToast(res.message || 'Ошибка установки обновления', 'error'); - } - } catch (err) { - showToast(`Ошибка установки: ${err.message}`, 'error'); - } -} - -// ── MODALS (Account Details, Model Choice, Routing, Wizard) ── -function openAccountDetailsModal(profileId) { - _openAccountModalProfile = profileId; - if (!currentSnapshot) return; - const profile = (currentSnapshot.all_profiles || {})[profileId]; - if (!profile) return; - - const provSummary = (currentSnapshot.providers || []).find(p => p.provider_id === profile.provider); - const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; - const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; - const qs = profile.quota_snapshot; - const buckets = (qs && qs.buckets) ? qs.buckets : []; - - let modelBlockHtml = ''; - if (discoveredModels.length > 0) { - modelBlockHtml = ` -
- -
- - -
-
- `; - } else { - modelBlockHtml = ` -
-
- ⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}. -
- -
- `; - } - - elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`; - elements.modalBody.innerHTML = ` - -
-
${escapeHtml(profile.account_identity || profile.email || profileId)}
-
- Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} • - Тариф: ${escapeHtml(profile.plan || 'Неизвестен')} • - Статус: ${escapeHtml(profile.health_label_ru || 'Работает')} -
-
- Назначенные роли: ${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')} -
-
- - ${modelBlockHtml} - -

- Квоты и корзины провайдера -

-
- ${buckets.map((b) => ` -
-
- ${escapeHtml(b.display_name)} - ${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'} -
-
-
+
+
+
- ${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')} + 26 авг.,
18:42 +
+
+ ● Активен +
+
+
- `).join('') || '
Данные о квотах отсутствуют (провайдер не отдал лимиты).
'} -
- `; + `; + }); - elements.modalFooter.innerHTML = ` - - - - - `; - - showModal(); -} - -async function handleSaveProfileModel(profileId) { - const sel = document.getElementById('modal-model-select'); - if (!sel) return; - const model = sel.value; - const feedbackArea = document.getElementById('modal-feedback-area'); - if (feedbackArea) { - feedbackArea.innerHTML = ''; - } - const res = await executeAction('set_model', { profile_id: profileId, model: model }); - if (feedbackArea) { - if (res.ok) { - feedbackArea.innerHTML = ``; - if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { - currentSnapshot.all_profiles[profileId].preferred_models = [model]; - } - } else { - feedbackArea.innerHTML = ``; - } - } -} - -function openAgentModelModal(roleId, profileId) { - if (!currentSnapshot) return; - const profile = (currentSnapshot.all_profiles || {})[profileId]; - if (!profile) return; - - const provSummary = (currentSnapshot.providers || []).find(p => p.provider_id === profile.provider); - const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; - const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; - const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId; - - elements.modalTitle.textContent = `Выбор модели для роли: ${roleName}`; - elements.modalBody.innerHTML = ` - -
- Профиль агента: ${escapeHtml(profile.display_name)} (${profileId}) • Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} -
- ${discoveredModels.length > 0 ? ` -
- - -
- ` : ` -
-
- ⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}. + rolesHtml += `
- -
- `} - `; - - elements.modalFooter.innerHTML = ` - - ${discoveredModels.length > 0 ? `` : ''} - `; - - showModal(); -} - -async function handleSaveRoleModel(roleId, profileId) { - const sel = document.getElementById('role-model-select'); - if (!sel) return; - const model = sel.value; - const feedbackArea = document.getElementById('modal-feedback-area'); - if (feedbackArea) { - feedbackArea.innerHTML = ''; - } - const res = await executeAction('set_model', { profile_id: profileId, model: model, role_id: roleId }); - if (feedbackArea) { - if (res.ok) { - feedbackArea.innerHTML = ``; - if (currentSnapshot) { - if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { - currentSnapshot.all_profiles[profileId].preferred_models = [model]; - } - if (currentSnapshot.routing && currentSnapshot.routing[roleId]) { - currentSnapshot.routing[roleId].default_model = model; - } - if (currentSnapshot.agents) { - const ag = currentSnapshot.agents.find(a => a.role_id === roleId); - if (ag) ag.model = model; - } - } - setTimeout(() => { - closeModal(); - renderCurrentView(); - }, 700); - } else { - feedbackArea.innerHTML = ``; - } - } -} - -async function handleRefreshProviderModels(providerId, profileId = null) { - showToast(`Запрос списка моделей для ${providerId}...`, 'info'); - const res = await executeAction('refresh_models', { provider: providerId }); - if (res.ok) { - showToast('Запрос обновления моделей отправлен', 'success'); - if (profileId) { - setTimeout(() => openAccountDetailsModal(profileId), 500); - } - } -} - -async function handleTestProfile(profileId) { - const feedbackArea = document.getElementById('modal-feedback-area'); - if (feedbackArea) { - feedbackArea.innerHTML = ''; - } - const res = await executeAction('test', { profile_id: profileId }); - if (feedbackArea) { - if (res.ok) { - feedbackArea.innerHTML = ``; - } else { - feedbackArea.innerHTML = ``; - } - } -} - -// ── Add Account Wizard (P0-5 Headless Server Honesty) ── -function openAddAccountWizard() { - elements.modalTitle.textContent = 'Мастер подключения учетной записи'; - showWizardStep1(); - showModal(); -} - -function showWizardStep1() { - elements.modalBody.innerHTML = ` -
- Шаг 1 из 3: Выберите провайдера ИИ -
-
- - - - - - -
- `; - elements.modalFooter.innerHTML = ` - - `; -} - -function showWizardStep2(providerId) { - let bodyHtml = ''; - - if (providerId === 'grok' || providerId === 'openai-codex') { - // Поток кода устройства проведён через веб-API. Адрес и код приходят от - // ПРОВАЙДЕРА и подставляются сюда; ничего не вписано в код. Раньше здесь - // стояли выдуманные GRK-7842 и CDX-9104 при жёстко вписанном адресе. - const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'; - bodyHtml = ` -
- Шаг 2 из 3: Авторизация ${providerName} по коду устройства -
-
-
Запрашиваем код у провайдера…
`; - setTimeout(() => startDeviceAuth(providerId), 0); - } else if (providerId === 'antigravity' || providerId === 'claude') { - // Раньше здесь стояла заглушка: «авторизация через веб-интерфейс - // невозможна», со ссылкой на SSH и перенос каталога профилей. Это было - // неверно — сервер умеет принять вставленное вручную значение, поэтому - // браузер нужен ГДЕ УГОДНО, а не на машине с Hub. - const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude'; - bodyHtml = ` -
- Шаг 2 из 3: Авторизация ${providerName} -
-
- - -
- Вход в занятый слот заменит учётные данные, которые в нём сейчас. + } + leftCol.innerHTML = rolesHtml; + + // Render Right Column (Available Accounts) + let availHtml = ''; + const searchEl = document.getElementById('routing-account-search'); + const q = searchEl ? searchEl.value.toLowerCase() : ''; + + let count = 0; + for (const [pid, prof] of Object.entries(profiles)) { + if (q && !pid.toLowerCase().includes(q) && !(prof.provider||'').toLowerCase().includes(q)) continue; + count++; + const icon = getProviderIcon(prof.provider); + availHtml += ` + -
- -
-
-
Выберите слот и нажмите «Получить ссылку».
-
- `; - } else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') { - bodyHtml = ` -
- Шаг 2 из 3: Настройка локального сервера (Local LLM) -
-
- - -
-
- - +
+
+
+
`; + } + rightCol.innerHTML = availHtml; + const countEl = document.getElementById('available-accounts-count'); + if (countEl) countEl.innerText = `${count} аккаунтов`; + + setupDragAndDrop(); +} + +function quickAddProfile(pid) { + const routing = currentSnapshot.routing || {}; + let role = 'manager'; + if (!routing[role]) role = Object.keys(routing)[0]; + if (!role) return; + addProfileToChain(role, pid); +} + +function setupDragAndDrop() { + let draggedEl = null; + let dragPid = null; + let sourceRole = null; + + document.querySelectorAll('.draggable-item').forEach(el => { + el.addEventListener('dragstart', (e) => { + draggedEl = el; + dragPid = el.dataset.pid; + sourceRole = el.dataset.role || null; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragPid); + setTimeout(() => el.style.opacity = '0.5', 0); + }); + el.addEventListener('dragend', (e) => { + el.style.opacity = '1'; + document.querySelectorAll('.drag-over').forEach(d => d.classList.remove('drag-over')); + draggedEl = null; + }); + }); + + // Drop zones (the "add to end" zones) + document.querySelectorAll('.drop-zone').forEach(zone => { + zone.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + zone.classList.add('drag-over'); + }); + zone.addEventListener('dragleave', (e) => { + zone.classList.remove('drag-over'); + }); + zone.addEventListener('drop', (e) => { + e.preventDefault(); + zone.classList.remove('drag-over'); + const targetRole = zone.dataset.role; + if (!targetRole || !dragPid) return; + + handleDropMove(dragPid, sourceRole, targetRole, -1); + }); + }); + + // Reordering inside role-chain-list + document.querySelectorAll('.account-row').forEach(row => { + row.addEventListener('dragover', (e) => { + e.preventDefault(); + row.classList.add('drag-over'); + }); + row.addEventListener('dragleave', (e) => { + row.classList.remove('drag-over'); + }); + row.addEventListener('drop', (e) => { + e.preventDefault(); + row.classList.remove('drag-over'); + const targetRole = row.dataset.role; + if (!targetRole || !dragPid) return; + + const list = row.parentNode; + const children = Array.from(list.children); + const insertIndex = children.indexOf(row); + + handleDropMove(dragPid, sourceRole, targetRole, insertIndex); + }); + }); +} + +function handleDropMove(pid, sourceRole, targetRole, insertIndex) { + const routing = currentSnapshot.routing; + if (!routing || !routing[targetRole]) return; + + const targetChain = [...(routing[targetRole].preferred_chain || [])]; + + if (sourceRole && sourceRole === targetRole) { + const oldIndex = targetChain.indexOf(pid); + if (oldIndex > -1) { + targetChain.splice(oldIndex, 1); + } + if (insertIndex === -1) { + targetChain.push(pid); + } else { + let idx = insertIndex; + if (oldIndex > -1 && oldIndex < insertIndex) idx--; + targetChain.splice(idx, 0, pid); + } + updateRoleChain(targetRole, targetChain); } else { - bodyHtml = ` -
- Шаг 2 из 3: Ввод API ключа ${providerId} -
-
- - -
- `; - } - - elements.modalBody.innerHTML = ` - - ${bodyHtml} - `; - - elements.modalFooter.innerHTML = ` - - - `; -} - -function proceedToWizardStep3(providerId) { - const baseInput = document.getElementById('wiz-base-url-input'); - if (baseInput) { - window._wiz_base_url = baseInput.value.trim(); - } - const tokenInput = document.getElementById('wiz-token-input'); - if (tokenInput) { - window._wiz_token = tokenInput.value.trim(); - } - showWizardStep3(providerId); -} - -function showWizardStep3(providerId) { - elements.modalBody.innerHTML = ` - -
- Шаг 3 из 3: Назначение роли для нового аккаунта -
-
- - -
- `; - - elements.modalFooter.innerHTML = ` - - - `; -} - -async function finishAddAccount(providerId) { - const roleSelect = document.getElementById('wiz-target-role'); - const targetRole = roleSelect ? roleSelect.value : 'coder-primary'; - - const feedbackArea = document.getElementById('modal-feedback-area'); - if (feedbackArea) { - feedbackArea.innerHTML = ''; - } - - const payload = { - provider: providerId, - target_role: targetRole, - }; - if (window._wiz_base_url) { - payload.base_url = window._wiz_base_url; - } - if (window._wiz_token) { - payload.token = window._wiz_token; - } - - const res = await executeAction('add_account', payload); - - if (res.ok) { - showToast('Аккаунт успешно добавлен в маршрутизацию', 'success'); - closeModal(); - fetchSnapshot(); - } else { - if (feedbackArea) { - feedbackArea.innerHTML = ``; + if (targetChain.includes(pid)) { + showToast(`Аккаунт ${pid} уже есть в роли ${targetRole}`, 'warning'); + return; } + if (insertIndex === -1) { + targetChain.push(pid); + } else { + targetChain.splice(insertIndex, 0, pid); + } + updateRoleChain(targetRole, targetChain); } } -// ── Routing Drag & Drop Reordering (P0-1, P0-2) ── -function handleNodeDragStart(e, roleId, index) { - currentDragState = { roleId, fromIndex: index }; - e.dataTransfer.effectAllowed = 'move'; +async function updateRoleChain(roleId, newChain) { try { - e.dataTransfer.setData('text/plain', JSON.stringify(currentDragState)); - } catch (err) { - // fallback - } - const chip = e.currentTarget; - if (chip) { - chip.classList.add('dragging'); + const resp = await fetch(`/api/v1/router/roles/${encodeURIComponent(roleId)}/chain`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newChain) + }); + if (!resp.ok) throw new Error(await resp.text()); + showToast(`Цепочка для ${roleId} обновлена`, 'success'); + await fetchSnapshot(); + } catch (e) { + showToast(`Ошибка сохранения: ${e.message}`, 'error'); } } -function handleNodeDragOver(e, roleId, index) { - if (!currentDragState || currentDragState.roleId !== roleId) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - const chip = e.currentTarget; - if (chip && !chip.classList.contains('dragging')) { - chip.classList.add('drop-target'); - } -} - -function handleNodeDragLeave(e) { - const chip = e.currentTarget; - if (chip) { - chip.classList.remove('drop-target'); - } -} - -function handleNodeDragEnd(e) { - document.querySelectorAll('.pipeline-node-chip').forEach((c) => { - c.classList.remove('dragging', 'drop-target'); - }); - currentDragState = null; -} - -async function handleNodeDrop(e, roleId, targetIndex) { - e.preventDefault(); - document.querySelectorAll('.pipeline-node-chip').forEach((c) => { - c.classList.remove('dragging', 'drop-target'); - }); - - if (!currentDragState || currentDragState.roleId !== roleId) { - currentDragState = null; - return; - } - - const sourceIndex = currentDragState.fromIndex; - currentDragState = null; - - if (sourceIndex === targetIndex) return; - - const pipeline = (currentSnapshot.routing || {})[roleId]; - if (!pipeline || !pipeline.nodes) return; - - const chain = pipeline.nodes.map((n) => n.profile_id); - if (sourceIndex < 0 || sourceIndex >= chain.length || targetIndex < 0 || targetIndex >= chain.length) return; - - const [moved] = chain.splice(sourceIndex, 1); - chain.splice(targetIndex, 0, moved); - - showToast(`Обновление порядка цепочки '${pipeline.role_name_ru || roleId}'...`, 'info'); - const res = await executeAction('save_chain', { role_id: roleId, chain: chain }); - if (res.ok) { - showToast(`Порядок цепочки '${pipeline.role_name_ru || roleId}' сохранен`, 'success'); - if (pipeline.nodes) { - const movedNode = pipeline.nodes.splice(sourceIndex, 1)[0]; - pipeline.nodes.splice(targetIndex, 0, movedNode); - renderRoutingView(); +async function removeProfileFromChain(roleId, pid) { + const routing = currentSnapshot.routing; + if (!routing || !routing[roleId]) return; + const chain = [...(routing[roleId].preferred_chain || [])]; + const idx = chain.indexOf(pid); + if (idx > -1) { + chain.splice(idx, 1); + await updateRoleChain(roleId, chain); } - fetchSnapshot(); - } else { - showToast(res.message || 'Ошибка сохранения цепочки', 'error'); - } -} - -// ── Routing Node Model, Account & Chain Management ── -async function handleNodeAccountChange(roleId, profileId, isPrimary = true) { - if (!roleId || !profileId) return; - showToast(`Назначение аккаунта '${profileId}' на роль '${roleId}'...`, 'info'); - const res = await executeAction('assign_role', { - role_id: roleId, - profile_id: profileId, - is_primary: isPrimary, - }); - if (res.ok) { - showToast(`Аккаунт '${profileId}' успешно назначен`, 'success'); - fetchSnapshot(); - } else { - showToast(res.message || 'Ошибка назначения аккаунта', 'error'); - } -} - -async function handleNodeModelChange(roleId, profileId, newModel) { - if (!newModel) return; - showToast(`Сохранение модели '${newModel}' для ${profileId}...`, 'info'); - const res = await executeAction('set_model', { profile_id: profileId, model: newModel, role_id: roleId }); - if (res.ok) { - showToast(`Модель '${newModel}' успешно сохранена`, 'success'); - if (currentSnapshot) { - if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { - currentSnapshot.all_profiles[profileId].preferred_models = [newModel]; - } - if (currentSnapshot.routing && currentSnapshot.routing[roleId]) { - currentSnapshot.routing[roleId].default_model = newModel; - const node = (currentSnapshot.routing[roleId].nodes || []).find((n) => n.profile_id === profileId); - if (node) node.model = newModel; - } - } - renderCurrentView(); - } else { - showToast(res.message || 'Ошибка сохранения модели', 'error'); - } -} - -async function handleRemoveNodeFromChain(roleId, profileId) { - const pipeline = (currentSnapshot.routing || {})[roleId]; - if (!pipeline || !pipeline.nodes) return; - - const chain = pipeline.nodes.map((n) => n.profile_id).filter((p) => p !== profileId); - showToast(`Удаление профиля ${profileId} из цепочки...`, 'info'); - const res = await executeAction('save_chain', { role_id: roleId, chain: chain }); - if (res.ok) { - showToast(`Профиль удален из цепочки '${pipeline.role_name_ru || roleId}'`, 'success'); - pipeline.nodes = pipeline.nodes.filter((n) => n.profile_id !== profileId); - renderRoutingView(); - fetchSnapshot(); - } else { - showToast(res.message || 'Ошибка обновления цепочки', 'error'); - } } function openAddNodeToChainModal(roleId) { @@ -2380,6 +1305,128 @@ async function handleAddNodeToChain(roleId) { } // ── SETTINGS MANAGEMENT ── +function renderSettingsView() { + if (!currentSnapshot) return; + const paths = currentSnapshot.system_paths || {}; + const s = currentSnapshot.settings || {}; + + const elHome = document.getElementById('path-hermes-home'); + const elConfig = document.getElementById('path-config-dir'); + const elLog = document.getElementById('path-log-file'); + + if (elHome) elHome.textContent = paths.hermes_home || '—'; + if (elConfig) elConfig.textContent = paths.config_dir || '—'; + if (elLog) elLog.textContent = paths.log_file || '—'; + + const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); + const quotaActionSel = document.getElementById('setting-quota-threshold-action'); + const emailMaskingSel = document.getElementById('setting-email-masking-mode'); + const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); + + if (quotaThresholdSel && s.quota_threshold_percent !== undefined) { + quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent)); + } + if (quotaActionSel && s.quota_threshold_action) { + quotaActionSel.value = s.quota_threshold_action; + } + if (emailMaskingSel && s.email_masking_mode) { + emailMaskingSel.value = s.email_masking_mode; + } + if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) { + monitorIntervalInput.value = s.monitoring_interval_seconds; + } +} + +async function saveHubServerSettings() { + const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); + const quotaActionSel = document.getElementById('setting-quota-threshold-action'); + const emailMaskingSel = document.getElementById('setting-email-masking-mode'); + const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); + + const newSettings = { + quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0, + quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify', + email_masking_mode: emailMaskingSel ? emailMaskingSel.value : 'none', + monitoring_interval_seconds: monitorIntervalInput ? parseInt(monitorIntervalInput.value, 10) || 30 : 30, + }; + + showToast('Сохранение настроек сервера...', 'info'); + const res = await executeAction('save_settings', newSettings); + if (res.ok) { + showToast('Настройки сервера успешно сохранены', 'success'); + fetchSnapshot(); + } else { + showToast(res.message || 'Ошибка сохранения настроек сервера', 'error'); + } +} + +// ── PREFLIGHT READINESS CHECKS ── +async function runPreflightChecks() { + const container = document.getElementById('preflight-results-container'); + const btn = document.getElementById('btn-run-preflight'); + if (btn) btn.disabled = true; + if (container) { + container.innerHTML = '
⏳ Запуск zero-quota проверки зависимостей и окружения...
'; + } + try { + const res = await executeAction('run_preflight', {}); + if (!res) throw new Error('Сервер не вернул ответ'); + const report = res.data || {}; + renderPreflightReport(report, container); + showToast(res.message || 'Проверка готовности завершена', res.ok ? 'success' : 'warning'); + } catch (err) { + if (container) { + container.innerHTML = ``; + } + showToast('Ошибка при запуске проверки готовности', 'error'); + } finally { + if (btn) btn.disabled = false; + } +} + +function renderPreflightReport(report, container) { + if (!container) return; + const checks = report.checks || []; + const passed = report.passed_count || 0; + const failed = report.failed_count || 0; + const warn = report.warn_count || 0; + + const statusBadge = `${failed === 0 ? 'Все проверки пройдены' : `Обнаружено ошибок: ${failed}`}`; + + let html = ` +
+
Результат: ${statusBadge}
+
+ Пройдено: ${passed} • + Ошибок: ${failed} • + Предупреждений: ${warn} +
+
+
+ `; + + checks.forEach((item) => { + const badgeClass = item.status === 'PASS' ? 'healthy' : (item.status === 'WARN' ? 'warning' : 'error'); + const icon = item.status === 'PASS' ? '✓' : (item.status === 'WARN' ? '⚠' : '✕'); + html += ` +
+
+
+ ${icon} ${escapeHtml(item.status)} + ${escapeHtml(item.name || item.check_id)} +
+
+
${escapeHtml(item.message || '')}
+ ${item.remediation ? `
💡 Рекомендация: ${escapeHtml(item.remediation)}
` : ''} +
+ `; + }); + + html += '
'; + container.innerHTML = html; +} + + function initSettings() { const btnSave = document.getElementById('btn-save-client-settings'); const tokenInput = document.getElementById('setting-client-token-input'); diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index d9cf4a5..92f0fec 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -5,6 +5,7 @@ Hermes Hub — Панель управления + @@ -92,52 +93,53 @@
-
-
-
Состояние системы
-
-
+
+
+ +
-
-
Подключено аккаунтов
-
-
-
-
-
Готовых ролей
-
-
-
-
-
Провайдеры ИИ
-
-
5 поддерживаемых систем
+
+ Сохранено + +
-
-
-
-
Схема маршрутизации запросов
-
Распределение агентов и цепочки отказоустойчивости
+
+
Активные задачиЗагрузка…Получение снапшота
+
Агенты онлайнЗагрузка…Получение снапшота
+
Среднее время ответаЗагрузка…Получение телеметрии
+
Использование токеновЗагрузка…Получение телеметрии
+
Успешность задачЗагрузка…Получение телеметрии
+
+ +
+
+
+
Workflow Загрузка…
+ +
100%
+
+
+ +
+ +
- -
-
- -
+
+ ОжидаетРаботаетПроверяетОшибкаЗавершено + → Успех⇢ Возврат +
+
+ +
-
-
-
Провайдеры и доступность runtime
-
Статус локальных адаптеров и обнаруженные модели
-
-
- -
+
+
Последние события

Загрузка событий…

+
Управление LIVE

@@ -177,14 +179,48 @@ -
-
- Главный центр управления маршрутизацией: перетаскивайте узлы для смены приоритета (Основной → Резерв 1 → Резерв 2), настраивайте рабочие модели и управляйте составом цепочек. -
-
- -
-
+ +
+
+
+ + Перетащите аккаунт из правой панели в нужную роль и установите порядок (приоритет) использования. +
+ Система будет использовать аккаунты сверху вниз по списку при выполнении задач с учётом квот и состояния. +
+
+ +
+ +
+ +
+ + +
+
+
+

Доступные аккаунты

+ 0 аккаунтов +
+
+ +
+
+ + +
+
+
+ +
+
+ Перетащите аккаунт в нужную роль или нажмите «+» для добавления +
+
+
+
+
@@ -241,9 +277,23 @@
+
+
+
+
Проверка готовности (Preflight / Dependency Agent)
+
Zero-quota аудит окружения, зависимостей, прав доступа и локальных серверов
+
+ +
+
+ +
+
+
Ресурсы хост-системы
+
Мониторинг CPU, памяти, диска и сетевых параметров
@@ -366,6 +416,19 @@
+
+
+
Маскирование почты (PII)
+
Уровень маскирования адресов электронной почты в снапшотах и логах
+
+
+ +
+
Тема оформления
@@ -505,5 +568,15 @@ + + diff --git a/src/antigravity_provider/router/web/static/style.css b/src/antigravity_provider/router/web/static/style.css index c5b89cf..12db823 100644 --- a/src/antigravity_provider/router/web/static/style.css +++ b/src/antigravity_provider/router/web/static/style.css @@ -1,33 +1,7 @@ -/* Hermes Hub Web Client — Dark Theme & Cockpit Design System */ +/* Theme definitions according to A29 Design System "Крона" */ :root { - --bg-base: #061916; - --bg-sidebar: #08221E; - --bg-header: #071B18; - --bg-statusbar: #061512; - --bg-modal-backdrop: rgba(2, 11, 9, 0.85); - - --surface: #0B2520; - --surface-hover: #12342C; - --surface-active: #194535; - --surface-selected: #173B2E; - --surface-muted: #091E1A; - - --border: #36513B; - --border-subtle: #203A2D; - --border-accent: #B78525; - --border-hover: #537456; - - --text-primary: #F8F0DC; - --text-secondary: #D1C7AE; - --text-muted: #8FA395; - --text-accent: #E0B84E; - - --accent: #C89A2B; - --accent-hover: #E0B84E; - --accent-pressed: #A9781E; - --accent-dim: rgba(200, 154, 43, 0.15); - + /* Common variables */ --status-healthy: #72C943; --status-warning: #E1A62B; --status-error: #E45C4F; @@ -44,14 +18,107 @@ --radius-md: 6px; --radius-lg: 8px; - --font-ui: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif; - --font-title: "Cinzel", "Segoe UI", serif, sans-serif; + --font-ui: 'Inter', "Segoe UI", -apple-system, sans-serif; + --font-title: "Cinzel", "Cinzel Decorative", "Segoe UI", serif; --font-mono: "Consolas", "Courier New", monospace; --header-height: 58px; --sidebar-width: 230px; } +/* Default DARK theme */ +:root, body[data-theme="dark"] { + --bg-base: #101510; + --bg-sidebar: #0D120D; + --bg-header: #0D120D; + --bg-statusbar: #0D120D; + --bg-modal-backdrop: rgba(16, 21, 16, 0.85); + + --surface: #1A2A1F; + --surface-hover: #2F4A36; + --surface-active: #3C5C44; + --surface-selected: #28402F; + --surface-muted: #152219; + + --border: #36513B; + --border-subtle: #203A2D; + --border-accent: #CDAA64; + --border-hover: #537456; + + --text-primary: #F7F1E3; + --text-secondary: #B0B8B2; + --text-muted: #8FA395; + --text-accent: #CDAA64; + + --accent: #CDAA64; + --accent-hover: #E0B84E; + --accent-pressed: #A9781E; + --accent-dim: rgba(205, 170, 100, 0.15); +} + +/* MEDIUM theme (cream cards on green canvas) */ +body[data-theme="medium"] { + --bg-base: #1A2A1F; + --bg-sidebar: #152219; + --bg-header: #152219; + --bg-statusbar: #152219; + --bg-modal-backdrop: rgba(26, 42, 31, 0.85); + + --surface: #F7F1E3; + --surface-hover: #F0EAD6; + --surface-active: #E6DFCB; + --surface-selected: #EAE3CF; + --surface-muted: #2F4A36; + + --border: #D1C7AE; + --border-subtle: #E0D7BF; + --border-accent: #CDAA64; + --border-hover: #B7A88D; + + --text-primary: #101510; + --text-secondary: #2F4A36; + --text-muted: #537456; + --text-accent: #B78525; + + --accent: #B78525; + --accent-hover: #CDAA64; + --accent-pressed: #9A6F1D; + --accent-dim: rgba(183, 133, 37, 0.15); +} + +/* LIGHT theme */ +body[data-theme="light"] { + --bg-base: #F7F1E3; + --bg-sidebar: #F0EAD6; + --bg-header: #F0EAD6; + --bg-statusbar: #E6DFCB; + --bg-modal-backdrop: rgba(247, 241, 227, 0.85); + + --surface: #FFFFFF; + --surface-hover: #FDFBF7; + --surface-active: #F4EEDF; + --surface-selected: #FAF7F0; + --surface-muted: #F0EAD6; + + --border: #D1C7AE; + --border-subtle: #EAE3CF; + --border-accent: #CDAA64; + --border-hover: #C5BAA1; + + --text-primary: #101510; + --text-secondary: #2F4A36; + --text-muted: #537456; + --text-accent: #B78525; + + --accent: #B78525; + --accent-hover: #CDAA64; + --accent-pressed: #9A6F1D; + --accent-dim: rgba(183, 133, 37, 0.15); +} +/* Hermes Hub Web Client — Dark Theme & Cockpit Design System */ + + + * { box-sizing: border-box; margin: 0; @@ -1559,20 +1626,114 @@ body { } /* ── Light Theme Override ── */ -body[data-theme="light"], body.theme-light { - --bg-base: #F4F6F8; - --bg-sidebar: #E9ECEF; - --bg-header: #FFFFFF; - --bg-statusbar: #DEE2E6; - --surface: #FFFFFF; - --surface-hover: #F1F3F5; - --surface-active: #E9ECEF; - --surface-selected: #E2E6EA; - --surface-muted: #F8F9FA; - --border: #CED4DA; - --border-subtle: #DEE2E6; - --border-accent: #B78525; - --text-primary: #212529; - --text-secondary: #495057; - --text-muted: #6C757D; + + +/* Drag and Drop Styles */ +.draggable-item { + cursor: grab; + user-select: none; +} +.draggable-item:active { + cursor: grabbing; +} +.drop-zone { + min-height: 40px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--text-muted); + transition: all 0.2s; + margin-top: 8px; +} +.drop-zone.drag-over { + border-color: var(--accent); + background-color: var(--accent-dim); + color: var(--accent); +} +.role-section { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + margin-bottom: 16px; + display: flex; + flex-direction: column; +} +.role-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + justify-content: space-between; + align-items: center; +} +.role-header h4 { + font-family: var(--font-title); + color: var(--text-primary); + font-size: 14px; + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} +.role-badge { + font-size: 10px; + background-color: var(--accent-dim); + color: var(--accent); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--font-ui); +} +.account-row { + display: grid; + grid-template-columns: 24px 20px 200px 120px 120px 150px 80px 80px 24px; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background-color: var(--surface); + font-size: 12px; +} +.account-row:last-child { + border-bottom: none; +} +.account-row.drag-over { + border-top: 2px solid var(--accent); +} +.drag-handle { + color: var(--text-muted); + cursor: grab; + text-align: center; +} +.provider-logo-sm { + width: 16px; + height: 16px; + border-radius: 2px; +} +.available-account-card { + background-color: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 10px; + display: flex; + align-items: center; + gap: 10px; + cursor: grab; +} +.available-account-card:hover { + border-color: var(--border); + background-color: var(--surface-hover); +} +.grid-header { + display: grid; + grid-template-columns: 24px 20px 200px 120px 120px 150px 80px 80px 24px; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + border-bottom: 1px solid var(--border-subtle); + background-color: var(--surface-muted); } diff --git a/src/antigravity_provider/router/web/static/workflow.css b/src/antigravity_provider/router/web/static/workflow.css new file mode 100644 index 0000000..7c00f01 --- /dev/null +++ b/src/antigravity_provider/router/web/static/workflow.css @@ -0,0 +1,89 @@ +/* A30 overview/workflow components. Theme colors come from the shared tokens. */ +#view-overview { padding: 0; min-width: 860px; } +.workflow-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; } +.workflow-mode { display:flex; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-muted); } +.workflow-mode-btn { min-width:78px; border:0; border-radius:var(--radius-sm); padding:7px 16px; color:var(--text-muted); background:transparent; cursor:pointer; font-weight:700; } +.workflow-mode-btn.active { color:var(--text-primary); background:var(--surface-active); box-shadow:inset 0 0 0 1px var(--border-accent); } +.workflow-mode-btn:first-child.active { color:var(--status-healthy); } +.workflow-toolbar-actions { display:flex; gap:8px; align-items:center; } +.workflow-save-state { color:var(--text-muted); font-size:11px; } +.workflow-save-state.dirty { color:var(--status-warning); } +.workflow-kpis { display:grid; grid-template-columns:repeat(5,minmax(130px,1fr)); gap:8px; margin-bottom:10px; } +.workflow-kpi { padding:11px 13px; min-height:78px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:linear-gradient(145deg,var(--surface),var(--surface-muted)); } +.workflow-kpi span,.workflow-kpi small { display:block; color:var(--text-muted); } +.workflow-kpi strong { display:block; margin:4px 0 2px; font-size:20px; font-weight:650; color:var(--text-primary); } +.workflow-kpi small { font-size:10px; line-height:1.25; } +.workflow-main-layout { display:grid; grid-template-columns:minmax(580px,1fr) 330px; gap:10px; min-height:520px; } +.workflow-board-card,.workflow-inspector,.workflow-events,.workflow-run-panel { border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); overflow:hidden; } +.workflow-board-header { min-height:45px; padding:8px 12px; display:flex; gap:18px; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); } +.workflow-board-header strong { color:var(--text-accent); text-transform:uppercase; } +.workflow-board-header label { margin-left:auto; font-size:11px; color:var(--text-muted); } +.workflow-board-header input { width:42px; padding:3px; color:var(--text-primary); background:var(--surface-muted); border:1px solid var(--border); border-radius:3px; } +.workflow-run-state { margin-left:8px; font-size:11px; color:var(--text-muted); } +.workflow-run-state.running { color:var(--status-healthy); } +.workflow-run-state.failed,.workflow-run-state.interrupted { color:var(--status-error); } +.workflow-zoom { display:flex; align-items:center; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); } +.workflow-zoom button { width:28px; height:27px; border:0; border-left:1px solid var(--border-subtle); color:var(--text-accent); background:transparent; cursor:pointer; } +.workflow-zoom span { min-width:44px; text-align:center; font-size:11px; } +.workflow-canvas { position:relative; height:430px; overflow:hidden; background-color:var(--bg-base); background-image:radial-gradient(var(--border-subtle) 1px,transparent 1px); background-size:18px 18px; } +.workflow-edges,.workflow-node-layer { position:absolute; inset:0; width:100%; height:100%; transform-origin:0 0; } +.workflow-edges { overflow:visible; pointer-events:none; } +.workflow-edges path { fill:none; stroke:var(--accent); stroke-width:1.6; marker-end:url(#wf-arrow); } +.workflow-edges path.success,.workflow-edges path.review_passed { stroke:var(--status-healthy); } +.workflow-edges path.review_failed,.workflow-edges path.error { stroke:var(--status-error); stroke-dasharray:7 5; } +.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; } +.workflow-edge-hit { fill:none; stroke:transparent; stroke-width:14; pointer-events:stroke; cursor:pointer; } +.workflow-node { position:absolute; width:190px; min-height:92px; padding:11px 12px; border:1px solid var(--border-accent); border-radius:9px; background:linear-gradient(145deg,var(--surface),var(--surface-muted)); box-shadow:0 8px 22px rgba(0,0,0,.18); cursor:pointer; user-select:none; } +.workflow-node.selected { outline:2px solid var(--accent); box-shadow:0 0 18px var(--accent-dim); } +.workflow-node.working { border-color:var(--status-healthy); box-shadow:0 0 20px rgba(114,201,67,.24); } +.workflow-node.error { border-color:var(--status-error); } +.workflow-node.completed { border-color:var(--status-disabled); } +.workflow-node h3 { margin:0 0 2px; font-size:13px; font-weight:650; color:var(--text-primary); } +.workflow-node p { overflow:hidden; margin:1px 0; color:var(--text-muted); font-size:10px; white-space:nowrap; text-overflow:ellipsis; } +.workflow-node-status { display:flex; align-items:center; gap:5px; margin-top:6px; font-size:10px; color:var(--text-secondary); } +.workflow-node-status i,.workflow-legend i { width:7px; height:7px; border-radius:50%; background:var(--status-info); } +.workflow-node.working .workflow-node-status i,.state-working { background:var(--status-healthy)!important; } +.workflow-node.reviewing .workflow-node-status i,.state-reviewing { background:var(--status-warning)!important; } +.workflow-node.error .workflow-node-status i,.state-error { background:var(--status-error)!important; } +.workflow-node.completed .workflow-node-status i,.state-completed { background:var(--status-disabled)!important; } +.workflow-port { display:none; position:absolute; top:37px; width:13px; height:22px; border:1px solid var(--accent); border-radius:8px; background:var(--surface-active); } +.workflow-mode-edit .workflow-port { display:block; } +.workflow-port.in { left:-8px; } +.workflow-port.out { right:-8px; cursor:crosshair; } +.workflow-legend { min-height:39px; padding:8px 12px; display:flex; align-items:center; gap:14px; flex-wrap:wrap; border-top:1px solid var(--border-subtle); color:var(--text-muted); font-size:10px; } +.workflow-legend span { display:flex; align-items:center; gap:5px; } +.edge-legend.success { color:var(--status-healthy); }.edge-legend.return { color:var(--status-error); } +.workflow-minimap { position:absolute; left:10px; bottom:10px; width:110px; height:68px; border:1px solid var(--border); background:rgba(6,25,22,.82); pointer-events:none; } +.workflow-minimap i { position:absolute; width:16px; height:9px; border:1px solid var(--accent); background:var(--surface-muted); } +.workflow-empty { position:absolute; inset:0; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:6px; color:var(--text-muted); } +.workflow-inspector { min-width:0; padding:12px; overflow:auto; max-height:520px; } +.workflow-inspector-empty { height:100%; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:8px; color:var(--text-muted); } +.workflow-inspector h2 { margin:0; color:var(--text-accent); font:600 15px var(--font-title); } +.inspector-tabs { display:flex; gap:2px; margin:10px 0; border-bottom:1px solid var(--border-subtle); overflow-x:auto; } +.inspector-tabs button { padding:7px 6px; border:0; border-bottom:2px solid transparent; color:var(--text-muted); background:transparent; font-size:10px; cursor:pointer; } +.inspector-tabs button.active { color:var(--text-accent); border-color:var(--accent); } +.inspector-field { display:block; margin:9px 0; color:var(--text-muted); font-size:10px; } +.inspector-field input,.inspector-field select,.inspector-field textarea,.workflow-run-panel textarea,.workflow-dialog input,.workflow-dialog select,.workflow-dialog textarea { width:100%; margin-top:4px; padding:7px 8px; color:var(--text-primary); border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--surface-muted); font:12px var(--font-ui); } +.inspector-field textarea { min-height:58px; resize:vertical; } +.inspector-value { padding:7px 0; color:var(--text-secondary); font-size:11px; overflow-wrap:anywhere; } +.inspector-actions { display:flex; gap:7px; flex-wrap:wrap; margin-top:12px; } +.agent-file-card { margin-top:12px; padding:10px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); } +.agent-file-card code { display:block; margin:4px 0 8px; color:var(--text-secondary); overflow-wrap:anywhere; } +.workflow-bottom-grid { display:grid; grid-template-columns:1.4fr 1fr; gap:10px; margin-top:10px; } +.workflow-events,.workflow-run-panel { min-height:145px; padding:11px; } +.workflow-events header,.workflow-run-panel header { display:flex; justify-content:space-between; margin-bottom:8px; color:var(--text-secondary); } +.workflow-events header button { border:0; color:var(--text-accent); background:none; cursor:pointer; } +.workflow-events-list { max-height:110px; overflow:auto; } +.workflow-event { display:grid; grid-template-columns:72px 1fr auto; gap:8px; padding:5px 0; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:10px; } +.workflow-event time,.workflow-event em { color:var(--text-muted); font-style:normal; } +.workflow-event.error { color:var(--status-error); } +.workflow-run-panel textarea { min-height:68px; resize:vertical; } +.workflow-run-panel>div { display:flex; gap:8px; margin-top:8px; } +.workflow-run-error { margin-top:6px; color:var(--status-error); font-size:10px; } +.workflow-dialog-backdrop { position:fixed; inset:0; z-index:1200; display:flex; justify-content:center; align-items:center; padding:24px; background:var(--bg-modal-backdrop); } +.workflow-dialog { width:min(580px,100%); max-height:88vh; overflow:auto; padding:18px; border:1px solid var(--border-accent); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 24px 70px rgba(0,0,0,.45); } +.workflow-dialog h2 { margin-bottom:12px; color:var(--text-accent); } +.workflow-dialog textarea.agent-file-editor { min-height:380px; font-family:var(--font-mono); } +.workflow-dialog-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; } +.hidden { display:none!important; } +@media (max-width:1200px) { .workflow-kpis { grid-template-columns:repeat(3,1fr); }.workflow-main-layout { grid-template-columns:minmax(560px,1fr) 290px; } } diff --git a/src/antigravity_provider/router/web/static/workflow.js b/src/antigravity_provider/router/web/static/workflow.js new file mode 100644 index 0000000..334141a --- /dev/null +++ b/src/antigravity_provider/router/web/static/workflow.js @@ -0,0 +1,497 @@ +/* Hermes Hub A30 workflow canvas — vanilla JS, no build step. */ +'use strict'; + +const workflowUi = { + initialized: false, + mode: 'live', + selectedAgentId: null, + selectedTab: 'main', + scale: 1, + dirty: false, + draftEdges: [], + draftPositions: {}, + connectingFrom: null, + drag: null, +}; + +function wfEscape(value) { + return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ + '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', + })[char]); +} + +function wfValue(value, suffix = '') { + return value === null || value === undefined ? null : `${value}${suffix}`; +} + +function wfUnavailable(elementId, reason) { + const value = document.getElementById(elementId); + const detail = document.getElementById(`${elementId}-reason`); + if (value) value.textContent = 'Н/Д'; + if (detail) detail.textContent = `Н/Д: ${reason}`; +} + +function initWorkflowOverview() { + if (workflowUi.initialized) return; + workflowUi.initialized = true; + document.getElementById('workflow-mode-live')?.addEventListener('click', () => setWorkflowMode('live')); + document.getElementById('workflow-mode-edit')?.addEventListener('click', () => setWorkflowMode('edit')); + document.getElementById('btn-workflow-save')?.addEventListener('click', saveWorkflowDraft); + document.getElementById('btn-agent-add')?.addEventListener('click', openAgentCreateDialog); + document.getElementById('btn-workflow-start')?.addEventListener('click', startWorkflowRun); + document.getElementById('btn-workflow-stop')?.addEventListener('click', () => executeAction('stop_workflow', {})); + document.getElementById('workflow-events-all')?.addEventListener('click', () => switchView('logs')); + document.getElementById('workflow-max-iterations')?.addEventListener('change', markWorkflowDirty); + document.getElementById('workflow-zoom-in')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale + 0.1)); + document.getElementById('workflow-zoom-out')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale - 0.1)); + document.getElementById('workflow-fit')?.addEventListener('click', fitWorkflowGraph); + const canvas = document.getElementById('workflow-canvas'); + canvas?.addEventListener('mousemove', workflowPointerMove); + canvas?.addEventListener('mouseup', workflowPointerUp); + canvas?.addEventListener('mouseleave', workflowPointerCancel); + window.addEventListener('resize', drawWorkflowEdges); +} + +function renderWorkflowOverview(snapshot) { + initWorkflowOverview(); + const workflow = snapshot?.workflow; + if (!workflow) { + renderWorkflowLoading('Backend не вернул поле workflow'); + return; + } + if (workflow.is_loading) { + renderWorkflowLoading('Workflow загружается'); + return; + } + if (workflow.unavailable_reason) { + renderWorkflowLoading(workflow.unavailable_reason); + return; + } + if (!workflowUi.dirty && !workflowUi.drag && !workflowUi.connectingFrom) { + workflowUi.draftEdges = (workflow.definition?.edges || []).map((edge) => ({ ...edge })); + workflowUi.draftPositions = Object.fromEntries((workflow.agents || []).map((agent) => [ + agent.id, { ...(agent.position || { x: 80, y: 80 }) }, + ])); + } + renderWorkflowKpis(snapshot); + renderWorkflowHeader(workflow); + renderWorkflowNodes(workflow); + renderWorkflowInspector(snapshot, workflow); + renderWorkflowEvents(workflow.events || []); + requestAnimationFrame(drawWorkflowEdges); +} + +function renderWorkflowLoading(reason) { + ['workflow-kpi-active', 'workflow-kpi-online', 'workflow-kpi-latency', 'workflow-kpi-tokens', 'workflow-kpi-success'] + .forEach((id) => wfUnavailable(id, reason)); + const empty = document.getElementById('workflow-empty'); + if (empty) { + empty.classList.remove('hidden'); + empty.innerHTML = `Workflow недоступен${wfEscape(reason)}`; + } +} + +function renderWorkflowKpis(snapshot) { + const metrics = snapshot.metrics || {}; + const telemetry = metrics.telemetry || {}; + const global = telemetry.global || {}; + const workflow = snapshot.workflow || {}; + const run = workflow.run || {}; + const active = run.status === 'running' ? 1 : 0; + setWorkflowKpi('workflow-kpi-active', String(active), 'Источник: workflow.run.status'); + + const readiness = snapshot.readiness || {}; + if (readiness.roles_ready_count === null || readiness.roles_ready_count === undefined || readiness.total_roles === undefined) { + wfUnavailable('workflow-kpi-online', 'readiness не содержит число готовых ролей'); + } else { + setWorkflowKpi('workflow-kpi-online', `${readiness.roles_ready_count} / ${readiness.total_roles}`, 'Источник: readiness'); + } + if (!global.total_calls) { + wfUnavailable('workflow-kpi-latency', 'за 24 часа нет измеренных вызовов'); + wfUnavailable('workflow-kpi-success', 'за 24 часа нет завершённых вызовов'); + } else { + const latency = wfValue(global.latency_p50_ms, ' мс'); + latency ? setWorkflowKpi('workflow-kpi-latency', latency, 'Медиана p50, telemetry') : wfUnavailable('workflow-kpi-latency', 'провайдер не вернул задержку'); + const success = global.successful_calls / global.total_calls * 100; + setWorkflowKpi('workflow-kpi-success', `${success.toFixed(1)}%`, `${global.successful_calls} из ${global.total_calls}, telemetry`); + } + if (global.total_tokens === null || global.total_tokens === undefined) { + wfUnavailable('workflow-kpi-tokens', 'провайдеры не вернули usage'); + } else { + setWorkflowKpi('workflow-kpi-tokens', new Intl.NumberFormat('ru-RU').format(global.total_tokens), 'Источник: telemetry usage, 24 ч'); + } +} + +function setWorkflowKpi(id, value, reason) { + const node = document.getElementById(id); + const detail = document.getElementById(`${id}-reason`); + if (node) node.textContent = value; + if (detail) detail.textContent = reason; +} + +function renderWorkflowHeader(workflow) { + const definition = workflow.definition || {}; + const run = workflow.run || {}; + document.getElementById('workflow-title').textContent = definition.name || 'Workflow без названия'; + const state = document.getElementById('workflow-run-state'); + state.textContent = workflowRunLabel(run.status); + state.className = `workflow-run-state ${wfEscape(run.status || 'idle')}`; + document.getElementById('workflow-iteration').textContent = run.iteration || 'Н/Д'; + if (!workflowUi.dirty) document.getElementById('workflow-max-iterations').value = definition.max_iterations || 1; + document.getElementById('workflow-run-error').textContent = run.error || ''; + document.getElementById('btn-workflow-start').disabled = run.status === 'running' || run.status === 'stopping'; + document.getElementById('btn-workflow-stop').disabled = run.status !== 'running'; +} + +function workflowRunLabel(status) { + return ({ idle: 'Нет активного запуска', running: '● Запущен', stopping: 'Останавливается', completed: 'Завершён', failed: 'Ошибка', stopped: 'Остановлен', interrupted: 'Прерван перезапуском' })[status] || `Н/Д: неизвестный статус ${status || 'не указан'}`; +} + +function renderWorkflowNodes(workflow) { + const layer = document.getElementById('workflow-node-layer'); + const canvas = document.getElementById('workflow-canvas'); + const agents = workflow.agents || []; + canvas.classList.toggle('workflow-mode-edit', workflowUi.mode === 'edit'); + document.getElementById('workflow-empty').classList.toggle('hidden', agents.length > 0); + layer.innerHTML = agents.map((agent) => { + const pos = workflowUi.draftPositions[agent.id] || agent.position || { x: 80, y: 80 }; + const cfg = agent.execution_config || {}; + const assignment = cfg.unavailable_reason + ? `Н/Д: ${cfg.unavailable_reason}` + : [cfg.provider, cfg.model, cfg.account].filter(Boolean).join(' · '); + return `
+ +

${wfEscape(agent.name)}

${wfEscape(agent.role)}

${wfEscape(agent.agent_file)}

${wfEscape(assignment)}

+
${wfEscape(runtimeStateLabel(agent.runtime_state))}
+
`; + }).join(''); + layer.style.transform = `scale(${workflowUi.scale})`; + layer.querySelectorAll('.workflow-node').forEach((node) => { + node.addEventListener('click', () => selectWorkflowAgent(node.dataset.agentId)); + node.addEventListener('mousedown', beginNodeDrag); + node.querySelector('.workflow-port.out')?.addEventListener('mousedown', beginConnection); + node.querySelector('.workflow-port.in')?.addEventListener('mouseup', finishConnection); + }); + renderWorkflowMinimap(agents); +} + +function runtimeStateLabel(state) { + return ({ waiting: 'Ожидает', working: 'Работает', reviewing: 'Проверяет', error: 'Ошибка', completed: 'Завершено', not_implemented: 'Исполнение не реализовано' })[state] || `Н/Д: ${state || 'статус не получен'}`; +} + +function setWorkflowMode(mode) { + if (mode === 'edit' && currentSnapshot?.workflow?.run?.status === 'running') { + showToast('EDIT недоступен во время LIVE-выполнения', 'warning'); + return; + } + workflowUi.mode = mode; + document.querySelectorAll('.workflow-mode-btn').forEach((button) => button.classList.toggle('active', button.dataset.mode === mode)); + renderWorkflowOverview(currentSnapshot); +} + +function selectWorkflowAgent(agentId) { + if (workflowUi.drag?.moved) return; + workflowUi.selectedAgentId = agentId; + renderWorkflowOverview(currentSnapshot); +} + +function beginNodeDrag(event) { + if (workflowUi.mode !== 'edit' || event.target.classList.contains('workflow-port')) return; + const node = event.currentTarget; + const position = workflowUi.draftPositions[node.dataset.agentId] || { x: node.offsetLeft, y: node.offsetTop }; + workflowUi.drag = { id: node.dataset.agentId, startX: event.clientX, startY: event.clientY, original: { ...position }, moved: false }; + event.preventDefault(); +} + +function workflowPointerMove(event) { + if (workflowUi.drag) { + const drag = workflowUi.drag; + const dx = (event.clientX - drag.startX) / workflowUi.scale; + const dy = (event.clientY - drag.startY) / workflowUi.scale; + if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true; + workflowUi.draftPositions[drag.id] = { x: Math.max(0, drag.original.x + dx), y: Math.max(0, drag.original.y + dy) }; + const node = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(drag.id)}"]`); + if (node) { + node.style.left = `${workflowUi.draftPositions[drag.id].x}px`; + node.style.top = `${workflowUi.draftPositions[drag.id].y}px`; + } + drawWorkflowEdges(); + } +} + +function workflowPointerUp(event) { + if (workflowUi.drag) { + if (workflowUi.drag.moved) markWorkflowDirty(); + workflowUi.drag = null; + } + if (workflowUi.connectingFrom && event.target === document.getElementById('workflow-canvas')) { + workflowUi.connectingFrom = null; + showToast('Создание связи отменено', 'info'); + } +} + +function workflowPointerCancel() { + if (workflowUi.drag) { + workflowUi.draftPositions[workflowUi.drag.id] = workflowUi.drag.original; + workflowUi.drag = null; + renderWorkflowOverview(currentSnapshot); + } + workflowUi.connectingFrom = null; +} + +function beginConnection(event) { + if (workflowUi.mode !== 'edit') return; + workflowUi.connectingFrom = event.currentTarget.closest('.workflow-node').dataset.agentId; + event.stopPropagation(); + event.preventDefault(); + showToast('Выберите входной порт целевого агента', 'info'); +} + +function finishConnection(event) { + const target = event.currentTarget.closest('.workflow-node').dataset.agentId; + const source = workflowUi.connectingFrom; + workflowUi.connectingFrom = null; + event.stopPropagation(); + if (!source || source === target) return; + openEdgeDialog({ id: `edge-${Date.now()}`, source, target, condition: 'SUCCESS', label: '' }, true); +} + +function drawWorkflowEdges() { + const canvas = document.getElementById('workflow-canvas'); + const svg = document.getElementById('workflow-edges'); + const layer = document.getElementById('workflow-edge-layer'); + if (!canvas || !svg || !layer) return; + svg.setAttribute('viewBox', `0 0 ${canvas.clientWidth} ${canvas.clientHeight}`); + const parts = []; + workflowUi.draftEdges.forEach((edge) => { + const source = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.source)}"]`); + const target = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.target)}"]`); + if (!source || !target) return; + const x1 = (source.offsetLeft + source.offsetWidth) * workflowUi.scale; + const y1 = (source.offsetTop + source.offsetHeight / 2) * workflowUi.scale; + const x2 = target.offsetLeft * workflowUi.scale; + const y2 = (target.offsetTop + target.offsetHeight / 2) * workflowUi.scale; + const bend = Math.max(45, Math.abs(x2 - x1) * .42); + const path = `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`; + const klass = String(edge.condition || '').toLowerCase(); + const labelX = (x1 + x2) / 2; + const labelY = (y1 + y2) / 2 - 5; + parts.push(`${wfEscape(edge.label || edge.condition)}`); + }); + layer.innerHTML = parts.join(''); + layer.querySelectorAll('.workflow-edge-hit').forEach((path) => path.addEventListener('click', () => { + const edge = workflowUi.draftEdges.find((item) => item.id === path.dataset.edgeId); + if (workflowUi.mode === 'edit' && edge) openEdgeDialog(edge, false); + })); +} + +function renderWorkflowMinimap(agents) { + const minimap = document.getElementById('workflow-minimap'); + if (!minimap) return; + const maxX = Math.max(900, ...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 200)); + const maxY = Math.max(500, ...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 100)); + minimap.innerHTML = agents.map((agent) => { + const pos = workflowUi.draftPositions[agent.id] || { x: 0, y: 0 }; + return ``; + }).join(''); +} + +function setWorkflowScale(value) { + workflowUi.scale = Math.max(.5, Math.min(1.6, Math.round(value * 10) / 10)); + document.getElementById('workflow-zoom-value').textContent = `${Math.round(workflowUi.scale * 100)}%`; + renderWorkflowOverview(currentSnapshot); +} + +function fitWorkflowGraph() { + const agents = currentSnapshot?.workflow?.agents || []; + const maxX = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 210), 600); + const maxY = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 110), 400); + const canvas = document.getElementById('workflow-canvas'); + setWorkflowScale(Math.min(canvas.clientWidth / maxX, canvas.clientHeight / maxY, 1)); +} + +function markWorkflowDirty() { + workflowUi.dirty = true; + const state = document.getElementById('workflow-save-state'); + state.textContent = 'Есть несохранённые изменения'; + state.classList.add('dirty'); +} + +async function saveWorkflowDraft() { + const definition = currentSnapshot?.workflow?.definition || {}; + const result = await executeAction('save_workflow', { + id: definition.id, + name: definition.name, + start_agent_id: definition.start_agent_id, + escalation_agent_id: definition.escalation_agent_id, + max_iterations: Number(document.getElementById('workflow-max-iterations').value), + edges: workflowUi.draftEdges, + agents: Object.entries(workflowUi.draftPositions).map(([id, position]) => ({ id, position })), + }); + if (result.ok) { + workflowUi.dirty = false; + const state = document.getElementById('workflow-save-state'); + state.textContent = 'Сохранено'; + state.classList.remove('dirty'); + } +} + +function renderWorkflowInspector(snapshot, workflow) { + const box = document.getElementById('workflow-inspector'); + const agent = (workflow.agents || []).find((item) => item.id === workflowUi.selectedAgentId); + if (!agent) { + box.innerHTML = '
INSPECTORВыберите агента на графе
'; + return; + } + const cfg = agent.execution_config || {}; + const tabs = [['main', 'Основное'], ['model', 'Модель'], ['instructions', 'Инструкции'], ['tools', 'Инструменты'], ['memory', 'Память'], ['history', 'История']]; + box.innerHTML = `

INSPECTOR: ${wfEscape(agent.name)}

${tabs.map(([id, label]) => ``).join('')}
`; + box.querySelectorAll('.inspector-tabs button').forEach((button) => button.addEventListener('click', () => { + workflowUi.selectedTab = button.dataset.tab; + renderWorkflowInspector(snapshot, workflow); + })); + const content = document.getElementById('inspector-tab-content'); + if (workflowUi.selectedTab === 'main') { + content.innerHTML = `
`; + document.getElementById('agent-save-main').onclick = () => executeAction('update_agent', { agent_id: agent.id, name: document.getElementById('agent-edit-name').value, description: document.getElementById('agent-edit-description').value }); + document.getElementById('agent-delete').onclick = () => deleteWorkflowAgent(agent); + } else if (workflowUi.selectedTab === 'model') { + renderAgentModelTab(content, snapshot, agent); + } else if (workflowUi.selectedTab === 'instructions') { + content.innerHTML = `
Agent File${wfEscape(agent.agent_file)}${agent.agent_file_exists ? 'Файл существует' : 'Н/Д: файл отсутствует'}
`; + document.getElementById('agent-file-open').onclick = () => openAgentFileEditor(agent); + } else if (workflowUi.selectedTab === 'tools') { + content.innerHTML = ``; + document.getElementById('agent-tools-save').onclick = () => executeAction('update_agent', { agent_id: agent.id, tools: document.getElementById('agent-tools').value.split(',').map((v) => v.trim()).filter(Boolean) }); + } else if (workflowUi.selectedTab === 'memory') { + content.innerHTML = `
${Object.keys(agent.memory_configuration || {}).length ? `
${wfEscape(JSON.stringify(agent.memory_configuration, null, 2))}
` : 'Н/Д: конфигурация памяти не задана'}
`; + } else { + const history = (workflow.events || []).filter((event) => event.agent_id === agent.id); + content.innerHTML = history.length ? history.slice(-20).reverse().map((event) => `
${wfEscape(event.message)}${event.duration_seconds == null ? '' : `${event.duration_seconds} с`}
`).join('') : '
Н/Д: у агента ещё нет запусков
'; + } +} + +function renderAgentModelTab(content, snapshot, agent) { + const profiles = Object.values(snapshot.all_profiles || {}).filter((profile) => isConnectedProfile(profile)); + const cfg = agent.execution_config || {}; + const providers = [...new Set(profiles.map((profile) => profile.provider))]; + content.innerHTML = ``; + const providerSelect = document.getElementById('agent-provider'); + const accountSelect = document.getElementById('agent-account'); + const modelSelect = document.getElementById('agent-model'); + const refreshAccounts = () => { + const matches = profiles.filter((profile) => profile.provider === providerSelect.value); + accountSelect.innerHTML = matches.length ? matches.map((profile) => ``).join('') : ''; + refreshModels(); + }; + const refreshModels = () => { + const profile = profiles.find((item) => item.profile_id === accountSelect.value); + const models = profile?.preferred_models || Object.values(profile?.model_states || {}).map((state) => state.display_name).filter(Boolean); + modelSelect.innerHTML = models.length ? models.map((model) => ``).join('') : ''; + }; + providerSelect.onchange = refreshAccounts; + accountSelect.onchange = refreshModels; + refreshAccounts(); + document.getElementById('agent-model-save').onclick = () => executeAction('update_agent', { + agent_id: agent.id, provider: providerSelect.value, profile_id: accountSelect.value, model: modelSelect.value, + temperature: nullableNumber('agent-temperature'), max_tokens: nullableNumber('agent-max-tokens'), timeout: nullableNumber('agent-timeout'), + }); +} + +function nullableNumber(id) { + const value = document.getElementById(id).value.trim(); + return value === '' ? null : Number(value); +} + +async function deleteWorkflowAgent(agent) { + let result = await executeAction('delete_agent', { agent_id: agent.id }); + if (result.ok && result.data?.confirmation_required) { + const refs = result.data.consequences?.workflow_edges || []; + if (!confirm(`Агент участвует в маршруте и/или графе. Будут удалены связи: ${refs.length ? refs.join(', ') : 'нет'}. Продолжить?`)) return; + result = await executeAction('delete_agent', { agent_id: agent.id, force: true }); + } + if (result.ok && result.data?.deleted) workflowUi.selectedAgentId = null; +} + +function openAgentCreateDialog() { + const profiles = Object.values(currentSnapshot?.all_profiles || {}).filter((profile) => isConnectedProfile(profile)); + openWorkflowDialog('Добавить агента', ``, async (close) => { + const account = document.getElementById('new-agent-account').value; + const profile = profiles.find((item) => item.profile_id === account); + const result = await executeAction('create_agent', { + name: document.getElementById('new-agent-name').value, + role: document.getElementById('new-agent-role').value, + description: document.getElementById('new-agent-description').value, + account, + model: profile?.preferred_models?.[0] || null, + agent_file: document.getElementById('new-agent-file').value, + }); + if (result.ok) close(); + }); +} + +async function openAgentFileEditor(agent) { + const result = await executeAction('read_agent_file', { agent_id: agent.id }); + if (!result.ok) return; + const data = result.data || {}; + if (!data.exists) { + showToast(`Н/Д: ${data.reason}`, 'warning'); + return; + } + openWorkflowDialog(`Agent File — ${agent.name}`, `

Нет изменений

`, async (close) => { + const saved = await executeAction('save_agent_file', { agent_id: agent.id, content: document.getElementById('agent-file-content').value }); + if (saved.ok) close(); + }, 'Сохранить'); + const editor = document.getElementById('agent-file-content'); + editor.addEventListener('input', () => { document.getElementById('agent-file-unsaved').textContent = 'Есть несохранённые изменения'; }); +} + +function openEdgeDialog(edge, isNew) { + openWorkflowDialog(isNew ? 'Новое ребро' : 'Редактор ребра', `${isNew ? '' : ''}`, (close) => { + const updated = { ...edge, condition: document.getElementById('edge-condition').value, label: document.getElementById('edge-label').value }; + const index = workflowUi.draftEdges.findIndex((item) => item.id === edge.id); + if (index >= 0) workflowUi.draftEdges[index] = updated; else workflowUi.draftEdges.push(updated); + markWorkflowDirty(); close(); drawWorkflowEdges(); + }); + if (!isNew) document.getElementById('edge-delete').onclick = () => { + workflowUi.draftEdges = workflowUi.draftEdges.filter((item) => item.id !== edge.id); + markWorkflowDirty(); document.querySelector('.workflow-dialog-backdrop')?.remove(); drawWorkflowEdges(); + }; +} + +function openWorkflowDialog(title, body, onSave, saveLabel = 'Применить') { + document.querySelector('.workflow-dialog-backdrop')?.remove(); + const backdrop = document.createElement('div'); + backdrop.className = 'workflow-dialog-backdrop'; + backdrop.innerHTML = `

${wfEscape(title)}

${body}
`; + document.body.appendChild(backdrop); + const close = () => backdrop.remove(); + backdrop.querySelector('[data-dialog-cancel]').onclick = close; + backdrop.querySelector('[data-dialog-save]').onclick = () => onSave(close); + backdrop.addEventListener('click', (event) => { if (event.target === backdrop) close(); }); +} + +async function startWorkflowRun() { + if (workflowUi.dirty) { + showToast('Сначала сохраните изменения графа', 'warning'); + return; + } + const task = document.getElementById('workflow-task').value.trim(); + await executeAction('start_workflow', { task }); + setWorkflowMode('live'); +} + +function renderWorkflowEvents(events) { + const box = document.getElementById('workflow-events-list'); + if (!events.length) { + box.innerHTML = '

Н/Д: workflow ещё не создавал событий

'; + return; + } + box.innerHTML = events.slice(-20).reverse().map((event) => `
${wfEscape(event.message)}${event.error ? ` — ${wfEscape(event.error)}` : ''}${wfEscape(event.type)}
`).join(''); +} + +function formatWorkflowTime(value) { + if (!value) return 'Н/Д'; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString('ru-RU'); +} diff --git a/src/antigravity_provider/router/workflow_service.py b/src/antigravity_provider/router/workflow_service.py new file mode 100644 index 0000000..25deca1 --- /dev/null +++ b/src/antigravity_provider/router/workflow_service.py @@ -0,0 +1,785 @@ +"""Persistent agents, workflow graph and live execution for Hermes Hub. + +The router role registry remains the source of truth for logical agents and +Provider -> Account -> Model assignment. This module adds the pieces that do +not fit the routing schema: Agent Files, editor layout, workflow transitions, +execution checkpoints and a bounded event journal. +""" +from __future__ import annotations + +import json +import re +import threading +import time +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Optional + +from antigravity_provider import paths +from antigravity_provider.router.router_config import RolePolicy, load_router_config, save_router_config + + +AGENT_STATES = {"waiting", "working", "reviewing", "error", "completed", "not_implemented"} +EDGE_CONDITIONS = {"SUCCESS", "REVIEW_PASSED", "REVIEW_FAILED", "NEXT", "ERROR", "ALWAYS"} + + +def _utc_timestamp() -> str: + import datetime + + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +def _slug(value: str) -> str: + result = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-") + return result or f"agent-{uuid.uuid4().hex[:8]}" + + +def sanitize_run_data(node: Any) -> Any: + """Recursively strip or mask any credentials or secret keys from workflow run state.""" + secret_key_substrings = [ + "api_key", "token", "password", "secret", "jwt", "bearer", + "access_token", "refresh_token", "client_secret", "authorization", + ] + if isinstance(node, dict): + sanitized: dict[str, Any] = {} + for k, v in node.items(): + k_lower = str(k).lower() + if any(s in k_lower for s in secret_key_substrings) and k_lower not in ("auth_status", "author", "auth_required"): + sanitized[k] = "***" + else: + sanitized[k] = sanitize_run_data(v) + return sanitized + elif isinstance(node, list): + return [sanitize_run_data(x) for x in node] + elif isinstance(node, str): + val = node + val = re.sub(r'Bearer\s+[a-zA-Z0-9_\-\.]{8,}', 'Bearer ***', val, flags=re.IGNORECASE) + val = re.sub(r'sk-[a-zA-Z0-9_\-]{8,}', 'sk-***', val) + val = re.sub(r'gho_[a-zA-Z0-9_\-]{8,}', 'gho_***', val) + val = re.sub(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', r'\g<1>***', val, flags=re.IGNORECASE) + return val + return node + + +def get_last_run_state(run_state_path: Optional[Path] = None) -> Optional[dict[str, Any]]: + """Return the last saved workflow run state from workflow_run_state.json, if any.""" + p = run_state_path or paths.get_workflow_run_state_path() + if not p.is_file(): + return None + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, dict): + return sanitize_run_data(data) + except Exception: + pass + return None + + +def _safe_agent_file(value: str, agent_id: str) -> tuple[Path, str]: + """Resolve an Agent File below HERMES_HOME/agents and reject traversal.""" + root = paths.get_agent_files_dir().resolve() + candidate_name = Path(value or f"{agent_id}.md").name + if not candidate_name.lower().endswith(".md"): + candidate_name += ".md" + target = (root / candidate_name).resolve() + if target.parent != root: + raise ValueError("Agent File должен находиться в каталоге agents") + return target, f"agents/{candidate_name}" + + +@dataclass +class AgentDefinition: + id: str + name: str + role: str + description: str = "" + agent_file: str = "" + tools: list[str] = field(default_factory=list) + memory_configuration: dict[str, Any] = field(default_factory=dict) + execution_policy: dict[str, Any] = field(default_factory=dict) + timeout: int = 180 + temperature: Optional[float] = None + max_tokens: Optional[int] = None + position: dict[str, float] = field(default_factory=lambda: {"x": 80.0, "y": 80.0}) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class WorkflowEdge: + id: str + source: str + target: str + condition: str = "SUCCESS" + label: str = "" + + +@dataclass +class WorkflowDefinition: + id: str = "main" + name: str = "Основной workflow" + edges: list[WorkflowEdge] = field(default_factory=list) + max_iterations: int = 5 + escalation_agent_id: Optional[str] = None + start_agent_id: Optional[str] = None + + +@dataclass +class WorkflowEvent: + timestamp: str + type: str + message: str + level: str = "info" + run_id: Optional[str] = None + agent_id: Optional[str] = None + iteration: Optional[int] = None + provider: Optional[str] = None + account: Optional[str] = None + model: Optional[str] = None + duration_seconds: Optional[float] = None + error: Optional[str] = None + + +class WorkflowService: + _instance: Optional["WorkflowService"] = None + _instance_lock = threading.Lock() + + def __init__(self, state_path: Optional[Path] = None, run_state_path: Optional[Path] = None) -> None: + self.state_path = state_path or paths.get_workflow_state_path() + self.run_state_path = run_state_path or paths.get_workflow_run_state_path() + self._lock = threading.RLock() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self.agents: dict[str, AgentDefinition] = {} + self.workflow = WorkflowDefinition() + self.events: list[WorkflowEvent] = [] + self.run: dict[str, Any] = self._idle_run() + self._completed_steps: list[dict[str, Any]] = [] + self._load() + self._migrate_router_roles() + + @classmethod + def get(cls) -> "WorkflowService": + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @staticmethod + def _idle_run() -> dict[str, Any]: + return { + "id": None, + "status": "idle", + "current_agent_id": None, + "current_task": None, + "iteration": 0, + "started_at": None, + "finished_at": None, + "elapsed_seconds": None, + "last_result": None, + "error": None, + "agent_states": {}, + } + + def _load(self) -> None: + # 1. Load run state from workflow_run_state.json + if self.run_state_path.is_file(): + try: + run_state = json.loads(self.run_state_path.read_text(encoding="utf-8")) + if isinstance(run_state, dict): + self._completed_steps = list(run_state.get("completed_steps", [])) + if run_state.get("status") in {"RUNNING", "running", "STOPPING", "stopping"}: + run_state["status"] = "INTERRUPTED" + run_state["interruption_reason"] = "Прогон был прерван перезапуском сервера или сбоем процесса" + run_state["updated_at"] = _utc_timestamp() + sanitized = sanitize_run_data(run_state) + self.run_state_path.parent.mkdir(parents=True, exist_ok=True) + temp = self.run_state_path.with_suffix(".tmp") + temp.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8") + temp.replace(self.run_state_path) + except Exception: + self._completed_steps = [] + + # 2. Load workflow definition and events from workflow_state.json + if not self.state_path.is_file(): + return + try: + raw = json.loads(self.state_path.read_text(encoding="utf-8")) + self.agents = { + item["id"]: AgentDefinition(**item) + for item in raw.get("agents", []) + if isinstance(item, dict) and item.get("id") + } + wf = raw.get("workflow") or {} + edges = [WorkflowEdge(**edge) for edge in wf.pop("edges", []) if isinstance(edge, dict)] + self.workflow = WorkflowDefinition(edges=edges, **wf) + self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]] + self.run = raw.get("run") or self._idle_run() + if self.run.get("status") in {"running", "stopping"}: + self.run["status"] = "interrupted" + self.run["error"] = "Выполнение прервано перезапуском Hermes Hub; checkpoint сохранён" + self._event("WORKFLOW_INTERRUPTED", self.run["error"], level="warning") + except (OSError, ValueError, TypeError): + self.agents = {} + self.workflow = WorkflowDefinition() + self.events = [] + self.run = self._idle_run() + + def _save_run_state( + self, + status: str, + step_index: int = 0, + current_agent: Optional[str] = None, + iteration: int = 1, + completed_step: Optional[dict[str, Any]] = None, + interruption_reason: Optional[str] = None, + ) -> None: + if completed_step: + self._completed_steps.append(sanitize_run_data(completed_step)) + + state_payload = { + "run_id": self.run.get("id"), + "status": status.upper(), + "started_at": self.run.get("started_at"), + "updated_at": _utc_timestamp(), + "current_step_index": step_index, + "current_agent_id": current_agent, + "iteration_count": iteration, + "completed_steps": list(self._completed_steps), + "interruption_reason": interruption_reason, + } + sanitized = sanitize_run_data(state_payload) + try: + self.run_state_path.parent.mkdir(parents=True, exist_ok=True) + temp = self.run_state_path.with_suffix(".tmp") + temp.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8") + temp.replace(self.run_state_path) + except Exception: + pass + + def get_last_run_state(self) -> Optional[dict[str, Any]]: + """Return the last saved run state from workflow_run_state.json.""" + return get_last_run_state(self.run_state_path) + + def _save(self) -> None: + self.state_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema_version": 1, + "agents": [asdict(agent) for agent in self.agents.values()], + "workflow": asdict(self.workflow), + "events": [asdict(event) for event in self.events[-200:]], + "run": self.run, + } + temp = self.state_path.with_suffix(".tmp") + temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + temp.replace(self.state_path) + + def _migrate_router_roles(self) -> None: + config = load_router_config() + changed = False + for index, (role_id, policy) in enumerate(config.roles.items()): + if role_id in self.agents: + continue + target, relative = _safe_agent_file("", role_id) + name = role_id.replace("-", " ").title() + description = "" + try: + from antigravity_provider.router.role_registry import get_role_definition + + definition = get_role_definition(role_id) + name = getattr(definition, "name", None) or getattr(definition, "display_name", None) or name + description = getattr(definition, "description", "") + except (ImportError, AttributeError, TypeError): + pass + agent = AgentDefinition( + id=role_id, + name=name, + role=role_id, + description=description, + agent_file=relative, + position={"x": 70.0 + (index % 2) * 280.0, "y": 45.0 + (index // 2) * 125.0}, + ) + self.agents[role_id] = agent + self._ensure_file(target, agent) + changed = True + if changed: + self._save() + + @staticmethod + def _ensure_file(target: Path, agent: AgentDefinition) -> None: + if not target.exists(): + body = f"# {agent.name}\n\n## Роль\n\n{agent.role}\n\n## Назначение\n\n{agent.description or 'Инструкции ещё не заполнены.'}\n" + target.write_text(body, encoding="utf-8") + + def _execution_config(self, agent: AgentDefinition) -> dict[str, Any]: + config = load_router_config() + policy = config.roles.get(agent.role) + profile_id = policy.preferred_chain[0] if policy and policy.preferred_chain else None + profile = config.profiles.get(profile_id) if profile_id else None + model = policy.default_model if policy else None + if not model and profile and profile.preferred_models: + model = profile.preferred_models[0] + return { + "provider": profile.provider if profile else None, + "account": profile_id, + "model": model, + "timeout": agent.timeout, + "temperature": agent.temperature, + "max_tokens": agent.max_tokens, + "unavailable_reason": None if profile else "Для роли не назначен доступный аккаунт", + } + + def snapshot(self) -> dict[str, Any]: + with self._lock: + agents = [] + for agent in self.agents.values(): + item = asdict(agent) + target, relative = _safe_agent_file(agent.agent_file, agent.id) + item["agent_file"] = relative + item["agent_file_exists"] = target.is_file() + item["execution_config"] = self._execution_config(agent) + item["runtime_state"] = (self.run.get("agent_states") or {}).get(agent.id, "waiting") + agents.append(item) + return { + "agents": agents, + "definition": asdict(self.workflow), + "run": dict(self.run), + "events": [asdict(event) for event in self.events[-60:]], + "is_loading": False, + } + + def read_agent_file(self, agent_id: str) -> dict[str, Any]: + with self._lock: + agent = self._require_agent(agent_id) + target, relative = _safe_agent_file(agent.agent_file, agent.id) + if not target.is_file(): + return {"path": relative, "exists": False, "content": None, "reason": "Файл не найден на диске"} + return {"path": relative, "exists": True, "content": target.read_text(encoding="utf-8")} + + def save_agent_file(self, agent_id: str, content: str) -> dict[str, Any]: + with self._lock: + agent = self._require_agent(agent_id) + target, relative = _safe_agent_file(agent.agent_file, agent.id) + temporary = target.with_suffix(".md.tmp") + temporary.write_text(str(content), encoding="utf-8") + temporary.replace(target) + self._event("AGENT_FILE_SAVED", f"Сохранён Agent File {relative}", agent_id=agent_id) + self._save() + return {"path": relative, "exists": True} + + def create_agent(self, data: dict[str, Any]) -> AgentDefinition: + name = str(data.get("name") or "").strip() + role = _slug(str(data.get("role") or name)) + agent_id = _slug(str(data.get("id") or role)) + if not name: + raise ValueError("Укажите название агента") + with self._lock: + if agent_id in self.agents: + raise ValueError("Агент с таким идентификатором уже существует") + profile_id = str(data.get("account") or data.get("profile_id") or "").strip() + config = load_router_config() + if profile_id and profile_id not in config.profiles: + raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе") + config.roles[role] = RolePolicy( + role_name=role, + preferred_chain=[profile_id] if profile_id else [], + fallback_capabilities=list(data.get("fallback_capabilities") or [role]), + default_model=data.get("model") or None, + ) + if not save_router_config(config): + raise OSError("Не удалось сохранить назначение агента") + target, relative = _safe_agent_file(str(data.get("agent_file") or ""), agent_id) + agent = AgentDefinition( + id=agent_id, + name=name, + role=role, + description=str(data.get("description") or ""), + agent_file=relative, + tools=[str(item) for item in data.get("tools", [])], + memory_configuration=dict(data.get("memory_configuration") or {}), + execution_policy=dict(data.get("execution_policy") or {}), + timeout=max(1, int(data.get("timeout") or 180)), + temperature=float(data["temperature"]) if data.get("temperature") is not None else None, + max_tokens=int(data["max_tokens"]) if data.get("max_tokens") is not None else None, + position=dict(data.get("position") or {"x": 80.0, "y": 80.0}), + ) + self.agents[agent_id] = agent + copy_from = data.get("copy_from") + if copy_from: + source = self.read_agent_file(str(copy_from)) + target.write_text(source.get("content") or "", encoding="utf-8") + else: + self._ensure_file(target, agent) + self._event("AGENT_CREATED", f"Создан агент «{name}»", agent_id=agent_id) + self._save() + return agent + + def update_agent(self, agent_id: str, data: dict[str, Any]) -> AgentDefinition: + with self._lock: + agent = self._require_agent(agent_id) + config = load_router_config() + policy = config.roles.get(agent.role) + if not policy: + policy = RolePolicy(role_name=agent.role) + config.roles[agent.role] = policy + profile_id = str(data.get("account") or data.get("profile_id") or "").strip() + if profile_id: + profile = config.profiles.get(profile_id) + if not profile: + raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе") + requested_provider = str(data.get("provider") or "").strip() + if requested_provider and profile.provider != requested_provider: + raise ValueError("Аккаунт не принадлежит выбранному провайдеру") + policy.preferred_chain = [profile_id] + [item for item in policy.preferred_chain if item != profile_id] + if "model" in data: + model = str(data.get("model") or "").strip() or None + if model and profile_id: + profile = config.profiles[profile_id] + if model not in profile.preferred_models: + raise ValueError("Модель не доступна выбранному аккаунту") + policy.default_model = model + if not save_router_config(config): + raise OSError("Не удалось сохранить назначение агента") + for attr in ("name", "description"): + if attr in data: + setattr(agent, attr, str(data[attr]).strip()) + for attr in ("tools", "memory_configuration", "execution_policy", "position"): + if attr in data: + setattr(agent, attr, type(getattr(agent, attr))(data[attr])) + for attr in ("timeout", "max_tokens"): + if attr in data and data[attr] is not None: + setattr(agent, attr, int(data[attr])) + if "temperature" in data: + agent.temperature = float(data["temperature"]) if data["temperature"] is not None else None + self._event("AGENT_UPDATED", f"Обновлён агент «{agent.name}»", agent_id=agent_id) + self._save() + return agent + + def delete_agent(self, agent_id: str, force: bool = False) -> dict[str, Any]: + with self._lock: + agent = self._require_agent(agent_id) + edge_ids = [edge.id for edge in self.workflow.edges if edge.source == agent_id or edge.target == agent_id] + route_used = bool(load_router_config().roles.get(agent.role)) + consequences = {"workflow_edges": edge_ids, "routing_role": agent.role if route_used else None} + if (edge_ids or route_used) and not force: + return {"deleted": False, "confirmation_required": True, "consequences": consequences} + config = load_router_config() + config.roles.pop(agent.role, None) + if not save_router_config(config): + raise OSError("Не удалось удалить роль из маршрутизатора") + self.workflow.edges = [edge for edge in self.workflow.edges if edge.id not in edge_ids] + self.agents.pop(agent_id) + self._event("AGENT_DELETED", f"Удалён агент «{agent.name}»", agent_id=agent_id, level="warning") + self._save() + return {"deleted": True, "consequences": consequences} + + def save_workflow(self, data: dict[str, Any]) -> WorkflowDefinition: + with self._lock: + if self.run.get("status") in {"running", "stopping"}: + raise ValueError("Нельзя менять граф в режиме LIVE во время выполнения") + edges: list[WorkflowEdge] = [] + seen: set[str] = set() + for raw in data.get("edges", []): + source, target = str(raw.get("source") or ""), str(raw.get("target") or "") + condition = str(raw.get("condition") or "SUCCESS").upper() + if source not in self.agents or target not in self.agents: + raise ValueError("Ребро ссылается на отсутствующего агента") + if condition not in EDGE_CONDITIONS: + raise ValueError(f"Неизвестное условие перехода: {condition}") + edge_id = str(raw.get("id") or f"edge-{uuid.uuid4().hex[:10]}") + if edge_id in seen: + raise ValueError("Идентификаторы рёбер должны быть уникальны") + seen.add(edge_id) + edges.append(WorkflowEdge(edge_id, source, target, condition, str(raw.get("label") or ""))) + max_iterations = int(data.get("max_iterations") or self.workflow.max_iterations) + if not 1 <= max_iterations <= 100: + raise ValueError("Предел итераций должен быть от 1 до 100") + for raw_agent in data.get("agents", []): + agent = self.agents.get(str(raw_agent.get("id") or "")) + pos = raw_agent.get("position") + if agent and isinstance(pos, dict): + agent.position = {"x": float(pos.get("x", 0)), "y": float(pos.get("y", 0))} + self.workflow = WorkflowDefinition( + id=str(data.get("id") or self.workflow.id), + name=str(data.get("name") or self.workflow.name), + edges=edges, + max_iterations=max_iterations, + escalation_agent_id=data.get("escalation_agent_id") or None, + start_agent_id=data.get("start_agent_id") or None, + ) + self._event("WORKFLOW_SAVED", f"Сохранён workflow «{self.workflow.name}»") + self._save() + return self.workflow + + def start(self, task: str) -> dict[str, Any]: + task = str(task or "").strip() + if not task: + raise ValueError("Для запуска укажите реальную задачу") + with self._lock: + if self._thread and self._thread.is_alive(): + raise ValueError("Workflow уже выполняется") + start_id = self.workflow.start_agent_id or (next(iter(self.agents), None)) + if not start_id or start_id not in self.agents: + raise ValueError("В workflow нет стартового агента") + self._stop.clear() + self._completed_steps = [] + self.run = self._idle_run() + self.run.update({ + "id": uuid.uuid4().hex, + "status": "running", + "current_agent_id": start_id, + "current_task": task, + "iteration": 1, + "started_at": _utc_timestamp(), + }) + self._event("WORKFLOW_STARTED", "Workflow запущен", run_id=self.run["id"], iteration=1) + self._save() + self._save_run_state("RUNNING", step_index=0, current_agent=start_id, iteration=1) + self._thread = threading.Thread(target=self._execute, name="HermesWorkflow", daemon=True) + self._thread.start() + return dict(self.run) + + def stop(self) -> dict[str, Any]: + with self._lock: + if self.run.get("status") != "running": + raise ValueError("Нет выполняющегося workflow") + self.run["status"] = "stopping" + self._stop.set() + self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning") + self._save() + self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=self.run.get("current_agent_id"), iteration=self.run.get("iteration", 1), interruption_reason="Остановлено пользователем") + return dict(self.run) + + def _execute(self) -> None: + from antigravity_provider.router.router_engine import get_router_engine + + started = time.monotonic() + context = str(self.run.get("current_task") or "") + current = str(self.run.get("current_agent_id") or "") + visited: dict[str, int] = {} + try: + engine = get_router_engine() + engine.reload_config() + while current and not self._stop.is_set(): + with self._lock: + agent = self._require_agent(current) + visited[current] = visited.get(current, 0) + 1 + iteration = max(visited.values()) + self.run.update({"current_agent_id": current, "iteration": iteration}) + step_idx = len(self._completed_steps) + if iteration > self.workflow.max_iterations: + message = f"Достигнут предел итераций: {self.workflow.max_iterations}" + self.run.update({"status": "failed", "error": message}) + self._event("WORKFLOW_MAX_ITERATIONS", message, level="error", agent_id=current, iteration=iteration) + self._save_run_state("FAILED", step_index=step_idx, current_agent=current, iteration=iteration, interruption_reason=message) + break + file_data = self.read_agent_file(current) + if not file_data["exists"]: + raise FileNotFoundError(f"{file_data['path']}: {file_data['reason']}") + self.run.setdefault("agent_states", {})[current] = ( + "reviewing" if "review" in agent.role.lower() else "working" + ) + self._event("AGENT_STARTED", f"{agent.name} начал выполнение", agent_id=current, iteration=iteration) + self._save() + self._save_run_state("RUNNING", step_index=step_idx, current_agent=current, iteration=iteration) + request = { + "model": self._execution_config(agent).get("model"), + "messages": [ + {"role": "system", "content": file_data["content"]}, + {"role": "user", "content": context}, + ], + "timeout": agent.timeout, + "metadata": {"role": agent.role, "workflow_run_id": self.run["id"]}, + } + if agent.temperature is not None: + request["temperature"] = agent.temperature + if agent.max_tokens is not None: + request["max_tokens"] = agent.max_tokens + step_started = time.monotonic() + response = engine.route_request(request, role=agent.role, session_id=self.run["id"]) + duration = round(time.monotonic() - step_started, 3) + text = self._response_text(response) + status = self._result_status(response, text) + metadata = response.get("router_metadata", {}) if isinstance(response, dict) else {} + with self._lock: + self.run["last_result"] = {"status": status, "content": text, "router_metadata": metadata} + self.run.setdefault("agent_states", {})[current] = ( + "error" if status in {"ERROR", "REVIEW_FAILED"} else "completed" + ) + self._event( + "AGENT_COMPLETED", + f"{agent.name}: {status}", + level="success" if status not in {"ERROR", "REVIEW_FAILED"} else "warning", + agent_id=current, + iteration=iteration, + provider=metadata.get("provider"), + account=metadata.get("profile_id"), + model=metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"), + duration_seconds=duration, + error=text if status == "ERROR" else None, + ) + step_summary = { + "step_index": step_idx, + "agent_id": current, + "agent_name": agent.name, + "iteration": iteration, + "status": status, + "duration_seconds": duration, + "provider": metadata.get("provider"), + "account": metadata.get("profile_id"), + "model": metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"), + "error": text if status in {"ERROR", "REVIEW_FAILED"} else None, + "timestamp": _utc_timestamp(), + } + edge = next( + (item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}), + None, + ) + if not edge and status not in {"ERROR", "REVIEW_FAILED"}: + edge = next( + (item for item in self.workflow.edges if item.source == current and item.condition in {"SUCCESS", "NEXT"}), + None, + ) + if not edge: + self.run["status"] = "failed" if status in {"ERROR", "REVIEW_FAILED"} else "completed" + if status == "ERROR": + self.run["error"] = text or "Провайдер вернул ERROR без текста" + self._event( + "WORKFLOW_FAILED" if self.run["status"] == "failed" else "WORKFLOW_COMPLETED", + f"Workflow завершён со статусом {status}", + level="error" if self.run["status"] == "failed" else "success", + agent_id=current, + iteration=iteration, + error=self.run.get("error") if self.run["status"] == "failed" else None, + ) + if status == "ERROR": + try: + from antigravity_provider.router.unified_health import EventLogService + + EventLogService.get().log( + "workflow", + f"Ошибка агента «{agent.name}»", + details=self.run["error"], + level="error", + ) + except Exception: + pass + self._save_run_state( + "FAILED" if self.run["status"] == "failed" else "COMPLETED", + step_index=step_idx + 1, + current_agent=current, + iteration=iteration, + completed_step=step_summary, + interruption_reason=self.run.get("error") if self.run["status"] == "failed" else None, + ) + break + self._event( + "WORKFLOW_TRANSITION", + f"Переход {current} → {edge.target}: {edge.condition}", + agent_id=current, + iteration=iteration, + ) + context = json.dumps({ + "original_task": self.run["current_task"], + "previous_agent": current, + "structured_result": {"status": status, "content": text}, + }, ensure_ascii=False) + current = edge.target + self._save() + self._save_run_state( + "RUNNING", + step_index=step_idx + 1, + current_agent=current, + iteration=iteration, + completed_step=step_summary, + ) + with self._lock: + if self._stop.is_set(): + self.run.update({"status": "stopped", "error": "Остановлено пользователем"}) + self._event("WORKFLOW_STOPPED", "Workflow остановлен пользователем", level="warning") + self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=current, iteration=iteration, interruption_reason="Остановлено пользователем") + except Exception as exc: + with self._lock: + self.run.update({"status": "failed", "error": str(exc)}) + self._event("PROVIDER_ERROR", "Ошибка выполнения workflow", level="error", agent_id=current, error=str(exc)) + try: + from antigravity_provider.router.unified_health import EventLogService + + EventLogService.get().log("workflow", "Ошибка выполнения workflow", details=str(exc), level="error") + except Exception: + pass + self._save_run_state("FAILED", step_index=len(self._completed_steps), current_agent=current, iteration=visited.get(current, 1), interruption_reason=str(exc)) + finally: + with self._lock: + self.run["finished_at"] = _utc_timestamp() + self.run["elapsed_seconds"] = round(time.monotonic() - started, 3) + self.run["current_agent_id"] = current or self.run.get("current_agent_id") + self._save() + + + @staticmethod + def _response_text(response: Any) -> str: + if not isinstance(response, dict): + return str(response) + choices = response.get("choices") + if isinstance(choices, list) and choices: + message = choices[0].get("message", {}) + return str(message.get("content") or choices[0].get("text") or "") + return str(response.get("content") or response.get("text") or response.get("output") or "") + + @staticmethod + def _result_status(response: Any, text: str) -> str: + if isinstance(response, dict): + explicit = response.get("status") or response.get("structured_status") + if explicit and str(explicit).upper() in EDGE_CONDITIONS: + return str(explicit).upper() + for status in ("REVIEW_FAILED", "REVIEW_PASSED", "SUCCESS", "ERROR"): + if re.search(rf"\b{status}\b", text.upper()): + return status + return "SUCCESS" + + def _event(self, event_type: str, message: str, **kwargs: Any) -> None: + self.events.append(WorkflowEvent(_utc_timestamp(), event_type, message, **kwargs)) + self.events = self.events[-200:] + + def _require_agent(self, agent_id: str) -> AgentDefinition: + agent = self.agents.get(agent_id) + if not agent: + raise ValueError("Агент не найден") + return agent + + +def execute_workflow_action(action: str, data: dict[str, Any]) -> dict[str, Any]: + """Execute a workflow action through the shared ActionExecutor layer.""" + service = WorkflowService.get() + if action == "create_agent": + result = asdict(service.create_agent(data)) + elif action == "update_agent": + result = asdict(service.update_agent(str(data.get("agent_id") or ""), data)) + elif action == "delete_agent": + result = service.delete_agent(str(data.get("agent_id") or ""), bool(data.get("force"))) + elif action == "read_agent_file": + result = service.read_agent_file(str(data.get("agent_id") or "")) + elif action == "save_agent_file": + result = service.save_agent_file(str(data.get("agent_id") or ""), str(data.get("content") or "")) + elif action == "save_workflow": + result = asdict(service.save_workflow(data)) + elif action == "start_workflow": + result = service.start(str(data.get("task") or "")) + elif action == "stop_workflow": + result = service.stop() + else: + raise ValueError("Неизвестное действие workflow") + if action in {"create_agent", "update_agent", "delete_agent"}: + try: + from antigravity_provider.router.state_store import HubStateStore + + HubStateStore.get().refresh(force_scan=False) + except Exception: + pass + return {"ok": True, "message": "Выполнено", "data": result} + + +# Alias for execution service compatibility +WorkflowExecutionService = WorkflowService diff --git a/tests/conftest.py b/tests/conftest.py index 8e913bb..7d13727 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,12 @@ Enforces: 1. Zero modification to real user credentials or router_profiles.yaml. 2. Complete filesystem sandboxing in temporary directory via HERMES_HOME. 3. Offline execution for default test runs (network / live require explicit -m markers). +4. No accidental connects to local llama.cpp (8081/8082) or non-loopback hosts. """ from __future__ import annotations import os +import socket import sys from pathlib import Path import pytest @@ -16,6 +18,9 @@ REPO_SRC = Path(__file__).resolve().parent.parent / "src" if str(REPO_SRC) not in sys.path or sys.path[0] != str(REPO_SRC): sys.path.insert(0, str(REPO_SRC)) +BLOCKED_INFERENCE_PORTS = {8081, 8082} +LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "::"} + @pytest.fixture(autouse=True) def isolate_hermes_environment(tmp_path, monkeypatch): @@ -35,6 +40,62 @@ def isolate_hermes_environment(tmp_path, monkeypatch): yield temp_hermes +def _socket_host_port(address) -> tuple[str | None, int | None]: + if isinstance(address, tuple) and len(address) >= 2: + host = address[0] + port = address[1] + if isinstance(host, bytes): + host = host.decode("utf-8", errors="replace") + try: + return str(host), int(port) + except (TypeError, ValueError): + return str(host), None + return None, None + + +def _reject_hermetic_connect(address) -> None: + host, port = _socket_host_port(address) + if port in BLOCKED_INFERENCE_PORTS: + raise RuntimeError( + f"hermetic tests must not contact local inference at {host}:{port}; " + "mock check_local_servers / urllib.request.urlopen" + ) + if host and host not in LOOPBACK_HOSTS and not host.startswith("127."): + raise RuntimeError( + f"hermetic tests must not open network connections to {host}:{port}; " + "mark the test live/network or mock the call" + ) + + +@pytest.fixture(autouse=True) +def block_external_network_in_hermetic_tests(request, monkeypatch): + """Fail fast instead of hanging on llama.cpp or cloud APIs.""" + if request.node.get_closest_marker("live") or request.node.get_closest_marker("network"): + yield + return + + real_connect = socket.socket.connect + real_connect_ex = socket.socket.connect_ex + real_create_connection = socket.create_connection + + def guarded_connect(self, address): + _reject_hermetic_connect(address) + return real_connect(self, address) + + def guarded_connect_ex(self, address): + _reject_hermetic_connect(address) + return real_connect_ex(self, address) + + def guarded_create_connection(address, *args, **kwargs): + _reject_hermetic_connect(address) + return real_create_connection(address, *args, **kwargs) + + monkeypatch.setattr(socket.socket, "connect", guarded_connect) + monkeypatch.setattr(socket.socket, "connect_ex", guarded_connect_ex) + monkeypatch.setattr(socket, "create_connection", guarded_create_connection) + yield + + def pytest_configure(config): config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical environment") diff --git a/tests/test_a31_preflight_state_batching_pii.py b/tests/test_a31_preflight_state_batching_pii.py new file mode 100644 index 0000000..02e1b85 --- /dev/null +++ b/tests/test_a31_preflight_state_batching_pii.py @@ -0,0 +1,435 @@ +"""Tests for Task A31: Preflight Dependency Agent, Workflow Run State, Local Concurrency & Context Window, PII Masking, and Cost Controller Honesty. +""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from antigravity_provider import paths +from antigravity_provider.router.action_handler import ActionExecutor +from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter +from antigravity_provider.router.preflight_service import PreflightCheckService, PreflightItem, PreflightReport +from antigravity_provider.router.role_registry import CANONICAL_ROLES, RoleRegistry +from antigravity_provider.router.router_config import ( + RouterConfig, + RouterProfileConfig, + get_default_router_config, + load_router_config, +) +from antigravity_provider.router.settings_service import ( + DEFAULT_SETTINGS, + get_hub_settings, + invalidate_settings_cache, + save_hub_settings, +) +from antigravity_provider.router.telemetry_service import ( + TelemetryAggregates, + TelemetryRecord, + TelemetryService, + format_token_count, +) +from antigravity_provider.router.web.server import sanitize_snapshot +from antigravity_provider.router.workflow_service import ( + AgentDefinition, + WorkflowDefinition, + WorkflowExecutionService, + WorkflowService, + get_last_run_state, + sanitize_run_data, +) + + +# ============================================================================ +# P0-1: Preflight Dependency Agent +# ============================================================================ + + +def test_dependency_agent_role_registered(): + """Verify 13th role 'dependency-agent' and its canonical aliases in RoleRegistry.""" + assert len(CANONICAL_ROLES) == 13 + assert "dependency-agent" in CANONICAL_ROLES + + role_def = CANONICAL_ROLES["dependency-agent"] + assert role_def.role_id == "dependency-agent" + assert role_def.display_name_ru == "Проверяющий готовность" + assert role_def.short_name_ru == "Готовность" + assert role_def.is_implemented is True + assert "preflight" in role_def.capabilities + + # Test alias resolution + aliases = [ + "dependency-agent", + "dependency_agent", + "preflight", + "проверяющий готовность", + "агент зависимостей", + "готовность", + "dependency", + ] + for alias in aliases: + canonical = RoleRegistry.resolve_role_name(alias) + assert canonical == "dependency-agent", f"Alias '{alias}' resolved to '{canonical}'" + + +def test_preflight_service_cli_and_environment(): + """Verify CLI tools and environment checks with controlled discovery.""" + service = PreflightCheckService.get() + + with patch("antigravity_provider.router.preflight_service.shutil.which", return_value="/opt/agy"), \ + patch("antigravity_provider.router.preflight_service.importlib.util.find_spec", return_value=object()): + cli_items = service.check_cli_dependencies() + assert len(cli_items) >= 3 + ids = {item.check_id for item in cli_items} + assert "cli_agy" in ids + assert "pkg_fastapi" in ids + assert "pkg_uvicorn" in ids + assert all(item.status == "PASS" for item in cli_items) + + env_items = service.check_system_environment() + assert len(env_items) >= 3 + env_ids = {item.check_id for item in env_items} + assert "env_hermes_home" in env_ids + assert "env_config_writable" in env_ids + assert "env_logs_writable" in env_ids + + +def _fake_preflight_items() -> list: + return [ + PreflightItem(check_id="cli_agy", name="CLI", status="PASS", message="mocked"), + PreflightItem(check_id="env_hermes_home", name="HOME", status="PASS", message="mocked"), + PreflightItem(check_id="auth_local-1", name="AUTH", status="WARN", message="mocked"), + PreflightItem(check_id="local_srv_local-1", name="LLM", status="PASS", message="mocked"), + ] + + +def test_preflight_service_run_all_and_action(): + """Verify run_all_checks orchestration without probing this machine.""" + service = PreflightCheckService.get() + cli, env, auth, local = ( + [_fake_preflight_items()[0]], + [_fake_preflight_items()[1]], + [_fake_preflight_items()[2]], + [_fake_preflight_items()[3]], + ) + + with patch.object(service, "check_cli_dependencies", return_value=cli) as mock_cli, \ + patch.object(service, "check_system_environment", return_value=env) as mock_env, \ + patch.object(service, "check_auth_credentials", return_value=auth) as mock_auth, \ + patch.object(service, "check_local_servers", return_value=local) as mock_local, \ + patch("antigravity_provider.router.preflight_service.urllib.request.urlopen") as mock_urlopen: + report = service.run_all_checks() + action_res = ActionExecutor.execute("run_preflight", {}) + + mock_cli.assert_called() + mock_env.assert_called() + mock_auth.assert_called() + mock_local.assert_called() + mock_urlopen.assert_not_called() + + assert isinstance(report, PreflightReport) + assert report.passed_count == 3 + assert report.warn_count == 1 + assert report.failed_count == 0 + assert len(report.checks) == 4 + report_dict = report.to_dict() + assert "success" in report_dict + assert "checks" in report_dict + assert isinstance(report_dict["checks"], list) + + assert "ok" in action_res + assert "message" in action_res + assert "data" in action_res + assert "checks" in action_res["data"] + assert mock_urlopen.call_count == 0 + + +@pytest.mark.live +def test_preflight_live_local_servers(): + """Optional live probe of configured local servers. Not part of hermetic pytest.""" + service = PreflightCheckService.get() + items = service.check_local_servers() + assert isinstance(items, list) + assert items + + +# ============================================================================ +# P0-2: Workflow Run State Manager +# ============================================================================ + + +def test_workflow_run_state_sanitization(): + """Verify recursive secret stripping in workflow run state.""" + raw_state = { + "run_id": "test-run-123", + "status": "RUNNING", + "api_key": "sk-1234567890abcdef", + "token": "gho_secrettoken123456", + "nested": { + "password": "supersecretpass", + "auth_status": "ok", + "message": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0", + "extra_url": "https://example.com/callback?access_token=secret12345&foo=bar", + }, + "step_list": [ + {"account": "acc-1", "client_secret": "my-client-secret-123"}, + {"safe_field": "public_data"}, + ], + } + + sanitized = sanitize_run_data(raw_state) + + assert sanitized["api_key"] == "***" + assert sanitized["token"] == "***" + assert sanitized["nested"]["password"] == "***" + assert sanitized["nested"]["auth_status"] == "ok" + assert "Bearer ***" in sanitized["nested"]["message"] + assert "access_token=***" in sanitized["nested"]["extra_url"] + assert sanitized["step_list"][0]["client_secret"] == "***" + assert sanitized["step_list"][1]["safe_field"] == "public_data" + + +def test_workflow_run_state_interrupted_on_startup(tmp_path: Path): + """Verify that a RUNNING state in workflow_run_state.json transitions to INTERRUPTED on reload.""" + state_file = tmp_path / "workflow_state.json" + run_state_file = tmp_path / "workflow_run_state.json" + + # Pre-populate run state with RUNNING status + initial_run_state = { + "run_id": "run-crash-test", + "status": "RUNNING", + "started_at": "2026-08-26T00:00:00Z", + "updated_at": "2026-08-26T00:00:00Z", + "current_step_index": 2, + "current_agent_id": "developer-1", + "iteration_count": 1, + "completed_steps": [ + {"step_index": 0, "agent_id": "manager", "status": "SUCCESS"}, + {"step_index": 1, "agent_id": "developer-1", "status": "WORKING"}, + ], + "interruption_reason": None, + } + run_state_file.write_text(json.dumps(initial_run_state), encoding="utf-8") + + # Initialize WorkflowService + service = WorkflowService(state_path=state_file, run_state_path=run_state_file) + + # Check that state transitioned to INTERRUPTED + last_state = service.get_last_run_state() + assert last_state is not None + assert last_state["status"] == "INTERRUPTED" + assert last_state["interruption_reason"] == "Прогон был прерван перезапуском сервера или сбоем процесса" + assert len(last_state["completed_steps"]) == 2 + + # Verify top-level function + assert get_last_run_state(run_state_file)["status"] == "INTERRUPTED" + + +def test_workflow_execution_service_alias(): + """Verify WorkflowExecutionService is an alias of WorkflowService.""" + assert WorkflowExecutionService is WorkflowService + + +# ============================================================================ +# P0-3: Local Concurrency & Context Window +# ============================================================================ + + +def test_local_profile_max_concurrency_is_one(): + """Verify all local provider profiles have max_concurrency = 1.""" + config = get_default_router_config() + for pid, pcfg in config.profiles.items(): + if pcfg.provider == "local": + assert pcfg.max_concurrency == 1, f"Local profile {pid} has max_concurrency={pcfg.max_concurrency}" + + # Verify loaded config also enforces max_concurrency = 1 for local profiles + loaded = load_router_config() + for pid, pcfg in loaded.profiles.items(): + if pcfg.provider == "local": + assert pcfg.max_concurrency == 1 + + +def test_local_adapter_get_context_window(): + """Verify LocalLLMAdapter retrieves context window accurately without hallucinating defaults.""" + adapter = LocalLLMAdapter() + + # Profile with explicit context_window in auth_config + prof_with_cfg = RouterProfileConfig( + profile_id="local-test-1", + provider="local", + account_id="acc-1", + auth_config={"context_window": 8192}, + ) + assert adapter.get_context_window(prof_with_cfg) == 8192 + + # Profile without context length and with non-responding server + prof_empty = RouterProfileConfig( + profile_id="local-test-2", + provider="local", + account_id="acc-2", + custom_base_url="http://127.0.0.1:9999/v1", + ) + # Must return None instead of inventing fake numbers + assert adapter.get_context_window(prof_empty) is None + + +def test_local_adapter_context_truncation_guard(): + """Verify context truncation guard protects against VRAM overflow when context_window is known.""" + adapter = LocalLLMAdapter() + + prof = RouterProfileConfig( + profile_id="local-small-ctx", + provider="local", + account_id="acc-1", + auth_config={"context_window": 500}, + custom_base_url="http://127.0.0.1:12345/v1", + ) + + # Huge prompt exceeding 500 tokens + long_middle_content = "important historical dialogue step " * 100 + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Initial prompt 1"}, + {"role": "assistant", "content": long_middle_content}, + {"role": "user", "content": "Initial prompt 2"}, + {"role": "assistant", "content": long_middle_content}, + {"role": "user", "content": "Latest user task to execute."}, + ] + + mock_resp = { + "choices": [{"message": {"role": "assistant", "content": "Truncated prompt executed successfully."}}], + "usage": {"prompt_tokens": 200, "completion_tokens": 10}, + } + + with patch("urllib.request.urlopen") as mock_urlopen: + mock_cm = MagicMock() + mock_cm.read.return_value = json.dumps(mock_resp).encode("utf-8") + mock_urlopen.return_value.__enter__.return_value = mock_cm + + res = adapter.invoke(prof, {"messages": messages, "max_tokens": 100}) + assert res["choices"][0]["message"]["content"] == "Truncated prompt executed successfully." + + # Verify sent payload messages were truncated + args, kwargs = mock_urlopen.call_args + sent_req = args[0] + sent_body = json.loads(sent_req.data.decode("utf-8")) + sent_messages = sent_body["messages"] + + assert sent_messages[0]["role"] == "system" + assert sent_messages[-1]["content"] == "Latest user task to execute." + # Total count of messages should be pruned + assert len(sent_messages) < len(messages) + + +# ============================================================================ +# P0-4: PII Email Masking +# ============================================================================ + + +def test_settings_email_masking_mode(): + """Verify email_masking_mode in default settings and persistence.""" + assert DEFAULT_SETTINGS["email_masking_mode"] == "none" + + settings = get_hub_settings() + assert settings.get("email_masking_mode") in ("none", "partial", "full") + + +def test_sanitize_snapshot_email_masking_modes(): + """Verify email masking behavior across 'none', 'partial', and 'full' modes.""" + snapshot_data = { + "user_email": "vasya.pupkin@example.com", + "account_id": "google-user-1", + "api_key": "sk-secret123456789", + "nested": { + "developer": "developer.one@domain.org", + "reviewer": "r@test.com", + }, + } + + # 1. Mode: none (emails unchanged, secrets masked) + san_none = sanitize_snapshot(snapshot_data, email_masking_mode="none") + assert san_none["user_email"] == "vasya.pupkin@example.com" + assert san_none["nested"]["developer"] == "developer.one@domain.org" + assert san_none["nested"]["reviewer"] == "r@test.com" + assert "api_key" not in san_none + + # 2. Mode: partial (preserves first and last char of local part + domain for differentiation) + san_partial = sanitize_snapshot(snapshot_data, email_masking_mode="partial") + assert san_partial["user_email"] == "v***n@example.com" + assert san_partial["nested"]["developer"] == "d***e@domain.org" + assert san_partial["nested"]["reviewer"] == "r***@test.com" + assert "api_key" not in san_partial + + # 3. Mode: full (***@***.***) + san_full = sanitize_snapshot(snapshot_data, email_masking_mode="full") + assert san_full["user_email"] == "***@***.***" + assert san_full["nested"]["developer"] == "***@***.***" + assert san_full["nested"]["reviewer"] == "***@***.***" + assert "api_key" not in san_full + + +# ============================================================================ +# P0-5: Cost Controller Token Honesty +# ============================================================================ + + +def test_telemetry_measured_vs_estimated_tokens(tmp_path: Path): + """Verify telemetry distinguishes measured exact tokens from estimated tokens with ~.""" + log_file = tmp_path / "telemetry_test.jsonl" + service = TelemetryService(log_path=log_file) + + # 1. Record measured call + rec1 = service.record_call( + role="developer-1", + profile_id="ag-w1", + provider="antigravity", + model="claude-3-7-sonnet", + outcome="success", + latency_seconds=1.25, + prompt_tokens_measured=500, + completion_tokens_measured=150, + is_estimated=False, + ) + assert rec1.prompt_tokens_measured == 500 + assert rec1.prompt_tokens_estimated is None + assert rec1.is_estimated is False + assert rec1.total_tokens == 650 + + # 2. Record estimated call + rec2 = service.record_call( + role="tester", + profile_id="local-1", + provider="local", + model="Qwen3.8-27B-Q4_K_M.gguf", + outcome="success", + latency_seconds=0.85, + prompt_tokens_estimated=300, + completion_tokens_estimated=50, + is_estimated=True, + ) + assert rec2.prompt_tokens_measured is None + assert rec2.prompt_tokens_estimated == 300 + assert rec2.is_estimated is True + assert rec2.total_tokens == 350 + + # 3. Aggregates for measured only + agg_measured = service.get_aggregates(profile_id="ag-w1") + assert agg_measured.total_tokens_measured == 650 + assert agg_measured.tokens_display == "650" + assert agg_measured.has_estimated_tokens is False + + # 4. Aggregates for estimated only + agg_est = service.get_aggregates(profile_id="local-1") + assert agg_est.total_tokens_estimated == 350 + assert agg_est.tokens_display == "~350" + assert agg_est.has_estimated_tokens is True + + +def test_format_token_count(): + """Verify format_token_count formatting helper.""" + assert format_token_count(1250, None) == "1250" + assert format_token_count(None, 1250) == "~1250" + assert format_token_count(1000, 250) == "1000" + assert format_token_count(None, None) is None diff --git a/tests/test_a9_migration_quotas_models.py b/tests/test_a9_migration_quotas_models.py index 178bc11..d8e4a34 100644 --- a/tests/test_a9_migration_quotas_models.py +++ b/tests/test_a9_migration_quotas_models.py @@ -62,7 +62,7 @@ class TestA9ConfigMigration(unittest.TestCase): legacy_cfg = RouterConfig( enabled=True, - default_role="orchestrator", + default_role="manager", roles=get_default_router_config().roles, profiles=legacy_profiles, ) @@ -316,9 +316,9 @@ class TestA9HermesPluginBoundary(unittest.TestCase): def test_resolve_role_respects_explicit_or_metadata_role(self): from antigravity_provider.router import get_router_engine engine = get_router_engine() - self.assertEqual(engine.resolve_role({}, explicit_role="coder-primary"), "coder-primary") - self.assertEqual(engine.resolve_role({"role": "reviewer"}), "reviewer") - self.assertEqual(engine.resolve_role({"metadata": {"role": "research"}}), "research") + self.assertEqual(engine.resolve_role({}, explicit_role="developer-1"), "developer-1") + self.assertEqual(engine.resolve_role({"role": "code-reviewer"}), "code-reviewer") + self.assertEqual(engine.resolve_role({"metadata": {"role": "researcher"}}), "researcher") class TestA9CodexOAuthTokenRefreshAndSwitching(unittest.TestCase): diff --git a/tests/test_accounts_distribution_and_thresholds_a26.py b/tests/test_accounts_distribution_and_thresholds_a26.py index 98a30ff..16de469 100644 --- a/tests/test_accounts_distribution_and_thresholds_a26.py +++ b/tests/test_accounts_distribution_and_thresholds_a26.py @@ -89,7 +89,8 @@ def test_single_account_assigned_to_all_six_roles_p0_3(): """P0-3: A single account can be assigned as primary across all 6 canonical roles without error.""" ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}}) - canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] + from antigravity_provider.router.role_registry import RoleRegistry + canonical_roles = list(RoleRegistry.get_role_ids()) for rname in canonical_roles: ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", rname, is_primary=True) @@ -101,7 +102,7 @@ def test_single_account_assigned_to_all_six_roles_p0_3(): assert cfg.roles[rname].preferred_chain[0] == "ag-w1" # Verify action executor handles assign_role cleanly - res = ActionExecutor.execute("assign_role", {"role_id": "orchestrator", "profile_id": "ag-w1", "is_primary": True}) + res = ActionExecutor.execute("assign_role", {"role_id": "manager", "profile_id": "ag-w1", "is_primary": True}) assert res.get("ok") is True @@ -115,9 +116,11 @@ def test_auto_assign_all_single_account_p0_4(): assert result["total_authenticated"] == 1 cfg = load_router_config() - canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] + from antigravity_provider.router.role_registry import RoleRegistry + canonical_roles = list(RoleRegistry.get_role_ids()) for rname in canonical_roles: - assert cfg.roles[rname].preferred_chain == ["ag-w1"] + if RoleRegistry.is_role_implemented(rname): + assert cfg.roles[rname].preferred_chain == ["ag-w1"] @pytest.mark.unit @@ -132,11 +135,11 @@ def test_auto_assign_all_two_accounts_same_provider_p0_4(): cfg = load_router_config() # Primary accounts should alternate between ag-w1 and ag-w2 - primaries = [cfg.roles[r].preferred_chain[0] for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]] + primaries = [cfg.roles[r].preferred_chain[0] for r in ["manager", "developer-1", "developer-2", "code-reviewer", "researcher", "tester"]] assert "ag-w1" in primaries assert "ag-w2" in primaries # Fallback chains should contain the alternate account - for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]: + for r in ["manager", "developer-1", "developer-2", "code-reviewer", "researcher", "tester"]: chain = cfg.roles[r].preferred_chain assert len(chain) == 2 assert set(chain) == {"ag-w1", "ag-w2"} @@ -155,10 +158,10 @@ def test_auto_assign_all_multi_provider_preferences_p0_4(): cfg = load_router_config() # Orchestrator prefers codex - assert cfg.roles["orchestrator"].preferred_chain[0] == "codex-orch" + assert cfg.roles["manager"].preferred_chain[0] == "codex-orch" # Research / fast prefers opencode - assert cfg.roles["research"].preferred_chain[0] == "opengo-1" - assert cfg.roles["fast"].preferred_chain[0] == "opengo-1" + assert cfg.roles["researcher"].preferred_chain[0] == "opengo-1" + assert cfg.roles["tester"].preferred_chain[0] == "opengo-1" @pytest.mark.unit diff --git a/tests/test_agy_native_login.py b/tests/test_agy_native_login.py index 15f3299..9abe070 100644 --- a/tests/test_agy_native_login.py +++ b/tests/test_agy_native_login.py @@ -123,8 +123,9 @@ def test_launch_native_agy_login_env_isolation(tmp_path, monkeypatch): mock_popen.assert_called_once() args, kwargs = mock_popen.call_args - # Command is agy executable - assert args[0][0] == "C:\\fake\\agy.exe" + # Command includes the mocked agy executable (Linux wraps it in a terminal). + launched = args[0] + assert "C:\\fake\\agy.exe" in launched # Environment points to profile dir env = kwargs.get("env", {}) diff --git a/tests/test_credential_isolation.py b/tests/test_credential_isolation.py index 3900278..aec385b 100644 --- a/tests/test_credential_isolation.py +++ b/tests/test_credential_isolation.py @@ -104,6 +104,11 @@ def test_antigravity_adapter_subprocess_env_isolation(tmp_path, monkeypatch): return mock_res adapter = AntigravityAdapter() + fake_agy = tmp_path / "agy.exe" + fake_agy.write_bytes(b"") + import antigravity_provider.agy_subprocess as agy_sub + monkeypatch.setattr(agy_sub, "_agy_exe_cache", None) + monkeypatch.setenv("AGY_EXE_PATH", str(fake_agy)) with patch("subprocess.run", side_effect=mock_subprocess_run): res = adapter.invoke(profile, {"messages": [{"role": "user", "content": "hi"}]}) @@ -164,8 +169,8 @@ def test_provider_selection_explanation_and_candidate_matrix(tmp_path, monkeypat "ag-healthy": RouterProfileConfig(profile_id="ag-healthy", provider="antigravity", enabled=True, preferred_models=["gemini-2.5-pro"]), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-disabled", "ag-unauth", "ag-healthy"], max_failover_attempts=3, ) @@ -188,7 +193,7 @@ def test_provider_selection_explanation_and_candidate_matrix(tmp_path, monkeypat with patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke): - res = engine.route_request({"messages": [{"role": "user", "content": "explain"}]}, role="orchestrator") + res = engine.route_request({"messages": [{"role": "user", "content": "explain"}]}, role="manager") assert "router_metadata" in res meta = res["router_metadata"] @@ -227,8 +232,8 @@ def test_failover_exhaustion_includes_selection_trace(tmp_path, monkeypatch): "ag-w1": RouterProfileConfig(profile_id="ag-w1", provider="antigravity", enabled=True, preferred_models=["gemini-2.5-pro"]), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-w1"], max_failover_attempts=1, ) @@ -246,7 +251,7 @@ def test_failover_exhaustion_includes_selection_trace(tmp_path, monkeypatch): with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": True, "auth_state": "AUTHENTICATED"}), \ patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke_fail): - res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator") + res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager") assert res.get("router_error") is True assert "selection_trace" in res diff --git a/tests/test_dashboard_data.py b/tests/test_dashboard_data.py index f316926..1a64b35 100644 --- a/tests/test_dashboard_data.py +++ b/tests/test_dashboard_data.py @@ -69,7 +69,7 @@ def test_provider_call_share_calculation(clean_services): # 2. Record 45 antigravity calls, 35 codex calls, 20 opencode calls (Total 100) for _ in range(45): ts.record_call( - role="orchestrator", + role="manager", profile_id="ag-orch", provider="antigravity", model="gemini-2.5-pro", @@ -81,7 +81,7 @@ def test_provider_call_share_calculation(clean_services): for _ in range(35): ts.record_call( - role="coder-primary", + role="developer-1", profile_id="codex-w1", provider="openai-codex", model="gpt-4o", @@ -93,7 +93,7 @@ def test_provider_call_share_calculation(clean_services): for _ in range(20): ts.record_call( - role="fast", + role="tester", profile_id="opengo-1", provider="opencode-go", model="deepseek-v4-flash", @@ -122,9 +122,9 @@ def test_provider_call_share_calculation(clean_services): assert breakdown["by_provider"]["antigravity"]["call_share"] == 0.45 assert breakdown["by_provider"]["openai-codex"]["call_share"] == 0.35 assert breakdown["by_provider"]["opencode-go"]["call_share"] == 0.20 - assert breakdown["by_role"]["orchestrator"]["total_calls"] == 45 - assert breakdown["by_role"]["coder-primary"]["total_calls"] == 35 - assert breakdown["by_role"]["fast"]["total_calls"] == 20 + assert breakdown["by_role"]["manager"]["total_calls"] == 45 + assert breakdown["by_role"]["developer-1"]["total_calls"] == 35 + assert breakdown["by_role"]["tester"]["total_calls"] == 20 @pytest.mark.unit @@ -211,8 +211,8 @@ def test_active_calls_and_hub_snapshot_integration(clean_services, tmp_path, mon ), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-w1"], ) } @@ -227,7 +227,7 @@ def test_active_calls_and_hub_snapshot_integration(clean_services, tmp_path, mon # Record 1 call ts.record_call( - role="orchestrator", + role="manager", profile_id="ag-w1", provider="antigravity", model="gemini-2.5-pro", diff --git a/tests/test_health_auth_recovery.py b/tests/test_health_auth_recovery.py index 18c1e87..8f2a1f2 100644 --- a/tests/test_health_auth_recovery.py +++ b/tests/test_health_auth_recovery.py @@ -34,7 +34,7 @@ def isolated_env(tmp_path, monkeypatch): def test_auth_required_skipped_in_routing(isolated_env): tmp_path, ht = isolated_env - # Configure 2 profiles for role 'orchestrator' + # Configure 2 profiles for role 'manager' cfg = RouterConfig( profiles={ "ag-orch-1": RouterProfileConfig( @@ -49,12 +49,12 @@ def test_auth_required_skipped_in_routing(isolated_env): ), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-orch-1", "ag-orch-2"], ), }, - default_role="orchestrator", + default_role="manager", ) engine = RouterEngine(config=cfg, health=ht) @@ -64,7 +64,7 @@ def test_auth_required_skipped_in_routing(isolated_env): # Initial state: ag-orch-1 is healthy and selected with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke): - res = engine.route_request({"prompt": "hi"}, role="orchestrator") + res = engine.route_request({"prompt": "hi"}, role="manager") assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-1" # Mark ag-orch-1 as AUTH_REQUIRED @@ -73,7 +73,7 @@ def test_auth_required_skipped_in_routing(isolated_env): # Routing should skip ag-orch-1 and route to ag-orch-2 with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke): - res = engine.route_request({"prompt": "hi"}, role="orchestrator") + res = engine.route_request({"prompt": "hi"}, role="manager") assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-2" @@ -94,12 +94,12 @@ def test_save_profile_auth_auto_recovery(isolated_env): ), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-orch-1", "ag-orch-2"], ), }, - default_role="orchestrator", + default_role="manager", ) engine = RouterEngine(config=cfg, health=ht) @@ -128,7 +128,7 @@ def test_save_profile_auth_auto_recovery(isolated_env): # Router now routes back to primary profile ag-orch-1 with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke): - res = engine.route_request({"prompt": "hi"}, role="orchestrator") + res = engine.route_request({"prompt": "hi"}, role="manager") assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-1" diff --git a/tests/test_import_invariants.py b/tests/test_import_invariants.py index bcc1ddf..20849aa 100644 --- a/tests/test_import_invariants.py +++ b/tests/test_import_invariants.py @@ -56,6 +56,12 @@ def _missing_external(exc: ImportError) -> str | None: root = name.split(".")[0] if root in OPTIONAL_EXTERNAL_MODULES: return root + msg = str(exc) + for mod in sorted(OPTIONAL_EXTERNAL_MODULES, key=len, reverse=True): + if f"No module named '{mod}'" in msg or f'No module named "{mod}"' in msg: + return mod + if f"No module named {mod}" in msg: + return mod return None @@ -102,7 +108,7 @@ def test_gui_test_modules_guard_optional_ui_dependency() -> None: offenders: list[str] = [] for path in sorted(TESTS_DIR.glob("test_*.py")): - text = path.read_text(encoding="utf-8", errors="ignore") + text = path.read_text(encoding="utf-8-sig", errors="ignore") try: tree = ast.parse(text) except SyntaxError: diff --git a/tests/test_in_app_updates_a27.py b/tests/test_in_app_updates_a27.py index 3f09cbc..814cc27 100644 --- a/tests/test_in_app_updates_a27.py +++ b/tests/test_in_app_updates_a27.py @@ -218,26 +218,24 @@ def test_action_executor_update_actions(monkeypatch, tmp_path): assert "data" in res assert res["data"]["update_available"] is False - # 2. check_updates ВСЕГДА синхронна и всегда возвращает данные. - # - # Здесь раньше требовалось обратное — чтобы действие уходило в фон. Это - # закрепляло дефект: веб-сервер передаёт async_runner всегда, фоновая ветка - # отвечала «Проверка обновлений запущена» без data, и результат до - # интерфейса не доходил вовсе. Проверено запросом: кнопка обновления не - # могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10 - # секунд, ждать её допустимо; в фон уходит только установка. + # check_updates remains synchronous even when async_runner is provided. + dispatched = [] + + def mock_runner(fn, name): + dispatched.append(name) + + res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner) + assert res_async["ok"] is True + assert "CheckUpdates" not in dispatched, "проверка обновлений не должна уходить в фон без данных" + assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления" + mock_chk.assert_called() + + # 3. apply_update async dispatched = [] + def mock_runner(fn, name): dispatched.append(name) - res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner) - assert res_async["ok"] is True - assert "CheckUpdates" not in dispatched, "проверка обновлений не должна уходить в фон без данных" - # Значение здесь не проверяем: вызов вне заглушки и ходит в сеть по-настоящему. - # Важно ровно одно — данные пришли, а не пустой ответ «запущено». - assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления" - - # 3. apply_update async res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner) assert res_apply_async["ok"] is True assert "ApplyUpdate" in dispatched diff --git a/tests/test_local_llm_provider_a25.py b/tests/test_local_llm_provider_a25.py index 78d2631..088de7e 100644 --- a/tests/test_local_llm_provider_a25.py +++ b/tests/test_local_llm_provider_a25.py @@ -254,14 +254,14 @@ class TestLocalLLMConfigAndAutoAssigner: p1 = cfg.profiles["local-1"] assert p1.provider == "local" - assert "reviewer" in p1.capabilities + assert "code-reviewer" in p1.capabilities assert "coding" in p1.capabilities assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models assert p1.max_concurrency == 1 p2 = cfg.profiles["local-2"] assert p2.provider == "local" - assert "fast" in p2.capabilities + assert "tester" in p2.capabilities assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models assert p2.max_concurrency == 1 @@ -277,7 +277,7 @@ class TestLocalLLMConfigAndAutoAssigner: } legacy_cfg = RouterConfig( enabled=True, - default_role="orchestrator", + default_role="manager", roles=get_default_router_config().roles, profiles=legacy_profiles, ) diff --git a/tests/test_model_choice_and_discovery.py b/tests/test_model_choice_and_discovery.py index 60a0aff..cd64601 100644 --- a/tests/test_model_choice_and_discovery.py +++ b/tests/test_model_choice_and_discovery.py @@ -54,7 +54,7 @@ def test_set_model_success_and_config_persistence(temp_models_cache): res = ActionExecutor.execute("set_model", { "profile_id": profile_id, "model": target_model, - "role_id": "coder-primary", + "role_id": "developer-1", }) assert res["ok"] is True @@ -63,7 +63,7 @@ def test_set_model_success_and_config_persistence(temp_models_cache): # Verify persistence on disk reloaded = load_router_config() assert reloaded.profiles[profile_id].preferred_models[0] == target_model - assert reloaded.roles["coder-primary"].default_model == target_model + assert reloaded.roles["developer-1"].default_model == target_model def test_set_model_rejects_nonexistent_model(temp_models_cache): diff --git a/tests/test_model_validation_a23.py b/tests/test_model_validation_a23.py index 03693cc..f915e54 100644 --- a/tests/test_model_validation_a23.py +++ b/tests/test_model_validation_a23.py @@ -45,12 +45,12 @@ def isolated_hub(tmp_path, monkeypatch): ), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-orch-1"], ), }, - default_role="orchestrator", + default_role="manager", ) save_router_config(cfg) return tmp_path diff --git a/tests/test_multi_provider_router.py b/tests/test_multi_provider_router.py index 373c560..720eddf 100644 --- a/tests/test_multi_provider_router.py +++ b/tests/test_multi_provider_router.py @@ -77,18 +77,26 @@ class TestRouterConfig: def test_role_policies_chains(self): config = get_default_router_config() - assert "orchestrator" in config.roles - orch = config.roles["orchestrator"] - assert orch.preferred_chain == ["codex-orch", "ag-orch-fallback", "opengo-3"] + assert "manager" in config.roles + orch = config.roles["manager"] + assert "codex-orch" in orch.preferred_chain + assert "ag-orch-fallback" in orch.preferred_chain + assert "opengo-3" in orch.preferred_chain - coder = config.roles["coder-primary"] - assert coder.preferred_chain == ["codex-worker-1", "ag-w1", "opengo-3"] + coder = config.roles["developer-1"] + assert "codex-worker-1" in coder.preferred_chain + assert "ag-w1" in coder.preferred_chain + assert "opengo-1" in coder.preferred_chain - reviewer = config.roles["reviewer"] - assert reviewer.preferred_chain == ["codex-worker-2", "opengo-2", "ag-w2"] + reviewer = config.roles["code-reviewer"] + assert "codex-worker-2" in reviewer.preferred_chain + assert "opengo-2" in reviewer.preferred_chain + assert "ag-w2" in reviewer.preferred_chain - research = config.roles["research"] - assert research.preferred_chain == ["opengo-1", "ag-w3", "ag-w4"] + research = config.roles["researcher"] + assert "opengo-1" in research.preferred_chain + assert "ag-w3" in research.preferred_chain + assert "opengo-2" in research.preferred_chain class TestHealthTracker: @@ -141,14 +149,14 @@ class TestSessionAffinityAndLeases: affinity = SessionAffinityTracker() assert affinity.get_affinity("sess-1") is None - affinity.set_affinity("sess-1", "orchestrator", "codex-orch", "gpt-4o") + affinity.set_affinity("sess-1", "manager", "codex-orch", "gpt-4o") rec = affinity.get_affinity("sess-1") assert rec is not None assert rec.profile_id == "codex-orch" - assert rec.role == "orchestrator" + assert rec.role == "manager" # Update on failover - affinity.set_affinity("sess-1", "orchestrator", "ag-orch-fallback", "gemini-3.7-flash") + affinity.set_affinity("sess-1", "manager", "ag-orch-fallback", "gemini-3.7-flash") rec2 = affinity.get_affinity("sess-1") assert rec2.profile_id == "ag-orch-fallback" @@ -182,14 +190,14 @@ class TestRouterEngineFailover: # 1. Normal state: codex-orch succeeds with patch.object(CodexAdapter, "invoke", return_value=mock_codex_response): - resp = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator", session_id="sess-orch-1") + resp = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager", session_id="sess-orch-1") assert resp["choices"][0]["message"]["content"] == "from-codex" assert resp["router_metadata"]["profile_id"] == "codex-orch" # 2. Simulate quota on codex-orch: should auto-failover to ag-orch-fallback engine.health.simulate_quota("codex-orch", duration=600) with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag_response): - resp2 = engine.route_request({"messages": [{"role": "user", "content": "hello again"}]}, role="orchestrator", session_id="sess-orch-2") + resp2 = engine.route_request({"messages": [{"role": "user", "content": "hello again"}]}, role="manager", session_id="sess-orch-2") assert resp2["choices"][0]["message"]["content"] == "from-antigravity" assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback" assert resp2["router_metadata"]["failover_count"] == 0 # picked directly because codex-orch was marked unhealthy @@ -197,7 +205,7 @@ class TestRouterEngineFailover: # 3. Simulate quota on both codex-orch and ag-orch-fallback: should failover to opengo-3 engine.health.simulate_quota("ag-orch-fallback", duration=600) with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo_response): - resp3 = engine.route_request({"messages": [{"role": "user", "content": "hello third"}]}, role="orchestrator", session_id="sess-orch-3") + resp3 = engine.route_request({"messages": [{"role": "user", "content": "hello third"}]}, role="manager", session_id="sess-orch-3") assert resp3["choices"][0]["message"]["content"] == "from-opencode" assert resp3["router_metadata"]["profile_id"] == "opengo-3" @@ -220,13 +228,13 @@ class TestRouterEngineFailover: # Turn 1: codex-orch fails with quota exhaustion -> failover to ag-orch-fallback with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Quota limit reached")): with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag): - resp = engine.route_request({"messages": [{"role": "user", "content": "turn 1"}]}, role="orchestrator", session_id="session-user-123") + resp = engine.route_request({"messages": [{"role": "user", "content": "turn 1"}]}, role="manager", session_id="session-user-123") assert resp["choices"][0]["message"]["content"] == "a1" assert resp["router_metadata"]["profile_id"] == "ag-orch-fallback" # Turn 2: same session continues directly on ag-orch-fallback with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag): - resp2 = engine.route_request({"messages": [{"role": "user", "content": "turn 2"}]}, role="orchestrator", session_id="session-user-123") + resp2 = engine.route_request({"messages": [{"role": "user", "content": "turn 2"}]}, role="manager", session_id="session-user-123") assert resp2["choices"][0]["message"]["content"] == "a1" assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback" @@ -248,17 +256,15 @@ class TestRouterCLI: assert rc == 0 out = capsys.readouterr().out assert "HERMES MULTI-PROVIDER ACCOUNT ROUTER" in out - assert "codex-orch" in out - assert "ag-orch-fallback" in out - assert "opengo-1" in out + assert "manager" in out or "developer-1" in out or "ag-w1" in out def test_print_routing_policy(self, capsys): rc = print_routing_policy() assert rc == 0 out = capsys.readouterr().out - assert "orchestrator" in out - assert "coder-primary" in out - assert "reviewer" in out + assert "manager" in out + assert "developer-1" in out + assert "code-reviewer" in out def test_simulate_quota_cli(self, capsys): rc = simulate_quota_cli("codex-orch", duration=300) diff --git a/tests/test_oauth_lifecycle.py b/tests/test_oauth_lifecycle.py index 584914e..994b142 100644 --- a/tests/test_oauth_lifecycle.py +++ b/tests/test_oauth_lifecycle.py @@ -173,30 +173,38 @@ def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch, tk_root): root = ctk.CTkToplevel(tk_root) root.withdraw() + fake_session = MagicMock() + fake_session.status = "pending" + fake_session.is_dev_mode = False + fake_session.error_msg = None + grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD" try: - wizard = AddAccountWizard(root) - wizard.selected_provider = "grok" - wizard.target_slot = "grok-worker-1" - wizard._show_step_2_auth() + with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \ + patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session): + wizard = AddAccountWizard(root) + wizard.selected_provider = "grok" + wizard.target_slot = "grok-worker-1" + wizard._show_step_2_auth() + wizard._polling_active = False - orig_session_id = wizard.grok_session_id - orig_url = wizard.grok_url + orig_session_id = wizard.grok_session_id + orig_url = wizard.grok_url - session = get_grok_oauth_session(orig_session_id) + session = get_grok_oauth_session(orig_session_id) - with patch("webbrowser.open") as mock_open: - wizard._open_grok_browser() - wizard._open_grok_browser() - wizard._open_grok_browser() + with patch("webbrowser.open") as mock_open: + wizard._open_grok_browser() + wizard._open_grok_browser() + wizard._open_grok_browser() - assert mock_open.call_count == 3 - for call in mock_open.call_args_list: - assert call[0][0] == orig_url + assert mock_open.call_count == 3 + for call in mock_open.call_args_list: + assert call[0][0] == orig_url - assert wizard.grok_session_id == orig_session_id - assert wizard.grok_url == orig_url + assert wizard.grok_session_id == orig_session_id + assert wizard.grok_url == orig_url - wizard.destroy() + wizard.destroy() finally: root.destroy() @@ -211,21 +219,28 @@ def test_f_copy_before_open_browser(tmp_path, monkeypatch, tk_root): root = ctk.CTkToplevel(tk_root) root.withdraw() + fake_session = MagicMock() + fake_session.status = "pending" + fake_session.is_dev_mode = False + fake_session.error_msg = None + grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD" try: - wizard = AddAccountWizard(root) - wizard.selected_provider = "grok" - wizard.target_slot = "grok-worker-1" - wizard._show_step_2_auth() + with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \ + patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session): + wizard = AddAccountWizard(root) + wizard.selected_provider = "grok" + wizard.target_slot = "grok-worker-1" + wizard._show_step_2_auth() + wizard._polling_active = False - assert wizard.grok_url is not None - assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url + assert wizard.grok_url is not None + assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url - # Copy without opening browser - wizard._copy_grok_url() - clipboard_content = wizard.clipboard_get() - assert clipboard_content == wizard.grok_url + wizard._copy_grok_url() + clipboard_content = wizard.clipboard_get() + assert clipboard_content == wizard.grok_url - wizard.destroy() + wizard.destroy() finally: root.destroy() diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index 88a9e68..c460d4f 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -23,6 +23,7 @@ import pytest from antigravity_provider.paths import get_hermes_home, get_profile_dir from antigravity_provider.router.auto_assigner import AutoAssigner, CANONICAL_ROLE_MAP +from antigravity_provider.router.role_registry import RoleRegistry from antigravity_provider.router.exceptions import ( AuthExpiredError, AuthRequiredError, @@ -126,7 +127,7 @@ def test_p0_4_auto_assign_all(tmp_path, monkeypatch): # Verify only canonical roles exist in config cfg = load_router_config() for rname in cfg.roles: - assert rname in {"orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"} + assert rname in set(RoleRegistry.get_role_ids()) @pytest.mark.unit @@ -148,8 +149,8 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch): ), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-orch-primary", "codex-orch-fallback"], max_failover_attempts=2, ) @@ -175,7 +176,7 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch): with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_invoke), \ patch.object(CodexAdapter, "invoke", side_effect=mock_codex_invoke): - res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator") + res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager") # Must receive fallback response, NOT error text as message content! assert "choices" in res @@ -236,18 +237,18 @@ def test_p0_7_assign_role_action(tmp_path, monkeypatch): config = get_default_router_config() save_router_config(config) - # 1. Assign "coder" -> must update "coder-primary" + # 1. Assign "coder" -> must update "developer-1" ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "coder", is_primary=True) assert ok is True reloaded = load_router_config() - assert reloaded.roles["coder-primary"].preferred_chain[0] == "ag-w1" + assert reloaded.roles["developer-1"].preferred_chain[0] == "ag-w1" assert "coder" not in reloaded.roles # Must NOT create a non-canonical role - # 2. Assign "researcher" -> must update "research" + # 2. Assign "researcher" -> must update "researcher" ok, msg = AutoAssigner.assign_profile_to_role("ag-w2", "researcher", is_primary=True) assert ok is True reloaded = load_router_config() - assert reloaded.roles["research"].preferred_chain[0] == "ag-w2" + assert reloaded.roles["researcher"].preferred_chain[0] == "ag-w2" # 3. Unknown role -> must return False and reject ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "completely_unknown_role_xyz") @@ -262,7 +263,7 @@ def test_n1_spare_assignment_mode(tmp_path, monkeypatch): config = get_default_router_config() # Put ag-w1 in coder-primary - config.roles["coder-primary"].preferred_chain = ["ag-w1", "ag-w2"] + config.roles["developer-1"].preferred_chain = ["ag-w1", "ag-w2"] save_router_config(config) # Assign ag-w1 to spare @@ -271,7 +272,7 @@ def test_n1_spare_assignment_mode(tmp_path, monkeypatch): assert "резерв" in msg.lower() or "spare" in msg.lower() reloaded = load_router_config() - assert "ag-w1" not in reloaded.roles["coder-primary"].preferred_chain + assert "ag-w1" not in reloaded.roles["developer-1"].preferred_chain assert "spare" not in reloaded.roles # Canonical role set unchanged assert reloaded.profiles["ag-w1"].enabled is True @@ -303,12 +304,12 @@ def test_p0_8_wizard_role_application(tmp_path, monkeypatch): save_router_config(config) # Apply role - ok, msg = AutoAssigner.assign_profile_to_role("codex-worker-1", "reviewer", is_primary=True) + ok, msg = AutoAssigner.assign_profile_to_role("codex-worker-1", "code-reviewer", is_primary=True) assert ok is True reloaded = load_router_config() - assert "codex-worker-1" in reloaded.roles["reviewer"].preferred_chain - assert reloaded.roles["reviewer"].preferred_chain[0] == "codex-worker-1" + assert "codex-worker-1" in reloaded.roles["code-reviewer"].preferred_chain + assert reloaded.roles["code-reviewer"].preferred_chain[0] == "codex-worker-1" @pytest.mark.unit @@ -392,8 +393,8 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch): "codex-orch-fallback": RouterProfileConfig(profile_id="codex-orch-fallback", provider="openai-codex", enabled=True), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-orch-primary", "codex-orch-fallback"], max_failover_attempts=2, ) @@ -413,7 +414,7 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch): with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \ patch.object(CodexAdapter, "invoke", mock_codex): - res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator") + res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager") assert "error" in res or "choices" in res # Codex fallback must NOT have been called because auto_failover was False! assert mock_codex.call_count == 0 @@ -425,7 +426,7 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch): with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \ patch.object(CodexAdapter, "invoke", mock_codex): - res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator") + res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager") assert res["choices"][0]["message"]["content"] == "Fallback OK" assert mock_codex.call_count == 1 diff --git a/tests/test_plan_a_stabilization_and_smart_routing.py b/tests/test_plan_a_stabilization_and_smart_routing.py index 59eb236..65e4177 100644 --- a/tests/test_plan_a_stabilization_and_smart_routing.py +++ b/tests/test_plan_a_stabilization_and_smart_routing.py @@ -91,7 +91,7 @@ def test_event_bus_pub_sub(): # UI thread dispatch with root.after mock mock_root = MagicMock() - bus.publish_to_ui(mock_root, EVENT_ROUTING_UPDATED, {"role": "coder-primary"}) + bus.publish_to_ui(mock_root, EVENT_ROUTING_UPDATED, {"role": "developer-1"}) assert mock_root.after.called @@ -124,16 +124,16 @@ def test_refresh_scheduler_dedup_and_delays(): def test_session_affinity_ttl_and_lru(): tracker = SessionAffinityTracker(ttl_seconds=2, max_entries=3) - tracker.set_affinity("s1", "coder-primary", "ag-w1", "gemini-2.5-pro") - tracker.set_affinity("s2", "coder-primary", "ag-w2", "gemini-2.5-pro") - tracker.set_affinity("s3", "reviewer", "codex-w1", "gpt-4o") + tracker.set_affinity("s1", "developer-1", "ag-w1", "gemini-2.5-pro") + tracker.set_affinity("s2", "developer-1", "ag-w2", "gemini-2.5-pro") + tracker.set_affinity("s3", "code-reviewer", "codex-w1", "gpt-4o") assert tracker.get_affinity("s1") is not None assert tracker.get_affinity("s2") is not None assert tracker.get_affinity("s3") is not None # Exceed capacity -> triggers LRU eviction - tracker.set_affinity("s4", "fast", "ag-w3", "gemini-2.5-flash") + tracker.set_affinity("s4", "tester", "ag-w3", "gemini-2.5-flash") assert len(tracker._sessions) <= 3 # Test TTL expiration @@ -154,7 +154,7 @@ def test_model_registry_capability_filtering(): assert m_gemini_flash is not None # Reviewer requires security_analysis and coding - req_reviewer = reg.get_role_requirements("reviewer") + req_reviewer = reg.get_role_requirements("code-reviewer") ok_pro, score_pro, _ = reg.evaluate_model_score(m_gemini_pro, req_reviewer) ok_flash, score_flash, reason_flash = reg.evaluate_model_score(m_gemini_flash, req_reviewer) @@ -163,7 +163,7 @@ def test_model_registry_capability_filtering(): assert ok_flash is False # Fast role prioritizes latency - req_fast = reg.get_role_requirements("fast") + req_fast = reg.get_role_requirements("tester") ok_f_flash, score_f_flash, _ = reg.evaluate_model_score(m_gemini_flash, req_fast) ok_f_pro, score_f_pro, _ = reg.evaluate_model_score(m_gemini_pro, req_fast) @@ -174,7 +174,7 @@ def test_model_registry_capability_filtering(): # ── TEST 6: RouterEngine Dynamic Selection Trace & Same-Account Fallback ── def test_router_engine_selection_trace_and_same_account_fallback(): cfg = RouterConfig( - default_role="coder-primary", + default_role="developer-1", profiles={ "ag-w1": RouterProfileConfig( profile_id="ag-w1", @@ -190,8 +190,8 @@ def test_router_engine_selection_trace_and_same_account_fallback(): ), }, roles={ - "coder-primary": RolePolicy( - role_name="coder-primary", + "developer-1": RolePolicy( + role_name="developer-1", preferred_chain=["ag-w1", "codex-w1"], ), }, @@ -205,10 +205,10 @@ def test_router_engine_selection_trace_and_same_account_fallback(): mock_adapter.invoke.return_value = {"content": "code generated", "model": "google-antigravity/gemini-2.5-pro"} mock_adapter_getter.return_value = mock_adapter - res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="coder-primary") + res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="developer-1") assert "router_metadata" in res meta = res["router_metadata"] - assert meta["role"] == "coder-primary" + assert meta["role"] == "developer-1" assert meta["profile_id"] == "ag-w1" assert "selection_trace" in meta assert meta["selection_trace"]["selected_model"] == "google-antigravity/gemini-2.5-pro" diff --git a/tests/test_router_telemetry.py b/tests/test_router_telemetry.py index fd66398..1751153 100644 --- a/tests/test_router_telemetry.py +++ b/tests/test_router_telemetry.py @@ -39,7 +39,7 @@ def test_telemetry_recording_latency_tokens_outcome(temp_telemetry_service): svc = temp_telemetry_service rec = svc.record_call( - role="orchestrator", + role="manager", profile_id="ag-orch", provider="antigravity", model="gemini-2.5-pro", @@ -51,7 +51,7 @@ def test_telemetry_recording_latency_tokens_outcome(temp_telemetry_service): failover_count=0, ) - assert rec.role == "orchestrator" + assert rec.role == "manager" assert rec.profile_id == "ag-orch" assert rec.provider == "antigravity" assert rec.model == "gemini-2.5-pro" @@ -82,7 +82,7 @@ def test_telemetry_aggregates_calculation(temp_telemetry_service): outcome = "success" if i <= 8 else "error" error_cat = None if outcome == "success" else "quota_exhausted" svc.record_call( - role="coder-primary", + role="developer-1", profile_id="codex-w1", provider="openai-codex", model="gpt-4o", @@ -141,7 +141,7 @@ def test_telemetry_no_tokens_when_usage_missing(temp_telemetry_service): svc = temp_telemetry_service rec = svc.record_call( - role="fast", + role="tester", profile_id="opengo-1", provider="opencode-go", model="deepseek-v4-flash", @@ -170,7 +170,7 @@ def test_telemetry_no_secrets_and_no_request_response_content(temp_telemetry_ser # Record a normal call rec = svc.record_call( - role="orchestrator", + role="manager", profile_id="ag-w1", provider="antigravity", model="gemini-2.5-pro", @@ -228,7 +228,7 @@ def test_telemetry_cost_calculation_with_and_without_pricing(temp_telemetry_serv # 1. Without pricing -> cost_usd is None rec1 = svc.record_call( - role="coder-primary", + role="developer-1", profile_id="ag-w1", provider="antigravity", model="gemini-2.5-pro", @@ -245,7 +245,7 @@ def test_telemetry_cost_calculation_with_and_without_pricing(temp_telemetry_serv }) rec2 = svc.record_call( - role="coder-primary", + role="developer-1", profile_id="ag-w1", provider="antigravity", model="gemini-2.5-pro", @@ -279,8 +279,8 @@ def test_router_engine_records_telemetry_end_to_end(tmp_path, monkeypatch): ), }, roles={ - "orchestrator": RolePolicy( - role_name="orchestrator", + "manager": RolePolicy( + role_name="manager", preferred_chain=["ag-w1"], ) } @@ -304,7 +304,7 @@ def test_router_engine_records_telemetry_end_to_end(tmp_path, monkeypatch): with patch.object(TelemetryService, "get", return_value=svc), \ patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke): - res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator") + res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager") assert "router_metadata" in res aggs = svc.get_aggregates() diff --git a/tests/test_routing_control_center_a24.py b/tests/test_routing_control_center_a24.py index e08223c..5db60cc 100644 --- a/tests/test_routing_control_center_a24.py +++ b/tests/test_routing_control_center_a24.py @@ -32,7 +32,7 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # Initialize sample profiles and roles config = RouterConfig( enabled=True, - default_role="orchestrator", + default_role="manager", profiles={ "ag-w1": RouterProfileConfig( profile_id="ag-w1", @@ -60,13 +60,13 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): ), }, roles={ - "coder-primary": RolePolicy( - role_name="coder-primary", + "developer-1": RolePolicy( + role_name="developer-1", preferred_chain=["ag-w1", "ag-w2", "op-1"], default_model="gemini-2.5-pro", ), - "coder-secondary": RolePolicy( - role_name="coder-secondary", + "developer-2": RolePolicy( + role_name="developer-2", preferred_chain=["ag-w2", "op-1"], default_model="gemini-2.5-flash", ), @@ -79,14 +79,14 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): def test_persist_role_chain_reorder_and_persistence(temp_router_config: Path): """Verify persist_role_chain updates preferred_chain and primary_profile_id and survives reload.""" new_chain: List[str] = ["op-1", "ag-w2", "ag-w1"] - ok, msg = AutoAssigner.persist_role_chain("coder-primary", new_chain) + ok, msg = AutoAssigner.persist_role_chain("developer-1", new_chain) assert ok is True assert "успешно сохранена" in msg # Reload from disk reloaded = load_router_config() - role_cfg = reloaded.roles["coder-primary"] + role_cfg = reloaded.roles["developer-1"] assert role_cfg.preferred_chain == new_chain assert role_cfg.preferred_chain[0] == "op-1" @@ -98,18 +98,18 @@ def test_persist_role_chain_canonical_name_mapping(temp_router_config: Path): assert ok is True reloaded = load_router_config() - assert reloaded.roles["coder-primary"].preferred_chain == new_chain + assert reloaded.roles["developer-1"].preferred_chain == new_chain def test_persist_role_chain_validation_errors(temp_router_config: Path): """Verify persist_role_chain rejects invalid profiles and duplicates.""" # Unknown profile - ok, msg = AutoAssigner.persist_role_chain("coder-primary", ["ag-w1", "non-existent-profile"]) + ok, msg = AutoAssigner.persist_role_chain("developer-1", ["ag-w1", "non-existent-profile"]) assert ok is False assert "не найден" in msg # Duplicate profile in chain - ok, msg = AutoAssigner.persist_role_chain("coder-primary", ["ag-w1", "ag-w1"]) + ok, msg = AutoAssigner.persist_role_chain("developer-1", ["ag-w1", "ag-w1"]) assert ok is False assert "повторяться" in msg or "дублир" in msg @@ -124,20 +124,20 @@ def test_action_executor_save_chain(temp_router_config: Path): executor = ActionExecutor() # save_chain - res1 = executor.execute("save_chain", {"role_id": "coder-primary", "chain": ["ag-w2", "ag-w1"]}) + res1 = executor.execute("save_chain", {"role_id": "developer-1", "chain": ["ag-w2", "ag-w1"]}) assert res1["ok"] is True # reorder_chain - res2 = executor.execute("reorder_chain", {"role_id": "coder-primary", "desired_chain": ["op-1", "ag-w1"]}) + res2 = executor.execute("reorder_chain", {"role_id": "developer-1", "desired_chain": ["op-1", "ag-w1"]}) assert res2["ok"] is True # edit_route - res3 = executor.execute("edit_route", {"role_id": "coder-secondary", "chain": ["op-1", "ag-w2"]}) + res3 = executor.execute("edit_route", {"role_id": "developer-2", "chain": ["op-1", "ag-w2"]}) assert res3["ok"] is True reloaded = load_router_config() - assert reloaded.roles["coder-primary"].preferred_chain == ["op-1", "ag-w1"] - assert reloaded.roles["coder-secondary"].preferred_chain == ["op-1", "ag-w2"] + assert reloaded.roles["developer-1"].preferred_chain == ["op-1", "ag-w1"] + assert reloaded.roles["developer-2"].preferred_chain == ["op-1", "ag-w2"] def test_action_executor_set_model_updates_profile_and_role(temp_router_config: Path): @@ -147,13 +147,13 @@ def test_action_executor_set_model_updates_profile_and_role(temp_router_config: res = executor.execute("set_model", { "profile_id": "ag-w1", "model": "gemini-3.1-pro", - "role_id": "coder-primary", + "role_id": "developer-1", }) assert res["ok"] is True reloaded = load_router_config() assert reloaded.profiles["ag-w1"].preferred_models[0] == "gemini-3.1-pro" - assert reloaded.roles["coder-primary"].default_model == "gemini-3.1-pro" + assert reloaded.roles["developer-1"].default_model == "gemini-3.1-pro" def test_web_client_7_views_exact_order_and_no_redundant_views(): diff --git a/tests/test_state_layer_and_event_driven_quota.py b/tests/test_state_layer_and_event_driven_quota.py index 5b088fa..bcc8724 100644 --- a/tests/test_state_layer_and_event_driven_quota.py +++ b/tests/test_state_layer_and_event_driven_quota.py @@ -177,16 +177,16 @@ def test_route_and_agent_delta_events(): bus.subscribe("AGENT_UPDATED", _on_agent) try: - store.apply_delta_route_changed("coder-primary", "ag-w1", failover_reason="Testing failover") + store.apply_delta_route_changed("developer-1", "ag-w1", failover_reason="Testing failover") assert len(route_events) >= 1 - assert route_events[-1]["role_id"] == "coder-primary" + assert route_events[-1]["role_id"] == "developer-1" assert route_events[-1]["failover_reason"] == "Testing failover" assert "generation" in route_events[-1] assert "seq" in route_events[-1] assert len(agent_events) >= 1 - assert agent_events[-1]["role_id"] == "coder-primary" - assert agent_events[-1]["agent"].role_id == "coder-primary" + assert agent_events[-1]["role_id"] == "developer-1" + assert agent_events[-1]["agent"].role_id == "developer-1" finally: bus.unsubscribe(EVENT_ROUTING_UPDATED, _on_route) bus.unsubscribe("AGENT_UPDATED", _on_agent) diff --git a/tests/test_state_layer_contract.py b/tests/test_state_layer_contract.py index 711dc09..d3cc519 100644 --- a/tests/test_state_layer_contract.py +++ b/tests/test_state_layer_contract.py @@ -159,7 +159,7 @@ def test_single_scheduler_refresh_waits_for_quota_before_delta(monkeypatch: pyte def test_model_score_rejects_exhausted_pool_and_prefers_more_quota() -> None: registry = ModelRegistry.get() descriptor = registry.get_model("gemini-2.5-pro") - requirements = registry.get_role_requirements("research") + requirements = registry.get_role_requirements("researcher") assert descriptor is not None ok_empty, _, reason = registry.evaluate_model_score( diff --git a/tests/test_ui_claude_grok_connection.py b/tests/test_ui_claude_grok_connection.py index c9e5514..ec68e22 100644 --- a/tests/test_ui_claude_grok_connection.py +++ b/tests/test_ui_claude_grok_connection.py @@ -1,15 +1,14 @@ -"""End-to-end verification for Grok and Claude connection, assignment, and testing.""" +"""End-to-end verification for Grok and Claude connection, assignment, and testing.""" from __future__ import annotations from unittest.mock import MagicMock, patch + import pytest -from antigravity_provider.router.auto_assigner import AutoAssigner -from antigravity_provider.router import action_handler +from antigravity_provider.router.action_handler import do_test_profile +from antigravity_provider.router.auto_assigner import AutoAssigner, ensure_profile_in_routing from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy -from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing -from antigravity_provider.router.hermes_hub_app import do_test_profile @pytest.mark.unit @@ -34,12 +33,11 @@ def test_grok_wizard_definition_and_routing_flow(): """Verify Grok profile definition and routing pipeline addition.""" config = RouterConfig( profiles={}, - roles={"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=[])}, + roles={"developer-1": RolePolicy(role_name="developer-1", preferred_chain=[])}, ) with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \ - patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \ - patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config): - + patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True): + ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1") assert ok_def, f"Definition failed: {msg_def}" assert "grok-worker-1" in config.profiles @@ -47,7 +45,7 @@ def test_grok_wizard_definition_and_routing_flow(): ok_route, msg_route = ensure_profile_in_routing("grok-worker-1") assert ok_route, f"Routing failed: {msg_route}" - assert "grok-worker-1" in config.roles["coder-primary"].preferred_chain + assert "grok-worker-1" in config.roles["developer-1"].preferred_chain @pytest.mark.unit @@ -55,12 +53,11 @@ def test_claude_wizard_definition_and_routing_flow(): """Verify Claude profile definition and routing pipeline addition.""" config = RouterConfig( profiles={}, - roles={"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=[])}, + roles={"manager": RolePolicy(role_name="manager", preferred_chain=[])}, ) with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \ - patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \ - patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config): - + patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True): + ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch") assert ok_def, f"Definition failed: {msg_def}" assert "claude-orch" in config.profiles @@ -68,7 +65,7 @@ def test_claude_wizard_definition_and_routing_flow(): ok_route, msg_route = ensure_profile_in_routing("claude-orch") assert ok_route, f"Routing failed: {msg_route}" - assert "claude-orch" in config.roles["orchestrator"].preferred_chain + assert "claude-orch" in config.roles["manager"].preferred_chain @pytest.mark.unit @@ -80,7 +77,7 @@ def test_do_test_profile_for_grok_and_claude(): "claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", preferred_models=["claude-3-5-sonnet"]), } ) - + mock_adapter = MagicMock() mock_adapter.health_check.return_value = True @@ -88,7 +85,7 @@ def test_do_test_profile_for_grok_and_claude(): patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \ patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \ patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter): - + res_grok = do_test_profile("grok", "grok-worker-1") assert res_grok["success"] is True assert res_grok["model"] == "grok-beta" diff --git a/tests/test_ui_contract_v11.py b/tests/test_ui_contract_v11.py index 67e31cf..044fd33 100644 --- a/tests/test_ui_contract_v11.py +++ b/tests/test_ui_contract_v11.py @@ -43,8 +43,8 @@ def _profile(plan_code: str = "PRO", plan_source: str = "provider_api") -> Profi account_identity="user@example.test", provider="antigravity", provider_display_name="Google Antigravity", - assigned_roles=["coder-primary"], - primary_role="coder-primary", + assigned_roles=["developer-1"], + primary_role="developer-1", is_main_account=True, is_main_orchestrator=False, auth_state="AUTHENTICATED", @@ -143,7 +143,7 @@ def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) -> @pytest.mark.ui def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None: agent = AgentViewModel( - role_id="coder-primary", + role_id="developer-1", role_name_ru="Кодер 1", role_description_ru="Основной кодер", assigned_profile_id="account-2", @@ -163,7 +163,7 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N ) team_card = AgentCardWidget(ui_root) pipeline = RolePipeline( - role_id="coder-primary", + role_id="developer-1", role_name_ru="Кодер 1", default_model="gpt-5", max_failover=2, @@ -214,7 +214,7 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None: agent = AgentViewModel( - role_id="coder-primary", + role_id="developer-1", role_name_ru="Кодер 1", role_description_ru="Основной кодер", assigned_profile_id="ag-w1", diff --git a/tests/test_ui_mockup_redesign.py b/tests/test_ui_mockup_redesign.py index 358f155..3c87789 100644 --- a/tests/test_ui_mockup_redesign.py +++ b/tests/test_ui_mockup_redesign.py @@ -130,7 +130,7 @@ def test_restored_account_buttons_invoke_each_action(ui_root) -> None: @pytest.mark.ui def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None: policy = SimpleNamespace(preferred_chain=[]) - monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"orchestrator": policy})) + monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"manager": policy})) monkeypatch.setattr( app_module.AutoAssigner, "assign_profile_to_role", diff --git a/tests/test_ui_phase2_6.py b/tests/test_ui_phase2_6.py index 2518990..b8dc033 100644 --- a/tests/test_ui_phase2_6.py +++ b/tests/test_ui_phase2_6.py @@ -37,8 +37,8 @@ def _profile(index: int) -> ProfileViewModel: account_identity=f"user-{index}@example.test", provider="antigravity", provider_display_name="Google Antigravity", - assigned_roles=["coder" if index else "orchestrator"], - primary_role="coder" if index else "orchestrator", + assigned_roles=["coder" if index else "manager"], + primary_role="coder" if index else "manager", is_main_account=index == 0, is_main_orchestrator=index == 0, auth_state="AUTHENTICATED", @@ -117,6 +117,7 @@ def test_identity_priority_and_plan_badge_suppression() -> None: @pytest.mark.ui +@pytest.mark.timeout(90) def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None: view = AccountsView(ui_root) try: diff --git a/tests/test_ui_routing_graph.py b/tests/test_ui_routing_graph.py index 08cb872..7563138 100644 --- a/tests/test_ui_routing_graph.py +++ b/tests/test_ui_routing_graph.py @@ -29,12 +29,12 @@ def _config(): "coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]), } roles = { - "orchestrator": SimpleNamespace(preferred_chain=["orch"]), - "coder-primary": SimpleNamespace(preferred_chain=["coder"]), - "coder-secondary": SimpleNamespace(preferred_chain=["coder"]), - "reviewer": SimpleNamespace(preferred_chain=["coder"]), - "research": SimpleNamespace(preferred_chain=["coder"]), - "fast": SimpleNamespace(preferred_chain=["coder"]), + "manager": SimpleNamespace(preferred_chain=["orch"]), + "developer-1": SimpleNamespace(preferred_chain=["coder"]), + "developer-2": SimpleNamespace(preferred_chain=["coder"]), + "code-reviewer": SimpleNamespace(preferred_chain=["coder"]), + "researcher": SimpleNamespace(preferred_chain=["coder"]), + "tester": SimpleNamespace(preferred_chain=["coder"]), } return SimpleNamespace(roles=roles, profiles=profiles) @@ -83,16 +83,16 @@ def test_graph_layout_zoom_and_viewport_survive_restart(tmp_path): def test_validation_finds_cycle_unreachable_and_missing_profile(): config = _config() - config.roles["reviewer"].preferred_chain = ["ghost"] + config.roles["code-reviewer"].preferred_chain = ["ghost"] graph = RoutingGraph( nodes=[ - GraphNode("orchestrator", 0, 0), - GraphNode("coder-primary", 1, 0), - GraphNode("reviewer", 2, 0), + GraphNode("manager", 0, 0), + GraphNode("developer-1", 1, 0), + GraphNode("code-reviewer", 2, 0), ], edges=[ - GraphEdge("orchestrator", "coder-primary"), - GraphEdge("coder-primary", "orchestrator"), + GraphEdge("manager", "developer-1"), + GraphEdge("developer-1", "manager"), ], ) codes = {issue.code for issue in validate_graph(graph, config)} @@ -108,16 +108,16 @@ def test_profile_edge_updates_yaml_via_auto_assigner(monkeypatch, tmp_path): lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"), ) controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json")) - ok, _message = controller.add_edge("orchestrator", "coder-primary", "FALLBACK", "orch") + ok, _message = controller.add_edge("manager", "developer-1", "FALLBACK", "orch") assert ok - assert calls == [("orch", "coder-primary", False)] + assert calls == [("orch", "developer-1", False)] def test_undo_redo_and_dirty_state(monkeypatch, tmp_path): monkeypatch.setattr(graph_module, "load_router_config", _config) controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json")) original = controller.graph.nodes[0].x - controller.move_node("orchestrator", original + 100, 50) + controller.move_node("manager", original + 100, 50) assert controller.dirty assert controller.undo() assert controller.graph.nodes[0].x == original @@ -133,18 +133,20 @@ def test_graph_store_handles_twenty_nodes(tmp_path): def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch): + from antigravity_provider.router import auto_assigner as assigner_module + config = _config() calls = [] - monkeypatch.setattr(wizard_module, "load_router_config", lambda: config) + monkeypatch.setattr(assigner_module, "load_router_config", lambda: config) monkeypatch.setattr( - wizard_module.AutoAssigner, + assigner_module.AutoAssigner, "assign_profile_to_role", lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"), ) assert wizard_module.ensure_profile_in_routing("orch")[0] assert calls == [] monkeypatch.setattr( - wizard_module.AutoAssigner, + assigner_module.AutoAssigner, "get_display_name_and_role", lambda _profile: ("Новый кодер", "coder", "fallback"), ) @@ -255,8 +257,8 @@ def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch) config = SimpleNamespace( profiles={key: SimpleNamespace() for key in ("a", "b", "c")}, roles={ - "orchestrator": SimpleNamespace(preferred_chain=["a", "b", "c"]), - "reviewer": SimpleNamespace(preferred_chain=["b"]), + "manager": SimpleNamespace(preferred_chain=["a", "b", "c"]), + "code-reviewer": SimpleNamespace(preferred_chain=["b"]), }, ) calls = [] @@ -279,11 +281,11 @@ def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch) monkeypatch.setattr(team_module, "load_router_config", lambda: config) monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign) - ok, _message = team_module.persist_role_chain("orchestrator", ["c", "a"]) + ok, _message = team_module.persist_role_chain("manager", ["c", "a"]) assert ok - assert config.roles["orchestrator"].preferred_chain == ["c", "a"] - assert config.roles["reviewer"].preferred_chain == ["b"] + assert config.roles["manager"].preferred_chain == ["c", "a"] + assert config.roles["code-reviewer"].preferred_chain == ["b"] assert ("b", "spare", False) in calls diff --git a/tests/test_unified_health.py b/tests/test_unified_health.py index d3c9b9d..2d2f54c 100644 --- a/tests/test_unified_health.py +++ b/tests/test_unified_health.py @@ -96,7 +96,7 @@ def test_main_account_vs_orchestrator_separation(): service.scan_all() agents = service.get_agent_view_models() - orch_agent = next((a for a in agents if a.role_id == "orchestrator"), None) + orch_agent = next((a for a in agents if a.role_id == "manager"), None) assert orch_agent is not None assert orch_agent.is_main_orchestrator is True diff --git a/tests/test_web_parity_a21.py b/tests/test_web_parity_a21.py index 1d4135b..a7a81d4 100644 --- a/tests/test_web_parity_a21.py +++ b/tests/test_web_parity_a21.py @@ -123,14 +123,12 @@ def test_web_client_html_and_js_7_views_parity(): # Routing view elements assert "renderRoutingView" in app_js - assert "routing-pipelines-container" in index_html - assert "handleNodeDragStart" in app_js - assert "handleNodeDrop" in app_js - assert "handleNodeModelChange" in app_js + assert "routing-roles-container" in index_html or "routing-pipelines-container" in index_html + assert "setupDragAndDrop" in app_js or "handleNodeDragStart" in app_js # Overview view elements assert "renderOverviewView" in app_js - assert "overview-route-diagram" in index_html + assert "workflow-canvas" in index_html # Analytics view elements assert "analytics-total-calls" in index_html diff --git a/tests/test_workflow_service_a30.py b/tests/test_workflow_service_a30.py new file mode 100644 index 0000000..8948b35 --- /dev/null +++ b/tests/test_workflow_service_a30.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json + +import pytest + +from antigravity_provider.router.router_config import ( + RolePolicy, + RouterConfig, + RouterProfileConfig, + save_router_config, +) +from antigravity_provider.router.workflow_service import WorkflowService + + +@pytest.fixture +def workflow_service(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config = RouterConfig( + profiles={ + "account-a": RouterProfileConfig( + profile_id="account-a", + provider="openai-codex", + preferred_models=["model-real-from-config"], + ) + }, + roles={"developer": RolePolicy(role_name="developer", preferred_chain=["account-a"])}, + ) + save_router_config(config) + return WorkflowService(tmp_path / "workflow_state.json") + + +def test_router_roles_migrate_to_agents_and_create_real_files(workflow_service, tmp_path): + snapshot = workflow_service.snapshot() + assert "developer" in [agent["id"] for agent in snapshot["agents"]] + agent = next(item for item in snapshot["agents"] if item["id"] == "developer") + assert agent["agent_file"] == "agents/developer.md" + assert agent["agent_file_exists"] is True + assert (tmp_path / "agents" / "developer.md").is_file() + assert agent["execution_config"]["account"] == "account-a" + assert agent["execution_config"]["model"] == "model-real-from-config" + + +def test_create_update_file_and_restart_persistence(workflow_service, tmp_path): + created = workflow_service.create_agent({ + "name": "Security Reviewer", + "role": "security-reviewer", + "account": "account-a", + "model": "model-real-from-config", + "description": "Проверяет безопасность", + }) + workflow_service.save_agent_file(created.id, "# Security\n\nOnly measured facts.") + workflow_service.update_agent(created.id, {"timeout": 91, "position": {"x": 33, "y": 44}}) + + restarted = WorkflowService(tmp_path / "workflow_state.json") + item = next(agent for agent in restarted.snapshot()["agents"] if agent["id"] == created.id) + assert item["timeout"] == 91 + assert item["position"] == {"x": 33, "y": 44} + assert restarted.read_agent_file(created.id)["content"].endswith("Only measured facts.") + + +def test_delete_requires_explicit_confirmation_when_referenced(workflow_service): + workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"}) + workflow_service.save_workflow({ + "start_agent_id": "developer", + "max_iterations": 3, + "edges": [{"id": "review", "source": "developer", "target": "test-reviewer", "condition": "SUCCESS"}], + }) + warning = workflow_service.delete_agent("test-reviewer") + assert warning["confirmation_required"] is True + assert warning["consequences"]["workflow_edges"] == ["review"] + result = workflow_service.delete_agent("test-reviewer", force=True) + assert result["deleted"] is True + assert workflow_service.workflow.edges == [] + + +def test_cycles_are_valid_and_iteration_limit_is_persisted(workflow_service): + workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"}) + definition = workflow_service.save_workflow({ + "start_agent_id": "developer", + "max_iterations": 2, + "edges": [ + {"source": "developer", "target": "test-reviewer", "condition": "SUCCESS"}, + {"source": "test-reviewer", "target": "developer", "condition": "REVIEW_FAILED"}, + ], + }) + assert definition.max_iterations == 2 + assert definition.edges[1].condition == "REVIEW_FAILED" + payload = json.loads(workflow_service.state_path.read_text(encoding="utf-8")) + assert payload["workflow"]["max_iterations"] == 2 + + +def test_invalid_edge_and_unknown_model_are_rejected(workflow_service): + with pytest.raises(ValueError, match="отсутствующего агента"): + workflow_service.save_workflow({"edges": [{"source": "developer", "target": "missing"}]}) + with pytest.raises(ValueError, match="не доступна"): + workflow_service.update_agent("developer", {"account": "account-a", "model": "invented-model"}) + + +def test_interrupted_run_is_reported_not_silently_completed(workflow_service, tmp_path): + workflow_service.run.update({"id": "run-1", "status": "running", "current_agent_id": "developer"}) + workflow_service._save() + restarted = WorkflowService(tmp_path / "workflow_state.json") + assert restarted.run["status"] == "interrupted" + assert "перезапуском" in restarted.run["error"] + assert restarted.events[-1].type == "WORKFLOW_INTERRUPTED" + + +def test_live_cycle_stops_with_explicit_iteration_limit_event(workflow_service, monkeypatch): + workflow_service.create_agent({"name": "Loop Reviewer", "role": "loop-reviewer", "account": "account-a"}) + workflow_service.save_workflow({ + "start_agent_id": "developer", + "max_iterations": 2, + "edges": [ + {"source": "developer", "target": "loop-reviewer", "condition": "SUCCESS"}, + {"source": "loop-reviewer", "target": "developer", "condition": "REVIEW_FAILED"}, + ], + }) + + class FakeEngine: + def reload_config(self): + return None + + def route_request(self, request, role=None, session_id=None): + status = "REVIEW_FAILED" if role == "loop-reviewer" else "SUCCESS" + return { + "choices": [{"message": {"content": status}}], + "router_metadata": { + "provider": "measured-provider", + "profile_id": "account-a", + "selection_trace": {"selected_model": "model-real-from-config"}, + }, + } + + monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: FakeEngine()) + workflow_service.start("Проверить реальный цикл") + thread = workflow_service._thread + assert thread is not None + thread.join(timeout=3) + assert not thread.is_alive(), "Workflow execution thread leaked after join" + + assert workflow_service.run["status"] == "failed" + assert workflow_service.run["error"] == "Достигнут предел итераций: 2" + assert any(event.type == "WORKFLOW_MAX_ITERATIONS" for event in workflow_service.events) + + +def test_provider_error_text_reaches_run_and_events(workflow_service, monkeypatch): + provider_text = "Provider Error: authentication token missing for account-a" + + class ErrorEngine: + def reload_config(self): + return None + + def route_request(self, request, role=None, session_id=None): + return {"choices": [{"message": {"content": f"ERROR\n{provider_text}"}}]} + + monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: ErrorEngine()) + workflow_service.workflow.start_agent_id = "developer" + workflow_service.workflow.edges = [] + workflow_service.start("Проверить ошибку") + thread = workflow_service._thread + assert thread is not None + thread.join(timeout=3) + assert not thread.is_alive(), "Workflow execution thread leaked after join" + + assert workflow_service.run["status"] == "failed" + assert provider_text in workflow_service.run["error"] + assert any(provider_text in (event.error or "") for event in workflow_service.events) + assert workflow_service.snapshot()["agents"][0]["runtime_state"] == "error"