Merge branch 'simp/r3-26-C' into simp/integration3
This commit is contained in:
@@ -177,10 +177,8 @@ def _profile_targets(log_label: str, *, lightweight: bool) -> List[Tuple[str, Pa
|
||||
config/meta and probes gateways/skills per profile — too heavy per sidebar refresh."""
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
try:
|
||||
if lightweight:
|
||||
targets = list(profiles_mod.profiles_to_serve(multiplex=True))
|
||||
else:
|
||||
targets = [(info.name, info.path) for info in profiles_mod.list_profiles()]
|
||||
targets = (list(profiles_mod.profiles_to_serve(multiplex=True)) if lightweight
|
||||
else [(info.name, info.path) for info in profiles_mod.list_profiles()])
|
||||
except Exception:
|
||||
_log.exception("%s: list_profiles failed", log_label)
|
||||
targets = []
|
||||
@@ -309,11 +307,6 @@ def _sidebar_singleflight_cache(func):
|
||||
refresh_lock = threading.Lock()
|
||||
miss = object()
|
||||
|
||||
def _key(args, kwargs):
|
||||
bound = signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
return tuple(bound.arguments.items())
|
||||
|
||||
def _lookup(key):
|
||||
now = time.monotonic()
|
||||
with cache_lock:
|
||||
@@ -330,7 +323,9 @@ def _sidebar_singleflight_cache(func):
|
||||
if ttl <= 0:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
key = _key(args, kwargs)
|
||||
bound = signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
key = tuple(bound.arguments.items())
|
||||
cached = _lookup(key)
|
||||
if cached is not miss:
|
||||
return cached
|
||||
@@ -359,11 +354,6 @@ def _sidebar_singleflight_cache(func):
|
||||
cache.popitem(last=False)
|
||||
return result
|
||||
|
||||
def cache_clear():
|
||||
with cache_lock:
|
||||
cache.clear()
|
||||
|
||||
wrapped.cache_clear = cache_clear
|
||||
return wrapped
|
||||
|
||||
|
||||
@@ -388,10 +378,8 @@ def get_profiles_sessions(
|
||||
if order not in ("created", "recent"):
|
||||
raise HTTPException(status_code=400, detail="order must be one of: created, recent")
|
||||
|
||||
if profile and profile != "all":
|
||||
targets = [_cron_profile_home(profile)]
|
||||
else:
|
||||
targets = _profile_targets("GET /api/profiles/sessions", lightweight=True)
|
||||
targets = ([_cron_profile_home(profile)] if profile and profile != "all"
|
||||
else _profile_targets("GET /api/profiles/sessions", lightweight=True))
|
||||
|
||||
# Source scoping (see /api/sessions): recents pass exclude_sources=cron, the cron-jobs
|
||||
# section source=cron — two independent lists so cron sessions can't starve recents.
|
||||
@@ -594,11 +582,10 @@ def get_profiles_projects_tree(preview_limit: int = 3, session_limit: int = 2000
|
||||
scoped_session_ids.extend(tree["scoped_session_ids"])
|
||||
_read_profile_db(name, home, errors, _read)
|
||||
|
||||
return {
|
||||
"projects": sorted(merged.values(), key=lambda p: p.get("lastActive") or 0, reverse=True),
|
||||
# Ownership is per profile, so no project is "the active one" here; the desktop only
|
||||
# reads active_id to bias its overview sort.
|
||||
"active_id": None, "scoped_session_ids": scoped_session_ids, "errors": errors}
|
||||
# active_id is None: ownership is per profile, so no project is "the active one" here.
|
||||
projects = sorted(merged.values(), key=lambda p: p.get("lastActive") or 0, reverse=True)
|
||||
return {"projects": projects, "active_id": None, "scoped_session_ids": scoped_session_ids,
|
||||
"errors": errors}
|
||||
|
||||
|
||||
# `gh pr create` prints the PR url and nothing else, so a tool result whose whole output IS a
|
||||
@@ -690,14 +677,12 @@ async def create_profile_endpoint(body: ProfileCreate):
|
||||
"Setting model for new profile %s failed", body.name,
|
||||
fn=lambda: (_write_profile_model(path, provider, model), True)[1], default=False)
|
||||
mcp_written = _best_effort(
|
||||
"Writing MCP servers for new profile %s failed", body.name,
|
||||
fn=lambda: _write_profile_mcp_servers(path, body.mcp_servers), default=0
|
||||
) if body.mcp_servers else 0
|
||||
"Writing MCP servers for new profile %s failed", body.name, default=0,
|
||||
fn=lambda: _write_profile_mcp_servers(path, body.mcp_servers)) if body.mcp_servers else 0
|
||||
# "keep" has replace semantics; skipped when empty (legacy: keep the bundle).
|
||||
skills_disabled = _best_effort(
|
||||
"Applying skill selection for new profile %s failed", body.name,
|
||||
fn=lambda: _disable_unselected_skills(path, body.keep_skills), default=0
|
||||
) if body.keep_skills else 0
|
||||
"Applying skill selection for new profile %s failed", body.name, default=0,
|
||||
fn=lambda: _disable_unselected_skills(path, body.keep_skills)) if body.keep_skills else 0
|
||||
|
||||
# Hub installs spawn async, scoped via `-p <name>` (a fresh subprocess re-binds
|
||||
# skills_hub.SKILLS_DIR at import). PIDs go back for the UI to poll.
|
||||
@@ -874,8 +859,8 @@ async def update_profile_description_endpoint(name: str, body: ProfileDescriptio
|
||||
|
||||
@router.put("/api/profiles/{name}/model")
|
||||
async def update_profile_model_endpoint(name: str, body: ProfileModelUpdate):
|
||||
"""Set the main model for a specific profile's config.yaml without touching the
|
||||
dashboard's own profile — ``POST /api/model/set`` (main scope) via the HERMES_HOME override."""
|
||||
"""Set the main model for a specific profile's config.yaml without touching the dashboard's
|
||||
own profile — ``POST /api/model/set`` (main scope) via the HERMES_HOME override."""
|
||||
profile_dir = _resolve_profile_dir(name)
|
||||
provider = (body.provider or "").strip()
|
||||
model = (body.model or "").strip()
|
||||
@@ -903,12 +888,9 @@ async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto):
|
||||
not_found=(), bad_request=()):
|
||||
# A synchronous LLM round-trip with a 60 s ceiling; on the loop it stalls everything.
|
||||
outcome = await run_in_threadpool(_run)
|
||||
return {
|
||||
"ok": bool(outcome.ok),
|
||||
"reason": outcome.reason,
|
||||
"description": outcome.description,
|
||||
# A failed sweep leaves any existing description untouched: not auto-authored.
|
||||
"description_auto": bool(outcome.ok)}
|
||||
# description_auto mirrors ok: a failed sweep leaves any existing description untouched.
|
||||
return {"ok": bool(outcome.ok), "reason": outcome.reason, "description": outcome.description,
|
||||
"description_auto": bool(outcome.ok)}
|
||||
|
||||
|
||||
# ── Export / Import ── wraps hermes_cli.profiles.export_profile / import_profile. Paths are
|
||||
@@ -954,10 +936,9 @@ async def import_profile_endpoint(body: ProfileImport):
|
||||
imported = profile_dir.name
|
||||
|
||||
# Match the CLI import flow: create the wrapper alias when it's safe.
|
||||
def _wrapper():
|
||||
if not profiles_mod.check_alias_collision(imported):
|
||||
profiles_mod.create_wrapper_script(imported)
|
||||
_best_effort("Creating wrapper for imported profile %s failed", imported, fn=_wrapper)
|
||||
_best_effort("Creating wrapper for imported profile %s failed", imported,
|
||||
fn=lambda: (profiles_mod.check_alias_collision(imported)
|
||||
or profiles_mod.create_wrapper_script(imported)))
|
||||
|
||||
# Bundled desktop appearance overlay, so the desktop needn't make another round-trip.
|
||||
desktop_overlay = None
|
||||
|
||||
@@ -58,6 +58,14 @@ _GATEWAY_HEALTH_ROUTE_TIMEOUT = 1.0
|
||||
_HEALTHY_PLATFORM_STATES = {"connected", "running", "ok"}
|
||||
|
||||
|
||||
def _safe_call(mod, fn_name: str, default):
|
||||
try:
|
||||
fn = getattr(mod, fn_name, None)
|
||||
return fn() if callable(fn) else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _count_status_active_sessions() -> int:
|
||||
"""Best-effort status garnish. Opens read-only (via the shared stale-schema heal) so
|
||||
/api/status never routinely writes to state.db while another Hermes process uses it."""
|
||||
@@ -95,20 +103,15 @@ async def get_ssh_ownership(request: Request):
|
||||
_require_token(request)
|
||||
if not _SSH_OWNER_NONCE:
|
||||
raise HTTPException(status_code=404, detail="SSH ownership is not active")
|
||||
return {
|
||||
"ok": True,
|
||||
"sshOwnerNonce": _SSH_OWNER_NONCE,
|
||||
"protocolVersion": 1,
|
||||
"runtimeIntact": _ssh_runtime_intact()}
|
||||
return {"ok": True, "sshOwnerNonce": _SSH_OWNER_NONCE, "protocolVersion": 1,
|
||||
"runtimeIntact": _ssh_runtime_intact()}
|
||||
|
||||
|
||||
@router.get("/api/health")
|
||||
async def get_health():
|
||||
"""Lightweight process liveness for desktop/backend readiness probes."""
|
||||
return {
|
||||
"ok": True,
|
||||
"version": __version__,
|
||||
"auth_required": bool(getattr(app.state, "auth_required", False))}
|
||||
return {"ok": True, "version": __version__,
|
||||
"auth_required": bool(getattr(app.state, "auth_required", False))}
|
||||
|
||||
|
||||
# Profile segment mirrors hermes_cli.profiles._PROFILE_ID_RE. Platform segment mirrors the
|
||||
@@ -197,10 +200,8 @@ def _project_gateway_platforms(gateway_platforms: dict, configured: "set[str] |
|
||||
entries are the diagnosis (credential collisions, auth failures) that the single
|
||||
exit_reason string can't express, so they are kept — upstream writer-identity/freshness
|
||||
filtering already dropped other processes' entries."""
|
||||
platforms = {
|
||||
key: _public_platform_entry(value)
|
||||
for key, value in gateway_platforms.items()
|
||||
if _status_platform_key_allowed(key, configured)}
|
||||
platforms = {key: _public_platform_entry(value) for key, value in gateway_platforms.items()
|
||||
if _status_platform_key_allowed(key, configured)}
|
||||
if gateway_running:
|
||||
return platforms
|
||||
if gateway_state == "startup_failed":
|
||||
@@ -326,15 +327,11 @@ async def _component_health(gateway: Dict[str, Any]) -> Dict[str, Any]:
|
||||
components["storage"] = {"status": storage_check.get("status", "degraded")}
|
||||
except Exception:
|
||||
components["storage"] = {"status": "degraded"}
|
||||
platform_states = [
|
||||
str(value.get("state") or value.get("status") or "").lower()
|
||||
for value in gateway_platforms.values()
|
||||
if isinstance(value, dict)]
|
||||
platform_states = [str(value.get("state") or value.get("status") or "").lower()
|
||||
for value in gateway_platforms.values() if isinstance(value, dict)]
|
||||
connected = sum(1 for state in platform_states if state in _HEALTHY_PLATFORM_STATES)
|
||||
components["platforms"] = {
|
||||
"status": "ok" if connected == len(platform_states) else "degraded",
|
||||
"configured": len(gateway_platforms),
|
||||
"connected": connected}
|
||||
components["platforms"] = {"status": "ok" if connected == len(platform_states) else "degraded",
|
||||
"configured": len(gateway_platforms), "connected": connected}
|
||||
return components
|
||||
|
||||
|
||||
@@ -411,13 +408,10 @@ async def get_status(profile: Optional[str] = None):
|
||||
auth = _auth_gate_status()
|
||||
|
||||
status = {
|
||||
"version": __version__,
|
||||
"release_date": __release_date__,
|
||||
"config_version": current_ver,
|
||||
"latest_config_version": latest_ver,
|
||||
"version": __version__, "release_date": __release_date__,
|
||||
"config_version": current_ver, "latest_config_version": latest_ver,
|
||||
"can_update_hermes": not _dashboard_local_update_managed_externally(),
|
||||
"gateway_running": gateway_running,
|
||||
"gateway_state": gateway_state,
|
||||
"gateway_running": gateway_running, "gateway_state": gateway_state,
|
||||
"gateway_platforms": gateway["gateway_platforms"],
|
||||
"gateway_exit_reason": gateway["gateway_exit_reason"],
|
||||
"gateway_updated_at": gateway["gateway_updated_at"],
|
||||
@@ -427,10 +421,8 @@ async def get_status(profile: Optional[str] = None):
|
||||
active_agents=active_agents),
|
||||
"gateway_drainable": derive_gateway_drainable(
|
||||
gateway_running=gateway_running, gateway_state=gateway_state),
|
||||
"restart_drain_timeout": restart_drain_timeout,
|
||||
"active_sessions": active_sessions,
|
||||
**auth,
|
||||
"nous_session_valid": _nous_session_validity()}
|
||||
"restart_drain_timeout": restart_drain_timeout, "active_sessions": active_sessions,
|
||||
**auth, "nous_session_valid": _nous_session_validity()}
|
||||
|
||||
# Stable per-install identity (first call may touch disk). Omitted (not null) when
|
||||
# unpersistable so older-client behavior and the no-identity fallback stay identical.
|
||||
@@ -455,12 +447,9 @@ async def get_status(profile: Optional[str] = None):
|
||||
# reaches this endpoint — surface them only on a loopback / ``--insecure`` bind.
|
||||
if not auth["auth_required"]:
|
||||
status.update({
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"config_path": str(get_config_path()),
|
||||
"env_path": str(get_env_path()),
|
||||
"gateway_pid": gateway["gateway_pid"],
|
||||
"gateway_health_url": _GATEWAY_HEALTH_URL,
|
||||
"gateways": topology["gateways"]})
|
||||
"hermes_home": str(get_hermes_home()), "config_path": str(get_config_path()),
|
||||
"env_path": str(get_env_path()), "gateway_pid": gateway["gateway_pid"],
|
||||
"gateway_health_url": _GATEWAY_HEALTH_URL, "gateways": topology["gateways"]})
|
||||
|
||||
return status
|
||||
finally:
|
||||
@@ -478,12 +467,10 @@ async def get_system_stats():
|
||||
**_display_system_platform(
|
||||
system=_platform.system(), release=_platform.release(), version=_platform.version(),
|
||||
platform_label=_platform.platform()),
|
||||
"arch": _platform.machine(),
|
||||
"hostname": _platform.node(),
|
||||
"arch": _platform.machine(), "hostname": _platform.node(),
|
||||
"python_version": _platform.python_version(),
|
||||
"python_impl": _platform.python_implementation(),
|
||||
"hermes_version": __version__,
|
||||
"cpu_count": os.cpu_count()}
|
||||
"hermes_version": __version__, "cpu_count": os.cpu_count()}
|
||||
|
||||
def _disk():
|
||||
du = psutil.disk_usage(str(get_hermes_home()))
|
||||
@@ -536,10 +523,7 @@ async def get_curator_status():
|
||||
from agent import curator
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}")
|
||||
try:
|
||||
state = curator.load_state()
|
||||
except Exception:
|
||||
state = {}
|
||||
state = _safe_call(curator, "load_state", {})
|
||||
return {
|
||||
"enabled": _safe_call(curator, "is_enabled", True),
|
||||
"paused": _safe_call(curator, "is_paused", False),
|
||||
@@ -619,14 +603,6 @@ async def update_learning_node(body: LearningNodeEdit):
|
||||
body.profile, lambda: edit_node(body.id, body.content), 400, "edit failed")
|
||||
|
||||
|
||||
def _safe_call(mod, fn_name: str, default):
|
||||
try:
|
||||
fn = getattr(mod, fn_name, None)
|
||||
return fn() if callable(fn) else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
# Portal — Nous Portal auth + Tool Gateway routing status (read-only).
|
||||
|
||||
|
||||
@@ -667,8 +643,7 @@ def _get_portal_status_sync():
|
||||
|
||||
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
|
||||
return {
|
||||
"logged_in": bool(auth.get("logged_in")),
|
||||
"portal_url": auth.get("portal_base_url"),
|
||||
"logged_in": bool(auth.get("logged_in")), "portal_url": auth.get("portal_base_url"),
|
||||
"inference_url": auth.get("inference_base_url"),
|
||||
"provider": str((model_cfg or {}).get("provider") or ""),
|
||||
"subscription_url": "https://portal.nousresearch.com/manage-subscription",
|
||||
@@ -710,12 +685,8 @@ async def run_debug_share_endpoint(body: DebugShareRequest | None = None):
|
||||
_log.exception("debug share failed")
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"urls": result.urls,
|
||||
"failures": result.failures,
|
||||
"redacted": result.redacted,
|
||||
"auto_delete_seconds": result.auto_delete_seconds}
|
||||
return {"ok": True, "urls": result.urls, "failures": result.failures,
|
||||
"redacted": result.redacted, "auto_delete_seconds": result.auto_delete_seconds}
|
||||
|
||||
|
||||
@logs_router.get("/api/logs")
|
||||
@@ -741,10 +712,8 @@ async def get_logs(
|
||||
if component and component.lower() != "all":
|
||||
comp_prefixes = COMPONENT_PREFIXES.get(component)
|
||||
if comp_prefixes is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown component: {component}. "
|
||||
f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}")
|
||||
raise HTTPException(status_code=400, detail=f"Unknown component: {component}. "
|
||||
f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}")
|
||||
result = _read_tail(
|
||||
log_path, min(lines, 500) if not search else 2000,
|
||||
has_filters=bool(min_level or comp_prefixes or search),
|
||||
|
||||
@@ -106,13 +106,11 @@ def _parse_model_ids(resp: "Any") -> List[str]:
|
||||
def _fallback_profile_entry(profiles_mod, name: str, home: Path, *, is_default: bool,
|
||||
has_env: bool, gateway_running: Callable[[], bool]) -> Dict[str, Any]:
|
||||
model, provider = _safe(lambda: profiles_mod._read_config_model(home), (None, None))
|
||||
|
||||
def meta(key, default):
|
||||
return _safe(lambda: profiles_mod.read_profile_meta(home).get(key, default), default)
|
||||
|
||||
meta = lambda key, default: _safe( # noqa: E731
|
||||
lambda: profiles_mod.read_profile_meta(home).get(key, default), default)
|
||||
return {
|
||||
"name": name, "path": str(home), "is_default": is_default,
|
||||
"model": model, "provider": provider, "has_env": has_env,
|
||||
"name": name, "path": str(home), "is_default": is_default, "model": model,
|
||||
"provider": provider, "has_env": has_env,
|
||||
"skill_count": _safe(lambda: profiles_mod._count_skills(home), 0),
|
||||
"gateway_running": _safe(gateway_running, False),
|
||||
"description": meta("description", ""), "description_auto": meta("description_auto", False),
|
||||
@@ -386,10 +384,8 @@ def _installed_hub_identifiers(profile: Optional[str] = None) -> dict:
|
||||
else:
|
||||
profile_dir = _resolve_profile_dir(profile.strip())
|
||||
lock = HubLockFile(profile_dir / "skills" / ".hub" / "lock.json")
|
||||
return {
|
||||
entry["identifier"]: {"name": entry.get("name"),
|
||||
"trust_level": entry.get("trust_level"),
|
||||
"scan_verdict": entry.get("scan_verdict")}
|
||||
for entry in lock.list_installed() if entry.get("identifier")}
|
||||
keys = ("name", "trust_level", "scan_verdict")
|
||||
return {entry["identifier"]: {k: entry.get(k) for k in keys}
|
||||
for entry in lock.list_installed() if entry.get("identifier")}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user