hkuds--openharness
451 行
16 KiB
Python
451 行
16 KiB
Python
"""Gateway service lifecycle for ohmo."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import os.path
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
if sys.platform == "win32":
|
|
import ctypes
|
|
|
|
from openharness.channels.bus.events import OutboundMessage
|
|
from openharness.channels.bus.queue import MessageBus
|
|
from openharness.channels.impl.manager import ChannelManager
|
|
|
|
from ohmo.gateway.bridge import OhmoGatewayBridge
|
|
from ohmo.gateway.config import build_channel_manager_config, load_gateway_config
|
|
from ohmo.gateway.models import GatewayState
|
|
from ohmo.gateway.runtime import OhmoSessionRuntimePool
|
|
from ohmo.workspace import (
|
|
get_gateway_restart_notice_path,
|
|
get_logs_dir,
|
|
get_state_path,
|
|
get_workspace_root,
|
|
initialize_workspace,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
class OhmoGatewayService:
|
|
"""Foreground/background service wrapper for the personal gateway."""
|
|
|
|
def __init__(self, cwd: str | Path | None = None, workspace: str | Path | None = None) -> None:
|
|
self._cwd = str(Path(cwd or Path.cwd()).resolve())
|
|
self._workspace = workspace
|
|
os.chdir(self._cwd)
|
|
root = initialize_workspace(self._workspace)
|
|
os.environ["OHMO_WORKSPACE"] = str(root)
|
|
self._config = load_gateway_config(self._workspace)
|
|
if self._config.allow_remote_admin_commands and self._config.allowed_remote_admin_commands:
|
|
logger.warning(
|
|
"ohmo gateway remote administrative commands enabled commands=%s",
|
|
",".join(self._config.allowed_remote_admin_commands),
|
|
)
|
|
self._bus = MessageBus()
|
|
self._manager = ChannelManager(build_channel_manager_config(self._config), self._bus)
|
|
self._runtime_pool = OhmoSessionRuntimePool(
|
|
cwd=self._cwd,
|
|
workspace=self._workspace,
|
|
provider_profile=self._config.provider_profile,
|
|
create_feishu_group=self.create_group_for_user,
|
|
publish_group_welcome=self.publish_group_welcome,
|
|
)
|
|
self._stop_event: asyncio.Event | None = None
|
|
self._restart_requested = False
|
|
self._bridge = OhmoGatewayBridge(
|
|
bus=self._bus,
|
|
runtime_pool=self._runtime_pool,
|
|
restart_gateway=self.request_restart,
|
|
workspace=root,
|
|
feishu_group_policy=str(
|
|
self._config.channel_configs.get("feishu", {}).get("group_policy", "managed_or_mention")
|
|
),
|
|
)
|
|
|
|
@property
|
|
def pid_file(self) -> Path:
|
|
return get_workspace_root(self._workspace) / "gateway.pid"
|
|
|
|
@property
|
|
def log_file(self) -> Path:
|
|
return get_logs_dir(self._workspace) / "gateway.log"
|
|
|
|
@property
|
|
def state_file(self) -> Path:
|
|
return get_state_path(self._workspace)
|
|
|
|
def _channel_last_error(self) -> str | None:
|
|
for name, channel in self._manager.channels.items():
|
|
error = getattr(channel, "last_error", None)
|
|
if error:
|
|
return f"{name}: {error}"
|
|
return None
|
|
|
|
def write_state(self, *, running: bool, last_error: str | None = None) -> None:
|
|
state = GatewayState(
|
|
running=running,
|
|
pid=os.getpid() if running else None,
|
|
active_sessions=self._runtime_pool.active_sessions,
|
|
provider_profile=self._config.provider_profile,
|
|
enabled_channels=self._config.enabled_channels,
|
|
last_error=last_error or self._channel_last_error(),
|
|
)
|
|
self.state_file.write_text(state.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
|
|
async def request_restart(self, message, session_key: str) -> None:
|
|
"""Ask the foreground gateway loop to restart itself."""
|
|
restart_notice = {
|
|
"channel": message.channel,
|
|
"chat_id": message.chat_id,
|
|
"session_key": session_key,
|
|
"content": "✅ gateway 已经重新连上,可以继续了。\nGateway is back online. We can continue.",
|
|
}
|
|
get_gateway_restart_notice_path(self._workspace).write_text(
|
|
json.dumps(restart_notice, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
self._restart_requested = True
|
|
# Let the outbound dispatcher flush the restart notice to the IM channel
|
|
# before we tear down the bridge and channel connections.
|
|
await asyncio.sleep(0.75)
|
|
if self._stop_event is not None:
|
|
self._stop_event.set()
|
|
|
|
async def create_group(self, message, name: str) -> str:
|
|
"""Create a managed group through the active channel implementation."""
|
|
if message.channel != "feishu":
|
|
raise RuntimeError(f"{message.channel} does not support managed group creation.")
|
|
return await self.create_group_for_user(str(message.sender_id), name)
|
|
|
|
async def create_group_for_user(self, user_open_id: str, name: str) -> str:
|
|
"""Create a managed Feishu group for a user open_id."""
|
|
channel = self._manager.get_channel("feishu")
|
|
if channel is None:
|
|
raise RuntimeError("Feishu channel is not enabled.")
|
|
creator = getattr(channel, "create_managed_group", None)
|
|
if creator is None:
|
|
raise RuntimeError("Feishu channel does not support managed group creation.")
|
|
result = creator(user_open_id=str(user_open_id), name=name)
|
|
return str(await result if asyncio.iscoroutine(result) else result)
|
|
|
|
async def publish_group_welcome(self, chat_id: str, content: str, owner_open_id: str) -> None:
|
|
"""Send a welcome message to a newly created managed group."""
|
|
await self._bus.publish_outbound(
|
|
OutboundMessage(
|
|
channel="feishu",
|
|
chat_id=chat_id,
|
|
content=content,
|
|
metadata={"chat_type": "group", "_session_key": f"feishu:{chat_id}:{owner_open_id}"},
|
|
)
|
|
)
|
|
|
|
def _exec_restart(self) -> None:
|
|
root = str(get_workspace_root(self._workspace))
|
|
argv = [
|
|
sys.executable,
|
|
"-m",
|
|
"ohmo",
|
|
"gateway",
|
|
"run",
|
|
"--cwd",
|
|
self._cwd,
|
|
"--workspace",
|
|
root,
|
|
]
|
|
logger.info("ohmo gateway restarting in-place argv=%s", argv)
|
|
os.execv(sys.executable, argv)
|
|
|
|
async def _publish_pending_restart_notice(self) -> None:
|
|
path = get_gateway_restart_notice_path(self._workspace)
|
|
if not path.exists():
|
|
return
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
channel = payload.get("channel")
|
|
chat_id = payload.get("chat_id")
|
|
content = payload.get("content")
|
|
session_key = payload.get("session_key")
|
|
if not isinstance(channel, str) or not isinstance(chat_id, str) or not isinstance(content, str):
|
|
return
|
|
await asyncio.sleep(2.0)
|
|
await self._bus.publish_outbound(
|
|
OutboundMessage(
|
|
channel=channel,
|
|
chat_id=chat_id,
|
|
content=content,
|
|
metadata={"_session_key": session_key} if isinstance(session_key, str) else {},
|
|
)
|
|
)
|
|
logger.info(
|
|
"ohmo gateway published restart confirmation channel=%s chat_id=%s session_key=%s",
|
|
channel,
|
|
chat_id,
|
|
session_key,
|
|
)
|
|
finally:
|
|
path.unlink(missing_ok=True)
|
|
|
|
async def run_foreground(self) -> int:
|
|
self.pid_file.write_text(str(os.getpid()), encoding="utf-8")
|
|
self.write_state(running=True)
|
|
bridge_task = asyncio.create_task(self._bridge.run(), name="ohmo-gateway-bridge")
|
|
manager_task = asyncio.create_task(self._manager.start_all(), name="ohmo-gateway-channels")
|
|
restart_notice_task = asyncio.create_task(
|
|
self._publish_pending_restart_notice(),
|
|
name="ohmo-gateway-restart-notice",
|
|
)
|
|
stop_event = asyncio.Event()
|
|
self._stop_event = stop_event
|
|
self._restart_requested = False
|
|
|
|
def _stop(*_: object) -> None:
|
|
stop_event.set()
|
|
|
|
loop = asyncio.get_running_loop()
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
with contextlib.suppress(NotImplementedError):
|
|
loop.add_signal_handler(sig, _stop)
|
|
|
|
async def _state_heartbeat() -> None:
|
|
while not stop_event.is_set():
|
|
self.write_state(running=True)
|
|
await asyncio.sleep(5.0)
|
|
|
|
state_task = asyncio.create_task(_state_heartbeat(), name="ohmo-gateway-state")
|
|
|
|
try:
|
|
await stop_event.wait()
|
|
except Exception as exc:
|
|
self.write_state(running=False, last_error=str(exc))
|
|
raise
|
|
finally:
|
|
self._bridge.stop()
|
|
bridge_task.cancel()
|
|
manager_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await bridge_task
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await manager_task
|
|
if not state_task.done():
|
|
state_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await state_task
|
|
if not restart_notice_task.done():
|
|
restart_notice_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await restart_notice_task
|
|
await self._manager.stop_all()
|
|
self.write_state(running=False)
|
|
self.pid_file.unlink(missing_ok=True)
|
|
self._stop_event = None
|
|
if self._restart_requested:
|
|
self._exec_restart()
|
|
return 0
|
|
|
|
|
|
def start_gateway_process(cwd: str | Path | None = None, workspace: str | Path | None = None) -> int:
|
|
"""Start the gateway as a detached subprocess."""
|
|
service = OhmoGatewayService(cwd, workspace)
|
|
service.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
env = os.environ.copy()
|
|
pythonpath_entries = [str(_REPO_ROOT)]
|
|
existing_pythonpath = env.get("PYTHONPATH")
|
|
if existing_pythonpath:
|
|
pythonpath_entries.append(existing_pythonpath)
|
|
env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries)
|
|
|
|
popen_kwargs: dict = {
|
|
"cwd": service._cwd,
|
|
"stdout": None,
|
|
"stderr": None,
|
|
"env": env,
|
|
}
|
|
if sys.platform == "win32":
|
|
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
|
|
popen_kwargs["stdin"] = subprocess.DEVNULL
|
|
else:
|
|
popen_kwargs["start_new_session"] = True
|
|
|
|
with service.log_file.open("a", encoding="utf-8") as log_file:
|
|
popen_kwargs["stdout"] = log_file
|
|
popen_kwargs["stderr"] = log_file
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"ohmo",
|
|
"gateway",
|
|
"run",
|
|
"--cwd",
|
|
service._cwd,
|
|
"--workspace",
|
|
str(get_workspace_root(workspace)),
|
|
"--no-console-log",
|
|
],
|
|
**popen_kwargs,
|
|
)
|
|
return process.pid
|
|
|
|
|
|
def _pid_is_running(pid: int) -> bool:
|
|
if sys.platform == "win32":
|
|
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
if not handle:
|
|
return False
|
|
try:
|
|
exit_code = ctypes.c_ulong()
|
|
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
return exit_code.value == 259 # STILL_ACTIVE
|
|
return False
|
|
finally:
|
|
kernel32.CloseHandle(handle)
|
|
else:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _iter_workspace_gateway_pids(workspace: str | Path | None = None) -> list[int]:
|
|
root = str(get_workspace_root(workspace))
|
|
if sys.platform == "win32":
|
|
try:
|
|
result = subprocess.run(
|
|
["wmic", "process", "where",
|
|
f"commandline like '%-m ohmo gateway run%' and commandline like '%--workspace {root}%'",
|
|
"get", "processid"],
|
|
capture_output=True, text=True, check=True,
|
|
)
|
|
except Exception:
|
|
return []
|
|
current_pid = os.getpid()
|
|
pids: list[int] = []
|
|
for line in result.stdout.splitlines():
|
|
line = line.strip()
|
|
if not line or line.lower() == "processid":
|
|
continue
|
|
try:
|
|
pid = int(line)
|
|
except ValueError:
|
|
continue
|
|
if pid == current_pid:
|
|
continue
|
|
if _pid_is_running(pid):
|
|
pids.append(pid)
|
|
return pids
|
|
else:
|
|
try:
|
|
result = subprocess.run(
|
|
["ps", "-eo", "pid=,args="],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
except Exception:
|
|
return []
|
|
|
|
current_pid = os.getpid()
|
|
pids = []
|
|
for line in result.stdout.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
pid_text, args = line.split(None, 1)
|
|
pid = int(pid_text)
|
|
except ValueError:
|
|
continue
|
|
if pid == current_pid:
|
|
continue
|
|
if "-m ohmo gateway run" not in args:
|
|
continue
|
|
if f"--workspace {root}" not in args:
|
|
continue
|
|
if _pid_is_running(pid):
|
|
pids.append(pid)
|
|
return pids
|
|
|
|
|
|
def stop_gateway_process(cwd: str | Path | None = None, workspace: str | Path | None = None) -> bool:
|
|
"""Stop the background gateway process if present."""
|
|
service = OhmoGatewayService(cwd, workspace)
|
|
pids: list[int] = []
|
|
if service.pid_file.exists():
|
|
try:
|
|
pids.append(int(service.pid_file.read_text(encoding="utf-8").strip()))
|
|
except ValueError:
|
|
pass
|
|
pids.extend(_iter_workspace_gateway_pids(workspace))
|
|
unique_pids = []
|
|
for pid in pids:
|
|
if pid not in unique_pids and _pid_is_running(pid):
|
|
unique_pids.append(pid)
|
|
if not unique_pids:
|
|
service.pid_file.unlink(missing_ok=True)
|
|
return False
|
|
if sys.platform == "win32":
|
|
for pid in unique_pids:
|
|
with contextlib.suppress(Exception):
|
|
subprocess.run(
|
|
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
else:
|
|
for pid in unique_pids:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.kill(pid, signal.SIGTERM)
|
|
service.pid_file.unlink(missing_ok=True)
|
|
service.write_state(running=False)
|
|
return True
|
|
|
|
|
|
def gateway_status(cwd: str | Path | None = None, workspace: str | Path | None = None) -> GatewayState:
|
|
"""Load the last known gateway state."""
|
|
service = OhmoGatewayService(cwd, workspace)
|
|
live_pid: int | None = None
|
|
if service.pid_file.exists():
|
|
try:
|
|
pid = int(service.pid_file.read_text(encoding="utf-8").strip())
|
|
except ValueError:
|
|
pid = None
|
|
if pid is not None and _pid_is_running(pid):
|
|
live_pid = pid
|
|
if live_pid is None:
|
|
live_pids = _iter_workspace_gateway_pids(workspace)
|
|
if live_pids:
|
|
live_pid = live_pids[0]
|
|
service.pid_file.write_text(str(live_pid), encoding="utf-8")
|
|
else:
|
|
service.pid_file.unlink(missing_ok=True)
|
|
|
|
active_sessions = 0
|
|
last_error: str | None = None
|
|
if service.state_file.exists():
|
|
with contextlib.suppress(Exception):
|
|
state = GatewayState.model_validate_json(service.state_file.read_text(encoding="utf-8"))
|
|
active_sessions = state.active_sessions
|
|
last_error = state.last_error
|
|
|
|
return GatewayState(
|
|
running=live_pid is not None,
|
|
pid=live_pid,
|
|
active_sessions=active_sessions,
|
|
provider_profile=service._config.provider_profile,
|
|
enabled_channels=service._config.enabled_channels,
|
|
last_error=last_error,
|
|
)
|