Fresh fix for #119975 (PR #119993 was deleted; nothing to salvage). Three paths collapsed into app_not_running with the sentence '<slug> is not running. Start <slug>': (1) a server_json probe with the app running AND its endpoint PRESENT — the exact case from the report, where the only thing missing is Hermes' own MCP connection; (2) every non-server_json, non-interactive_session liveness kind; (3) static and unregistered liveness, which cannot observe the app at all yet still claimed it was stopped. Telling the user to start an app that IS running is the wrong instruction. Add a hermes_not_connected LivenessState ('<app>'s MCP connection is missing. Reconnect <app> in Hermes, then try again.'), map the running+endpoint-present branch and the static/unknown fallback to it, and wire it through the TUI gateway contract (PluginServerState) and the Desktop Plugins tab (AgentPluginServerState, SERVER_TONE, serverStates i18n in en/de/es/fr). Also stop composing the sentence from the declaration's slug: describe() takes an optional display_name, and _plugin_server_rows passes the curated catalog title (fallback: the manifest name) so the Plugins tab reads 'NVIDIA App' instead of a raw server slug. Fixes #119975
192 lines
7.8 KiB
Python
192 lines
7.8 KiB
Python
"""Application-backed MCP liveness parsing and user-facing status descriptions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Literal
|
|
|
|
from hermes_platform import declaration
|
|
from hermes_platform.host import facts
|
|
from hermes_platform.resolver.app import AppDef, AppResolver
|
|
from hermes_platform.resolver.availability import Availability, availability
|
|
from hermes_platform.resolver.base import Effort
|
|
from hermes_platform.resolver.core import CheckState
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LivenessKind = Literal["static", "server_json", "interactive_session"]
|
|
LivenessState = Literal[
|
|
"app_not_running",
|
|
"hermes_not_connected",
|
|
"endpoint_unavailable",
|
|
"no_interactive_session",
|
|
"version_too_old",
|
|
"missing_app",
|
|
]
|
|
Retry = Literal["after_user_action", "never_here"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Liveness:
|
|
kind: LivenessKind
|
|
path: str = ""
|
|
url_field: str = "http"
|
|
token_field: str = "token"
|
|
pid_field: str = "pid"
|
|
|
|
def app_definition(self, definition: AppDef) -> AppDef:
|
|
if self.kind != "server_json":
|
|
return definition
|
|
return replace(
|
|
definition,
|
|
liveness_kind="server_json",
|
|
liveness_path=self.path,
|
|
liveness_pid_key=self.pid_field,
|
|
liveness_url_key=self.url_field,
|
|
liveness_token_key=self.token_field,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Status:
|
|
state: LivenessState
|
|
availability: Availability
|
|
liveness: Liveness
|
|
user_action: str
|
|
retry: Retry
|
|
|
|
|
|
def parse_liveness(raw: Any) -> Liveness:
|
|
"""Parse one portable-plugin liveness declaration."""
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("liveness must be an object")
|
|
kind = raw.get("kind")
|
|
if kind == "static":
|
|
if set(raw) != {"kind"}:
|
|
raise ValueError("static liveness only accepts 'kind'")
|
|
return Liveness("static")
|
|
if kind == "interactive_session":
|
|
if set(raw) != {"kind"}:
|
|
raise ValueError("interactive_session liveness only accepts 'kind'")
|
|
return Liveness("interactive_session")
|
|
if kind != "server_json":
|
|
raise ValueError(f"unknown liveness kind: {kind!r}")
|
|
if set(raw) - {"kind", "path", "fields"}:
|
|
raise ValueError("server_json liveness has unknown fields")
|
|
path = raw.get("path")
|
|
fields = {"url": "http", "token": "token", "pid": "pid", **(raw.get("fields") or {})}
|
|
if not isinstance(path, str) or not path.strip():
|
|
raise ValueError("server_json liveness requires a non-empty path")
|
|
if set(fields) != {"url", "token", "pid"}:
|
|
raise ValueError("server_json liveness fields may only override url, token, and pid")
|
|
if any(not isinstance(value, str) or not value.strip() for value in fields.values()):
|
|
raise ValueError("server_json liveness field names must be non-empty strings")
|
|
return Liveness(
|
|
"server_json",
|
|
path=path.strip(),
|
|
url_field=fields["url"].strip(),
|
|
token_field=fields["token"].strip(),
|
|
pid_field=fields["pid"].strip(),
|
|
)
|
|
|
|
|
|
def liveness_for(server_name: str) -> Liveness:
|
|
"""Return a server's registered liveness declaration, defaulting to static."""
|
|
try:
|
|
from hermes_cli.agent_plugins import liveness_for as registered_liveness
|
|
except ImportError:
|
|
return Liveness("static")
|
|
raw = registered_liveness(server_name)
|
|
if raw is None:
|
|
return Liveness("static")
|
|
try:
|
|
return parse_liveness(raw)
|
|
except ValueError as exc:
|
|
logger.warning("MCP server '%s' has an invalid liveness declaration (%s); treating it as static", server_name, exc)
|
|
return Liveness("static")
|
|
|
|
|
|
def _action(state: LivenessState, app_name: str) -> tuple[str, Retry]:
|
|
actions: dict[LivenessState, tuple[str, Retry]] = {
|
|
"app_not_running": (f"Start {app_name}, then try again.", "after_user_action"),
|
|
"hermes_not_connected": (f"Reconnect {app_name} in Hermes, then try again.", "after_user_action"),
|
|
"endpoint_unavailable": (f"Open {app_name} and enable its local connection, then try again.", "after_user_action"),
|
|
"no_interactive_session": (f"Open an interactive desktop session and start {app_name}, then try again.", "never_here"),
|
|
"version_too_old": (f"Update {app_name}, then try again.", "after_user_action"),
|
|
"missing_app": (f"Install {app_name}, then try again.", "after_user_action"),
|
|
}
|
|
return actions[state]
|
|
|
|
|
|
def status(server_name: str) -> Status | None:
|
|
"""Return the current unavailable state for a registered declaration."""
|
|
decl = declaration.lookup(server_name)
|
|
if decl is None:
|
|
return None
|
|
available = availability(decl)
|
|
live = liveness_for(server_name)
|
|
if available.state in {"missing_app", "unsupported_os"}:
|
|
state: LivenessState = "missing_app"
|
|
elif available.state == "version_too_old":
|
|
state = "version_too_old"
|
|
elif live.kind == "interactive_session" and not facts.interactive_session():
|
|
state = "no_interactive_session"
|
|
elif live.kind == "server_json":
|
|
definition = decl.app_for(facts.os_family())
|
|
if definition is None:
|
|
state = "missing_app"
|
|
else:
|
|
probe = AppResolver(live.app_definition(definition)).probe(
|
|
AppResolver(live.app_definition(definition)).locate(), effort=Effort.LOCAL
|
|
)
|
|
if probe.running.value is not True:
|
|
state = "app_not_running"
|
|
elif probe.endpoint.state is not CheckState.PRESENT:
|
|
state = "endpoint_unavailable"
|
|
else:
|
|
# The app runs and its endpoint answers: the only thing missing is Hermes' own
|
|
# MCP connection to it. Telling the user to "start" an app that IS running is
|
|
# the wrong instruction (#119975).
|
|
state = "hermes_not_connected"
|
|
else:
|
|
# static / unknown liveness kinds cannot observe the app, so they cannot conclude it
|
|
# is not running; the honest answer is that Hermes is not connected (#119975).
|
|
state = "hermes_not_connected"
|
|
action, retry = _action(state, decl.name)
|
|
return Status(state, available, live, action, retry)
|
|
|
|
|
|
def describe(decl: declaration.Declaration, available: Availability, liveness_state: LivenessState,
|
|
display_name: str | None = None) -> str:
|
|
"""Compose one unavailable-state sentence with one user action.
|
|
|
|
``display_name`` overrides the declaration's slug when the caller knows the plugin's
|
|
catalog/manifest title — the Plugins tab does, and a raw slug reads like an error code."""
|
|
app_name = display_name or decl.name
|
|
action, _retry = _action(liveness_state, app_name)
|
|
if liveness_state == "missing_app":
|
|
reason = f"{app_name} is not installed."
|
|
elif liveness_state == "version_too_old":
|
|
found = f" version {available.version}" if available.version else ""
|
|
minimum = f"; version {available.min_version} or newer is required" if available.min_version else ""
|
|
reason = f"{app_name}{found} is too old{minimum}."
|
|
elif liveness_state == "no_interactive_session":
|
|
reason = f"{app_name} needs an interactive desktop session."
|
|
elif liveness_state == "endpoint_unavailable":
|
|
reason = f"{app_name}'s local endpoint is unavailable."
|
|
elif liveness_state == "hermes_not_connected":
|
|
reason = f"{app_name}'s MCP connection is missing."
|
|
else:
|
|
reason = f"{app_name} is not running."
|
|
return f"{reason} {action}"
|
|
|
|
|
|
def unavailable_details(server_name: str) -> tuple[declaration.Declaration, Status, str] | None:
|
|
"""Return declaration, structured state, and its composed sentence."""
|
|
decl = declaration.lookup(server_name)
|
|
current = status(server_name)
|
|
if decl is None or current is None:
|
|
return None
|
|
return decl, current, describe(decl, current.availability, current.state)
|