项目文件夹

文件
2026-06-04 09:10:47 +08:00

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,
)