180 lines
5.9 KiB
Python
180 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Keep Hermes agent project coordination and model routes current."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import asdict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from hermes_model_routing import (
|
|
_atomic_write,
|
|
_read_env,
|
|
configure_routes,
|
|
discover_claude_models,
|
|
discover_codex_models,
|
|
)
|
|
|
|
|
|
CASSANDRA_PATH = Path("/opt/data/workspace/projects/cassandra")
|
|
CASSANDRA_REMOTE = "https://scm.bstein.dev/bstein/cassandra.git"
|
|
|
|
|
|
def bootstrap_cassandra(root: Path) -> None:
|
|
"""Create the initial isolated board and project without resetting user state."""
|
|
os.environ["HERMES_HOME"] = str(root)
|
|
from hermes_cli import kanban_db as kb
|
|
from hermes_cli import projects_db as pdb
|
|
|
|
first_create = not kb.board_exists("cassandra")
|
|
kb.create_board(
|
|
"cassandra",
|
|
name="Cassandra",
|
|
description="Objectives, implementation tasks, reviews, and evidence for Cassandra.",
|
|
default_workdir=str(CASSANDRA_PATH),
|
|
)
|
|
if first_create:
|
|
kb.set_current_board("cassandra")
|
|
|
|
CASSANDRA_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with pdb.connect_closing() as connection:
|
|
project = pdb.get_project(connection, "cassandra")
|
|
if project is None:
|
|
project_id = pdb.create_project(
|
|
connection,
|
|
name="Cassandra",
|
|
slug="cassandra",
|
|
folders=[str(CASSANDRA_PATH)],
|
|
primary_path=str(CASSANDRA_PATH),
|
|
description="Cassandra project objectives and coordinated delivery.",
|
|
board_slug="cassandra",
|
|
)
|
|
if pdb.get_active_id(connection) is None:
|
|
pdb.set_active(connection, project_id)
|
|
else:
|
|
pdb.update_project(
|
|
connection,
|
|
project.id,
|
|
name="Cassandra",
|
|
description="Cassandra project objectives and coordinated delivery.",
|
|
board_slug="cassandra",
|
|
)
|
|
pdb.add_folder(connection, project.id, str(CASSANDRA_PATH), is_primary=True)
|
|
|
|
|
|
def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
|
"""Clone or fetch Cassandra when the optional Gitea token is available."""
|
|
token = env_values.get("GITEA_TOKEN", "").strip()
|
|
if shutil.which("git") is None:
|
|
return "git-unavailable"
|
|
if (CASSANDRA_PATH / ".git").is_dir():
|
|
if not token:
|
|
return "ready; fetch skipped until Gitea token is configured"
|
|
command = [
|
|
"git",
|
|
"-C",
|
|
str(CASSANDRA_PATH),
|
|
"fetch",
|
|
"--quiet",
|
|
"--prune",
|
|
"origin",
|
|
]
|
|
else:
|
|
CASSANDRA_PATH.mkdir(parents=True, exist_ok=True)
|
|
if any(CASSANDRA_PATH.iterdir()):
|
|
return "unmanaged-nonempty-directory"
|
|
if not token:
|
|
return "awaiting-gitea-token"
|
|
command = ["git", "clone", "--quiet", CASSANDRA_REMOTE, str(CASSANDRA_PATH)]
|
|
child_env = os.environ.copy()
|
|
child_env.update(env_values)
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
env=child_env,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=180,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return "sync-failed"
|
|
return (
|
|
"ready" if completed.returncode == 0 else f"sync-failed-{completed.returncode}"
|
|
)
|
|
|
|
|
|
def refresh_once(root: Path) -> dict[str, Any]:
|
|
"""Refresh provider catalogs and all managed coordinator state once."""
|
|
env_values = _read_env(root / ".env")
|
|
if env_values.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = env_values["CLAUDE_CODE_OAUTH_TOKEN"]
|
|
codex = discover_codex_models()
|
|
claude = discover_claude_models()
|
|
routes = configure_routes(root, codex, claude)
|
|
bootstrap_cassandra(root)
|
|
repo_state = sync_cassandra_repo(env_values)
|
|
status = {
|
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
"refresh_interval_hours": 1,
|
|
"providers": {
|
|
codex.provider: asdict(codex),
|
|
claude.provider: asdict(claude),
|
|
},
|
|
"routes": routes,
|
|
"projects": {
|
|
"cassandra": {
|
|
"board": "cassandra",
|
|
"workspace": str(CASSANDRA_PATH),
|
|
"repository": CASSANDRA_REMOTE,
|
|
"state": repo_state,
|
|
}
|
|
},
|
|
}
|
|
_atomic_write(
|
|
root / "workspace" / "coordinator" / "model-routing.json",
|
|
json.dumps(status, indent=2, sort_keys=True) + "\n",
|
|
)
|
|
return status
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--once", action="store_true", help="refresh once and exit")
|
|
parser.add_argument("--loop", action="store_true", help="refresh until stopped")
|
|
parser.add_argument(
|
|
"--interval", type=int, default=3600, help="loop interval in seconds"
|
|
)
|
|
args = parser.parse_args()
|
|
root = Path(os.environ.get("HERMES_HOME", "/opt/data")).resolve()
|
|
while True:
|
|
try:
|
|
status = refresh_once(root)
|
|
states = ", ".join(
|
|
f"{name}={details['state']}"
|
|
for name, details in status["providers"].items()
|
|
)
|
|
print(f"Hermes coordinator refresh complete: {states}", flush=True)
|
|
except Exception as error:
|
|
print(
|
|
f"Hermes coordinator refresh failed: {type(error).__name__}: {error}",
|
|
flush=True,
|
|
)
|
|
if args.once or not args.loop:
|
|
return 1
|
|
if args.once or not args.loop:
|
|
return 0
|
|
time.sleep(max(300, args.interval))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|