fix(ci): A33 stop hanging tests and stabilize CI #1
63 changed files with 4644 additions and 1801 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
43
agents/inbox/2026-08-26-antigravity-release-gate-a30.md
Normal file
43
agents/inbox/2026-08-26-antigravity-release-gate-a30.md
Normal file
|
|
@ -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 при известных блокерах.
|
||||
BIN
docs/screenshots/a30/agent-file-editor.png
Normal file
BIN
docs/screenshots/a30/agent-file-editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/screenshots/a30/edge-editor.png
Normal file
BIN
docs/screenshots/a30/edge-editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/screenshots/a30/overview-edit-inspector.png
Normal file
BIN
docs/screenshots/a30/overview-edit-inspector.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/screenshots/a30/overview-live-provider-error.png
Normal file
BIN
docs/screenshots/a30/overview-live-provider-error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/screenshots/a30/overview-live.png
Normal file
BIN
docs/screenshots/a30/overview-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
82
scripts/run_hermetic_tests.py
Normal file
82
scripts/run_hermetic_tests.py
Normal file
|
|
@ -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())
|
||||
272
scripts/test_hang_diagnose.py
Normal file
272
scripts/test_hang_diagnose.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Пустой ответ — это отказ, а не успех.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
394
src/antigravity_provider/router/preflight_service.py
Normal file
394
src/antigravity_provider/router/preflight_service.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
357
src/antigravity_provider/router/role_registry.py
Normal file
357
src/antigravity_provider/router/role_registry.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hermes Hub — Панель управления</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="static/workflow.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -92,52 +93,53 @@
|
|||
<div class="content-scroll">
|
||||
<!-- 1. OVERVIEW VIEW -->
|
||||
<section id="view-overview" class="view-pane active">
|
||||
<div class="overview-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Состояние системы</div>
|
||||
<div class="kpi-value text-healthy" id="kpi-system-readiness">—</div>
|
||||
<div class="kpi-sub" id="kpi-readiness-summary">—</div>
|
||||
<div class="workflow-toolbar">
|
||||
<div class="workflow-mode" role="group" aria-label="Режим workflow">
|
||||
<button class="workflow-mode-btn active" id="workflow-mode-live" data-mode="live">LIVE</button>
|
||||
<button class="workflow-mode-btn" id="workflow-mode-edit" data-mode="edit">EDIT</button>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Подключено аккаунтов</div>
|
||||
<div class="kpi-value text-accent" id="kpi-total-accounts">—</div>
|
||||
<div class="kpi-sub" id="kpi-accounts-sub">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Готовых ролей</div>
|
||||
<div class="kpi-value text-healthy" id="kpi-ready-roles">—</div>
|
||||
<div class="kpi-sub" id="kpi-roles-sub">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Провайдеры ИИ</div>
|
||||
<div class="kpi-value text-info" id="kpi-providers-count">—</div>
|
||||
<div class="kpi-sub" id="kpi-providers-sub">5 поддерживаемых систем</div>
|
||||
<div class="workflow-toolbar-actions">
|
||||
<span class="workflow-save-state" id="workflow-save-state">Сохранено</span>
|
||||
<button class="btn btn-secondary" id="btn-workflow-save">Сохранить workflow</button>
|
||||
<button class="btn btn-primary" id="btn-agent-add">+ Добавить агента</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<div>
|
||||
<div class="section-card-title">Схема маршрутизации запросов</div>
|
||||
<div class="section-card-subtitle">Распределение агентов и цепочки отказоустойчивости</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-auto-assign" title="Автоматическое распределение аккаунтов по ролям">
|
||||
Авто-распределение
|
||||
</button>
|
||||
</div>
|
||||
<div class="route-diagram-container" id="overview-route-diagram">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
<div class="workflow-kpis" aria-label="Оперативные показатели">
|
||||
<article class="workflow-kpi"><span>Активные задачи</span><strong id="workflow-kpi-active">Загрузка…</strong><small id="workflow-kpi-active-reason">Получение снапшота</small></article>
|
||||
<article class="workflow-kpi"><span>Агенты онлайн</span><strong id="workflow-kpi-online">Загрузка…</strong><small id="workflow-kpi-online-reason">Получение снапшота</small></article>
|
||||
<article class="workflow-kpi"><span>Среднее время ответа</span><strong id="workflow-kpi-latency">Загрузка…</strong><small id="workflow-kpi-latency-reason">Получение телеметрии</small></article>
|
||||
<article class="workflow-kpi"><span>Использование токенов</span><strong id="workflow-kpi-tokens">Загрузка…</strong><small id="workflow-kpi-tokens-reason">Получение телеметрии</small></article>
|
||||
<article class="workflow-kpi"><span>Успешность задач</span><strong id="workflow-kpi-success">Загрузка…</strong><small id="workflow-kpi-success-reason">Получение телеметрии</small></article>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title">Провайдеры и доступность runtime</div>
|
||||
<div class="section-card-subtitle">Статус локальных адаптеров и обнаруженные модели</div>
|
||||
<div class="workflow-main-layout">
|
||||
<section class="workflow-board-card">
|
||||
<header class="workflow-board-header">
|
||||
<div><strong id="workflow-title">Workflow</strong> <span id="workflow-run-state" class="workflow-run-state">Загрузка…</span></div>
|
||||
<label>Итерация: <strong id="workflow-iteration">Н/Д</strong> / <input id="workflow-max-iterations" type="number" min="1" max="100" value="5"></label>
|
||||
<div class="workflow-zoom"><button id="workflow-zoom-out" title="Уменьшить">−</button><span id="workflow-zoom-value">100%</span><button id="workflow-zoom-in" title="Увеличить">+</button><button id="workflow-fit" title="Вписать граф">⛶</button></div>
|
||||
</header>
|
||||
<div class="workflow-canvas" id="workflow-canvas" tabindex="0" aria-label="Редактор графа workflow">
|
||||
<svg id="workflow-edges" class="workflow-edges" aria-hidden="true"><defs><marker id="wf-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"></path></marker></defs><g id="workflow-edge-layer"></g></svg>
|
||||
<div id="workflow-node-layer" class="workflow-node-layer"></div>
|
||||
<div id="workflow-empty" class="workflow-empty hidden"><strong>В workflow пока нет агентов</strong><span>Добавьте агента, затем переключитесь в EDIT и соедините узлы.</span></div>
|
||||
<div class="workflow-minimap" id="workflow-minimap" aria-label="Мини-карта"></div>
|
||||
</div>
|
||||
<div class="providers-grid" id="overview-providers-summary">
|
||||
<!-- Rendered by app.js -->
|
||||
<footer class="workflow-legend">
|
||||
<span><i class="state-waiting"></i>Ожидает</span><span><i class="state-working"></i>Работает</span><span><i class="state-reviewing"></i>Проверяет</span><span><i class="state-error"></i>Ошибка</span><span><i class="state-completed"></i>Завершено</span>
|
||||
<span class="edge-legend success">→ Успех</span><span class="edge-legend return">⇢ Возврат</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="workflow-inspector" id="workflow-inspector">
|
||||
<div class="workflow-inspector-empty"><strong>INSPECTOR</strong><span>Выберите агента на графе</span></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="workflow-bottom-grid">
|
||||
<section class="workflow-events"><header><strong>Последние события</strong><button id="workflow-events-all">Журнал событий →</button></header><div id="workflow-events-list" class="workflow-events-list"><p>Загрузка событий…</p></div></section>
|
||||
<section class="workflow-run-panel"><header><strong>Управление LIVE</strong></header><textarea id="workflow-task" placeholder="Опишите реальную задачу для workflow"></textarea><div><button class="btn btn-primary" id="btn-workflow-start">Запустить workflow</button><button class="btn btn-secondary" id="btn-workflow-stop">Остановить</button></div><p id="workflow-run-error" class="workflow-run-error"></p></section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -177,15 +179,49 @@
|
|||
</section>
|
||||
|
||||
<!-- 3. ROUTING VIEW (Main Control Center) -->
|
||||
|
||||
<section id="view-routing" class="view-pane">
|
||||
<div class="view-header-note">
|
||||
Главный центр управления маршрутизацией: перетаскивайте узлы для смены приоритета (Основной → Резерв 1 → Резерв 2), настраивайте рабочие модели и управляйте составом цепочек.
|
||||
<div class="view-header-note" style="display:flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<i class="fa-solid fa-circle-info" style="color:var(--status-warning);"></i>
|
||||
Перетащите аккаунт из правой панели в нужную роль и установите порядок (приоритет) использования.
|
||||
<br>
|
||||
Система будет использовать аккаунты сверху вниз по списку при выполнении задач с учётом квот и состояния.
|
||||
</div>
|
||||
<div class="routing-pipelines-list" id="routing-pipelines-container">
|
||||
</div>
|
||||
|
||||
<div class="routing-grid" style="display: flex; gap: 24px; height: calc(100vh - 150px);">
|
||||
<!-- LEFT COLUMN: Roles -->
|
||||
<div class="routing-left-col" id="routing-roles-container" style="flex: 2; overflow-y: auto; padding-right: 8px;">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Available Accounts -->
|
||||
<div class="routing-right-col" style="flex: 1; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-md); background-color: var(--surface-muted);">
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--border-subtle);">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 12px;">
|
||||
<h3 style="font-family: var(--font-title); font-size: 14px; text-transform: uppercase;">Доступные аккаунты</h3>
|
||||
<span id="available-accounts-count" class="text-muted" style="font-size: 12px;">0 аккаунтов</span>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="text" id="routing-account-search" placeholder="Поиск по аккаунтам, ролям или моделям..." style="width: 100%;">
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<select id="routing-filter-provider" style="flex:1;"><option value="all">Все провайдеры</option></select>
|
||||
<select id="routing-filter-status" style="flex:1;"><option value="all">Все статусы</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="available-accounts-list" id="routing-available-container" style="flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px;">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
<div style="padding: 16px; text-align: center; font-size: 12px; color: var(--text-muted); border-top: 1px dashed var(--border-subtle);">
|
||||
<i class="fa-solid fa-arrows-up-down-left-right"></i> Перетащите аккаунт в нужную роль или нажмите «+» для добавления
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- 4. ANALYTICS VIEW (P0-1 & P0-5) -->
|
||||
<section id="view-analytics" class="view-pane">
|
||||
<div class="view-header-note">
|
||||
|
|
@ -241,9 +277,23 @@
|
|||
<!-- Readiness state banner rendered by app.js -->
|
||||
</div>
|
||||
|
||||
<div class="section-card" id="health-preflight-card">
|
||||
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<div>
|
||||
<div class="section-card-title">Проверка готовности (Preflight / Dependency Agent)</div>
|
||||
<div class="section-card-subtitle">Zero-quota аудит окружения, зависимостей, прав доступа и локальных серверов</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-run-preflight">Запустить проверку готовности</button>
|
||||
</div>
|
||||
<div id="preflight-results-container" style="padding-top:12px;">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title">Ресурсы хост-системы</div>
|
||||
|
||||
<div class="section-card-subtitle">Мониторинг CPU, памяти, диска и сетевых параметров</div>
|
||||
</div>
|
||||
<div class="host-resources-grid" id="health-host-resources">
|
||||
|
|
@ -366,6 +416,19 @@
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Маскирование почты (PII)</div>
|
||||
<div class="setting-desc">Уровень маскирования адресов электронной почты в снапшотах и логах</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="setting-email-masking-mode" class="select-filter">
|
||||
<option value="none" selected>Без маскирования (по умолчанию)</option>
|
||||
<option value="partial">Частичное (v***@gmail.com)</option>
|
||||
<option value="full">Полное (***@***.***)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Тема оформления</div>
|
||||
|
|
@ -505,5 +568,15 @@
|
|||
|
||||
<!-- Application Logic -->
|
||||
<script src="app.js"></script>
|
||||
<script src="static/workflow.js"></script>
|
||||
<script>
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const theme = urlParams.get('theme');
|
||||
if (theme) {
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
const sel = document.getElementById('theme-selector');
|
||||
if (sel) sel.value = theme;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
89
src/antigravity_provider/router/web/static/workflow.css
Normal file
89
src/antigravity_provider/router/web/static/workflow.css
Normal file
|
|
@ -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; } }
|
||||
497
src/antigravity_provider/router/web/static/workflow.js
Normal file
497
src/antigravity_provider/router/web/static/workflow.js
Normal file
|
|
@ -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 = `<strong>Workflow недоступен</strong><span>${wfEscape(reason)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 `<article class="workflow-node ${wfEscape(agent.runtime_state)} ${workflowUi.selectedAgentId === agent.id ? 'selected' : ''}" data-agent-id="${wfEscape(agent.id)}" style="left:${Number(pos.x) || 0}px;top:${Number(pos.y) || 0}px">
|
||||
<button class="workflow-port in" aria-label="Вход"></button><button class="workflow-port out" aria-label="Создать связь"></button>
|
||||
<h3>${wfEscape(agent.name)}</h3><p>${wfEscape(agent.role)}</p><p>${wfEscape(agent.agent_file)}</p><p title="${wfEscape(assignment)}">${wfEscape(assignment)}</p>
|
||||
<div class="workflow-node-status"><i></i><span>${wfEscape(runtimeStateLabel(agent.runtime_state))}</span></div>
|
||||
</article>`;
|
||||
}).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(`<path class="${wfEscape(klass)}" d="${path}"></path><path class="workflow-edge-hit" data-edge-id="${wfEscape(edge.id)}" d="${path}"></path><text class="workflow-edge-label" x="${labelX}" y="${labelY}">${wfEscape(edge.label || edge.condition)}</text>`);
|
||||
});
|
||||
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 `<i style="left:${pos.x / maxX * 92}px;top:${pos.y / maxY * 55}px"></i>`;
|
||||
}).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 = '<div class="workflow-inspector-empty"><strong>INSPECTOR</strong><span>Выберите агента на графе</span></div>';
|
||||
return;
|
||||
}
|
||||
const cfg = agent.execution_config || {};
|
||||
const tabs = [['main', 'Основное'], ['model', 'Модель'], ['instructions', 'Инструкции'], ['tools', 'Инструменты'], ['memory', 'Память'], ['history', 'История']];
|
||||
box.innerHTML = `<h2>INSPECTOR: ${wfEscape(agent.name)}</h2><div class="inspector-tabs">${tabs.map(([id, label]) => `<button data-tab="${id}" class="${workflowUi.selectedTab === id ? 'active' : ''}">${label}</button>`).join('')}</div><div id="inspector-tab-content"></div>`;
|
||||
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 = `<label class="inspector-field">Название<input id="agent-edit-name" value="${wfEscape(agent.name)}"></label><label class="inspector-field">Роль<div class="inspector-value">${wfEscape(agent.role)}</div></label><label class="inspector-field">Описание<textarea id="agent-edit-description">${wfEscape(agent.description)}</textarea></label><label class="inspector-field">Runtime status<div class="inspector-value">${wfEscape(runtimeStateLabel(agent.runtime_state))}</div></label><label class="inspector-field">Текущая задача<div class="inspector-value">${wfEscape(workflow.run?.current_agent_id === agent.id ? workflow.run.current_task : 'Н/Д: агент сейчас не выполняется')}</div></label><div class="inspector-actions"><button class="btn btn-primary btn-sm" id="agent-save-main">Сохранить</button><button class="btn btn-secondary btn-sm" id="agent-delete">Удалить</button></div>`;
|
||||
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 = `<div class="agent-file-card"><strong>Agent File</strong><code>${wfEscape(agent.agent_file)}</code><span>${agent.agent_file_exists ? 'Файл существует' : 'Н/Д: файл отсутствует'}</span><div class="inspector-actions"><button class="btn btn-secondary btn-sm" id="agent-file-open">Открыть в редакторе</button></div></div>`;
|
||||
document.getElementById('agent-file-open').onclick = () => openAgentFileEditor(agent);
|
||||
} else if (workflowUi.selectedTab === 'tools') {
|
||||
content.innerHTML = `<label class="inspector-field">Инструменты, через запятую<input id="agent-tools" value="${wfEscape((agent.tools || []).join(', '))}"></label><button class="btn btn-primary btn-sm" id="agent-tools-save">Сохранить</button>`;
|
||||
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 = `<div class="inspector-value">${Object.keys(agent.memory_configuration || {}).length ? `<pre>${wfEscape(JSON.stringify(agent.memory_configuration, null, 2))}</pre>` : 'Н/Д: конфигурация памяти не задана'}</div>`;
|
||||
} else {
|
||||
const history = (workflow.events || []).filter((event) => event.agent_id === agent.id);
|
||||
content.innerHTML = history.length ? history.slice(-20).reverse().map((event) => `<div class="workflow-event ${wfEscape(event.level)}"><time>${wfEscape(formatWorkflowTime(event.timestamp))}</time><span>${wfEscape(event.message)}</span><em>${event.duration_seconds == null ? '' : `${event.duration_seconds} с`}</em></div>`).join('') : '<div class="inspector-value">Н/Д: у агента ещё нет запусков</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<label class="inspector-field">Провайдер<select id="agent-provider"><option value="">Не назначен</option>${providers.map((provider) => `<option value="${wfEscape(provider)}" ${provider === cfg.provider ? 'selected' : ''}>${wfEscape(provider)}</option>`).join('')}</select></label><label class="inspector-field">Аккаунт<select id="agent-account"></select></label><label class="inspector-field">Модель<select id="agent-model"></select></label><label class="inspector-field">Температура<input id="agent-temperature" type="number" step="0.1" min="0" max="2" value="${cfg.temperature ?? ''}" placeholder="Н/Д: не задана"></label><label class="inspector-field">Макс. токенов<input id="agent-max-tokens" type="number" min="1" value="${cfg.max_tokens ?? ''}" placeholder="Н/Д: не задано"></label><label class="inspector-field">Таймаут, с<input id="agent-timeout" type="number" min="1" value="${cfg.timeout || ''}"></label><button class="btn btn-primary btn-sm" id="agent-model-save">Изменить конфигурацию</button>`;
|
||||
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) => `<option value="${wfEscape(profile.profile_id)}" ${profile.profile_id === cfg.account ? 'selected' : ''}>${wfEscape(profile.account_identity || profile.display_name || profile.profile_id)}</option>`).join('') : '<option value="">Н/Д: нет подключённых аккаунтов</option>';
|
||||
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) => `<option value="${wfEscape(model)}" ${model === cfg.model ? 'selected' : ''}>${wfEscape(model)}</option>`).join('') : '<option value="">Н/Д: модели не обнаружены</option>';
|
||||
};
|
||||
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('Добавить агента', `<label>Название<input id="new-agent-name" required></label><label>Роль (произвольный идентификатор)<input id="new-agent-role" placeholder="security-reviewer"></label><label>Описание<textarea id="new-agent-description"></textarea></label><label>Аккаунт<select id="new-agent-account"><option value="">Не назначать</option>${profiles.map((profile) => `<option value="${wfEscape(profile.profile_id)}">${wfEscape(profile.provider)} · ${wfEscape(profile.account_identity || profile.profile_id)}</option>`).join('')}</select></label><label>Agent File<input id="new-agent-file" placeholder="agents/имя.md"></label>`, 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}`, `<label>Путь<input value="${wfEscape(data.path)}" readonly></label><label>Markdown<textarea id="agent-file-content" class="agent-file-editor">${wfEscape(data.content)}</textarea></label><p id="agent-file-unsaved">Нет изменений</p>`, 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 ? 'Новое ребро' : 'Редактор ребра', `<label>От<input value="${wfEscape(edge.source)}" readonly></label><label>К<input value="${wfEscape(edge.target)}" readonly></label><label>Условие<select id="edge-condition">${['SUCCESS', 'REVIEW_PASSED', 'REVIEW_FAILED', 'NEXT', 'ERROR', 'ALWAYS'].map((condition) => `<option ${condition === edge.condition ? 'selected' : ''}>${condition}</option>`).join('')}</select></label><label>Подпись<input id="edge-label" value="${wfEscape(edge.label || '')}"></label>${isNew ? '' : '<button class="btn btn-secondary btn-sm" id="edge-delete">Удалить ребро</button>'}`, (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 = `<div class="workflow-dialog"><h2>${wfEscape(title)}</h2>${body}<div class="workflow-dialog-actions"><button class="btn btn-secondary" data-dialog-cancel>Отмена</button><button class="btn btn-primary" data-dialog-save>${wfEscape(saveLabel)}</button></div></div>`;
|
||||
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 = '<p>Н/Д: workflow ещё не создавал событий</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = events.slice(-20).reverse().map((event) => `<div class="workflow-event ${wfEscape(event.level)}"><time>${wfEscape(formatWorkflowTime(event.timestamp))}</time><span>${wfEscape(event.message)}${event.error ? ` — ${wfEscape(event.error)}` : ''}</span><em>${wfEscape(event.type)}</em></div>`).join('');
|
||||
}
|
||||
|
||||
function formatWorkflowTime(value) {
|
||||
if (!value) return 'Н/Д';
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString('ru-RU');
|
||||
}
|
||||
785
src/antigravity_provider/router/workflow_service.py
Normal file
785
src/antigravity_provider/router/workflow_service.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
435
tests/test_a31_preflight_state_batching_pii.py
Normal file
435
tests/test_a31_preflight_state_batching_pii.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,8 +116,10 @@ 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:
|
||||
if RoleRegistry.is_role_implemented(rname):
|
||||
assert cfg.roles[rname].preferred_chain == ["ag-w1"]
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner)
|
||||
assert res_apply_async["ok"] is True
|
||||
assert "ApplyUpdate" in dispatched
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -173,11 +173,19 @@ 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:
|
||||
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
|
||||
|
|
@ -211,16 +219,23 @@ 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:
|
||||
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
|
||||
|
||||
# Copy without opening browser
|
||||
wizard._copy_grok_url()
|
||||
clipboard_content = wizard.clipboard_get()
|
||||
assert clipboard_content == wizard.grok_url
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,11 +33,10 @@ 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}"
|
||||
|
|
@ -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,11 +53,10 @@ 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}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
169
tests/test_workflow_service_a30.py
Normal file
169
tests/test_workflow_service_a30.py
Normal file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue