feat(installer): improve dev setup and gateway restart flow
This commit is contained in:
+30
-1
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from openharness.channels.bus.events import OutboundMessage
|
||||
from openharness.channels.bus.queue import MessageBus
|
||||
@@ -52,9 +53,16 @@ def _format_gateway_error(exc: Exception) -> str:
|
||||
class OhmoGatewayBridge:
|
||||
"""Consume inbound messages and publish assistant replies."""
|
||||
|
||||
def __init__(self, *, bus: MessageBus, runtime_pool: OhmoSessionRuntimePool) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: MessageBus,
|
||||
runtime_pool: OhmoSessionRuntimePool,
|
||||
restart_gateway: Callable[[object, str], Awaitable[None] | None] | None = None,
|
||||
) -> None:
|
||||
self._bus = bus
|
||||
self._runtime_pool = runtime_pool
|
||||
self._restart_gateway = restart_gateway
|
||||
self._running = False
|
||||
self._session_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._session_cancel_reasons: dict[str, str] = {}
|
||||
@@ -81,6 +89,9 @@ class OhmoGatewayBridge:
|
||||
if message.content.strip() == "/stop":
|
||||
await self._handle_stop(message, session_key)
|
||||
continue
|
||||
if message.content.strip() == "/restart":
|
||||
await self._handle_restart(message, session_key)
|
||||
continue
|
||||
await self._interrupt_session(
|
||||
session_key,
|
||||
reason="replaced by a newer user message",
|
||||
@@ -119,6 +130,24 @@ class OhmoGatewayBridge:
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_restart(self, message, session_key: str) -> None:
|
||||
await self._interrupt_session(
|
||||
session_key,
|
||||
reason="restarting gateway by user command",
|
||||
)
|
||||
await self._bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content="🔄 正在重启 gateway,马上回来。\nRestarting the gateway now. I'll be back in a moment.",
|
||||
metadata={"_session_key": session_key},
|
||||
)
|
||||
)
|
||||
if self._restart_gateway is not None:
|
||||
result = self._restart_gateway(message, session_key)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
|
||||
async def _interrupt_session(
|
||||
self,
|
||||
session_key: str,
|
||||
|
||||
+95
-2
@@ -4,13 +4,16 @@ 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
|
||||
|
||||
from openharness.channels.bus.events import OutboundMessage
|
||||
from openharness.channels.bus.queue import MessageBus
|
||||
from openharness.channels.impl.manager import ChannelManager
|
||||
|
||||
@@ -18,7 +21,13 @@ 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_logs_dir, get_state_path, get_workspace_root, initialize_workspace
|
||||
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]
|
||||
@@ -40,7 +49,13 @@ class OhmoGatewayService:
|
||||
workspace=self._workspace,
|
||||
provider_profile=self._config.provider_profile,
|
||||
)
|
||||
self._bridge = OhmoGatewayBridge(bus=self._bus, runtime_pool=self._runtime_pool)
|
||||
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,
|
||||
)
|
||||
self._manager = ChannelManager(build_channel_manager_config(self._config), self._bus)
|
||||
|
||||
@property
|
||||
@@ -66,12 +81,83 @@ class OhmoGatewayService:
|
||||
)
|
||||
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()
|
||||
|
||||
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()
|
||||
@@ -91,9 +177,16 @@ class OhmoGatewayService:
|
||||
await bridge_task
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await manager_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
|
||||
|
||||
|
||||
|
||||
@@ -227,6 +227,10 @@ def get_gateway_config_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "gateway.json"
|
||||
|
||||
|
||||
def get_gateway_restart_notice_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "gateway-restart-notice.json"
|
||||
|
||||
|
||||
def ensure_workspace(workspace: str | Path | None = None) -> Path:
|
||||
"""Create the workspace if needed and return its root."""
|
||||
root = get_workspace_root(workspace)
|
||||
|
||||
@@ -29,6 +29,10 @@ dependencies = [
|
||||
"questionary>=2.0.1",
|
||||
"watchfiles>=0.20.0",
|
||||
"croniter>=2.0.0",
|
||||
"slack-sdk>=3.0.0",
|
||||
"python-telegram-bot>=21.0.0",
|
||||
"discord.py>=2.0.0",
|
||||
"lark-oapi>=1.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+61
-35
@@ -40,8 +40,8 @@ for arg in "$@"; do
|
||||
echo "Usage: $0 [--from-source] [--with-channels]"
|
||||
echo ""
|
||||
echo " --from-source Clone from GitHub and install in editable mode"
|
||||
echo " --with-channels Also install IM channel dependencies"
|
||||
echo " (slack-sdk, python-telegram-bot, discord.py)"
|
||||
echo " --with-channels Deprecated compatibility flag."
|
||||
echo " Common IM channel dependencies are installed by default."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -232,14 +232,11 @@ fi
|
||||
success "OpenHarness package installed"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5: Install IM channel dependencies (--with-channels)
|
||||
# Step 5: Channel dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$WITH_CHANNELS" = true ]; then
|
||||
step "Installing IM channel dependencies (--with-channels)"
|
||||
CHANNEL_DEPS="slack-sdk python-telegram-bot discord.py"
|
||||
info "Installing: ${CHANNEL_DEPS}"
|
||||
$PIP_CMD install $CHANNEL_DEPS --quiet
|
||||
success "Channel dependencies installed"
|
||||
step "Channel dependencies"
|
||||
info "--with-channels is no longer required; common IM channel dependencies are installed by default."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -279,21 +276,23 @@ success "Config directory ready: ~/.openharness/"
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Verifying installation"
|
||||
|
||||
if command -v oh &>/dev/null; then
|
||||
if command -v oh &>/dev/null && command -v ohmo &>/dev/null; then
|
||||
OH_VERSION=$(oh --version 2>&1 || echo "(version check failed)")
|
||||
OHMO_VERSION=$(ohmo --help >/dev/null 2>&1 && echo "available" || echo "not available")
|
||||
success "Installation successful!"
|
||||
echo ""
|
||||
echo -e " ${BOLD}oh${RESET} is ready: ${GREEN}${OH_VERSION}${RESET}"
|
||||
echo -e " ${BOLD}ohmo${RESET} is ready: ${GREEN}${OHMO_VERSION}${RESET}"
|
||||
elif "$PYTHON_CMD" -m openharness --version &>/dev/null 2>&1; then
|
||||
OH_VERSION=$("$PYTHON_CMD" -m openharness --version 2>&1)
|
||||
warn "'oh' not in PATH. Run via: python -m openharness"
|
||||
warn "'oh'/'ohmo' not in PATH. Run via: python -m openharness or python -m ohmo"
|
||||
echo " Version: ${OH_VERSION}"
|
||||
echo " To add 'oh' to PATH, ensure your Python bin directory is in PATH:"
|
||||
echo " export PATH=\"\$($PYTHON_CMD -m site --user-base)/bin:\$PATH\""
|
||||
echo " To add them to PATH, ensure ${VENV_DIR}/bin is in PATH:"
|
||||
echo " export PATH=\"${VENV_DIR}/bin:\$PATH\""
|
||||
else
|
||||
warn "Could not verify 'oh' command. The package may need a PATH update."
|
||||
warn "Could not verify 'oh'/'ohmo' commands. The package may need a PATH update."
|
||||
echo " Try: $PYTHON_CMD -m openharness --version"
|
||||
echo " Or add Python's user bin to PATH and restart your shell."
|
||||
echo " Or add ${VENV_DIR}/bin to PATH and restart your shell."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -301,27 +300,51 @@ fi
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Setting up shell integration"
|
||||
|
||||
SHELL_RC=""
|
||||
if [ -f "$HOME/.zshrc" ]; then
|
||||
SHELL_RC="$HOME/.zshrc"
|
||||
elif [ -f "$HOME/.bashrc" ]; then
|
||||
SHELL_RC="$HOME/.bashrc"
|
||||
elif [ -f "$HOME/.bash_profile" ]; then
|
||||
SHELL_RC="$HOME/.bash_profile"
|
||||
ACTIVATION_LINE="export PATH=\"$VENV_DIR/bin:\$PATH\""
|
||||
FISH_CONFIG="$HOME/.config/fish/config.fish"
|
||||
FISH_BLOCK=$(cat <<EOF
|
||||
# OpenHarness
|
||||
if not contains -- "$VENV_DIR/bin" \$PATH
|
||||
set -gx PATH "$VENV_DIR/bin" \$PATH
|
||||
end
|
||||
EOF
|
||||
)
|
||||
|
||||
configured_any=false
|
||||
|
||||
append_shell_path() {
|
||||
local rc_file="$1"
|
||||
if [ ! -f "$rc_file" ]; then
|
||||
return
|
||||
fi
|
||||
if grep -q "$VENV_DIR/bin" "$rc_file" 2>/dev/null; then
|
||||
info "PATH already configured in $(basename "$rc_file")"
|
||||
configured_any=true
|
||||
return
|
||||
fi
|
||||
echo "" >> "$rc_file"
|
||||
echo "# OpenHarness" >> "$rc_file"
|
||||
echo "$ACTIVATION_LINE" >> "$rc_file"
|
||||
success "Added $VENV_DIR/bin to PATH in $(basename "$rc_file")"
|
||||
configured_any=true
|
||||
}
|
||||
|
||||
append_shell_path "$HOME/.zshrc"
|
||||
append_shell_path "$HOME/.bashrc"
|
||||
append_shell_path "$HOME/.bash_profile"
|
||||
|
||||
mkdir -p "$(dirname "$FISH_CONFIG")"
|
||||
if [ -f "$FISH_CONFIG" ] && grep -q "$VENV_DIR/bin" "$FISH_CONFIG" 2>/dev/null; then
|
||||
info "PATH already configured in $(basename "$FISH_CONFIG")"
|
||||
configured_any=true
|
||||
else
|
||||
echo "" >> "$FISH_CONFIG"
|
||||
printf "%s\n" "$FISH_BLOCK" >> "$FISH_CONFIG"
|
||||
success "Added $VENV_DIR/bin to PATH in $(basename "$FISH_CONFIG")"
|
||||
configured_any=true
|
||||
fi
|
||||
|
||||
ACTIVATION_LINE="export PATH=\"$VENV_DIR/bin:\$PATH\""
|
||||
|
||||
if [ -n "$SHELL_RC" ]; then
|
||||
if ! grep -q "$VENV_DIR/bin" "$SHELL_RC" 2>/dev/null; then
|
||||
echo "" >> "$SHELL_RC"
|
||||
echo "# OpenHarness" >> "$SHELL_RC"
|
||||
echo "$ACTIVATION_LINE" >> "$SHELL_RC"
|
||||
success "Added $VENV_DIR/bin to PATH in $(basename $SHELL_RC)"
|
||||
else
|
||||
info "PATH already configured in $(basename $SHELL_RC)"
|
||||
fi
|
||||
else
|
||||
if [ "$configured_any" = false ]; then
|
||||
warn "Could not find shell config file. Add this to your shell profile:"
|
||||
echo " $ACTIVATION_LINE"
|
||||
fi
|
||||
@@ -333,8 +356,11 @@ echo ""
|
||||
echo -e "${BOLD}${GREEN}OpenHarness is installed!${RESET}"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Restart shell (or run): source ${SHELL_RC:-~/.bashrc}"
|
||||
echo " 1. Restart shell, or reload your shell config:"
|
||||
echo " bash/zsh: source ~/.bashrc (or ~/.zshrc)"
|
||||
echo " fish: source ~/.config/fish/config.fish"
|
||||
echo " 2. Set your API key: export ANTHROPIC_API_KEY=your_key"
|
||||
echo " 3. Launch: oh"
|
||||
echo " 4. Docs: https://github.com/HKUDS/OpenHarness"
|
||||
echo " 4. Launch ohmo: ohmo"
|
||||
echo " 5. Docs: https://github.com/HKUDS/OpenHarness"
|
||||
echo ""
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# Developer install for the current checkout.
|
||||
# Usage:
|
||||
# bash scripts/install_dev.sh
|
||||
# bash scripts/install_dev.sh --global-venv
|
||||
# bash scripts/install_dev.sh --with-channels
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -t 1 ]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' RESET=''
|
||||
fi
|
||||
|
||||
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
|
||||
step() { echo -e "\n${BOLD}${BLUE}==>${RESET}${BOLD} $*${RESET}"; }
|
||||
|
||||
WITH_CHANNELS=false
|
||||
GLOBAL_VENV=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--with-channels) WITH_CHANNELS=true ;;
|
||||
--global-venv) GLOBAL_VENV=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--with-channels] [--global-venv]"
|
||||
echo ""
|
||||
echo "Installs the current checkout in editable mode and"
|
||||
echo "registers oh/ohmo in ~/.local/bin."
|
||||
echo ""
|
||||
echo " default use ./ .openharness-venv inside the current repo"
|
||||
echo " --global-venv use ~/.openharness-venv but still install the current repo"
|
||||
echo " --with-channels deprecated compatibility flag; common IM deps install by default"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Unknown argument: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
if [ "$GLOBAL_VENV" = true ]; then
|
||||
VENV_DIR="$HOME/.openharness-venv"
|
||||
else
|
||||
VENV_DIR="$REPO_ROOT/.openharness-venv"
|
||||
fi
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
|
||||
step "Checking Python version (>= 3.10 required)"
|
||||
|
||||
PYTHON_CMD=""
|
||||
for cmd in python3 python; do
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
PY_VER=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1)
|
||||
PY_MINOR=$(echo "$PY_VER" | cut -d. -f2)
|
||||
if [ "${PY_MAJOR}" -ge 3 ] && [ "${PY_MINOR}" -ge 10 ]; then
|
||||
PYTHON_CMD="$cmd"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON_CMD" ]; then
|
||||
error "Python 3.10+ not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Found $("$PYTHON_CMD" --version 2>&1) (${PYTHON_CMD})"
|
||||
|
||||
step "Preparing developer virtual environment"
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
info "Creating virtual environment at ${VENV_DIR}"
|
||||
"$PYTHON_CMD" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
source "$VENV_DIR/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel --quiet
|
||||
success "Virtual environment ready: ${VENV_DIR}"
|
||||
|
||||
step "Installing current checkout in editable mode"
|
||||
python -m pip install -e "$REPO_ROOT" --quiet
|
||||
success "Installed OpenHarness from ${REPO_ROOT}"
|
||||
|
||||
if [ "$WITH_CHANNELS" = true ]; then
|
||||
step "Channel dependencies"
|
||||
info "--with-channels is no longer required; common IM channel dependencies are installed by default."
|
||||
fi
|
||||
|
||||
step "Installing React terminal dependencies (optional)"
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
NODE_MAJOR=$(node --version 2>&1 | grep -oE '[0-9]+' | head -1)
|
||||
FRONTEND_DIR="$REPO_ROOT/frontend/terminal"
|
||||
if [ "${NODE_MAJOR}" -ge 18 ] 2>/dev/null && [ -f "$FRONTEND_DIR/package.json" ]; then
|
||||
info "Running npm install in ${FRONTEND_DIR}"
|
||||
(cd "$FRONTEND_DIR" && npm install --no-fund --no-audit --silent)
|
||||
success "React terminal dependencies installed"
|
||||
else
|
||||
warn "Node.js too old or frontend directory missing; skipping npm install"
|
||||
fi
|
||||
else
|
||||
warn "Node.js not found; skipping npm install"
|
||||
fi
|
||||
|
||||
step "Registering global commands"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -snf "$VENV_DIR/bin/oh" "$BIN_DIR/oh"
|
||||
ln -snf "$VENV_DIR/bin/ohmo" "$BIN_DIR/ohmo"
|
||||
ln -snf "$VENV_DIR/bin/openharness" "$BIN_DIR/openharness"
|
||||
success "Linked oh/ohmo into ${BIN_DIR}"
|
||||
|
||||
ensure_path_in_file() {
|
||||
local rc_file="$1"
|
||||
local line="$2"
|
||||
[ -f "$rc_file" ] || return 0
|
||||
if ! grep -qF "$line" "$rc_file" 2>/dev/null; then
|
||||
echo "" >> "$rc_file"
|
||||
echo "# OpenHarness dev" >> "$rc_file"
|
||||
echo "$line" >> "$rc_file"
|
||||
success "Added ${BIN_DIR} to PATH in $(basename "$rc_file")"
|
||||
fi
|
||||
}
|
||||
|
||||
step "Ensuring ~/.local/bin is on PATH"
|
||||
mkdir -p "$HOME/.config/fish"
|
||||
ensure_path_in_file "$HOME/.bashrc" "export PATH=\"$BIN_DIR:\$PATH\""
|
||||
ensure_path_in_file "$HOME/.bash_profile" "export PATH=\"$BIN_DIR:\$PATH\""
|
||||
ensure_path_in_file "$HOME/.zshrc" "export PATH=\"$BIN_DIR:\$PATH\""
|
||||
|
||||
if [ -f "$HOME/.config/fish/config.fish" ]; then
|
||||
if ! grep -qF "$BIN_DIR" "$HOME/.config/fish/config.fish" 2>/dev/null; then
|
||||
{
|
||||
echo ""
|
||||
echo "# OpenHarness dev"
|
||||
echo "if not contains -- \"$BIN_DIR\" \$PATH"
|
||||
echo " set -gx PATH \"$BIN_DIR\" \$PATH"
|
||||
echo "end"
|
||||
} >> "$HOME/.config/fish/config.fish"
|
||||
success "Added ${BIN_DIR} to PATH in config.fish"
|
||||
fi
|
||||
else
|
||||
cat > "$HOME/.config/fish/config.fish" <<EOF
|
||||
# OpenHarness dev
|
||||
if not contains -- "$BIN_DIR" \$PATH
|
||||
set -gx PATH "$BIN_DIR" \$PATH
|
||||
end
|
||||
EOF
|
||||
success "Created config.fish with ${BIN_DIR} on PATH"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}Developer install complete.${RESET}"
|
||||
echo ""
|
||||
echo " Repo root: $REPO_ROOT"
|
||||
echo " Virtual environment: $VENV_DIR"
|
||||
echo " Command links: $BIN_DIR/oh , $BIN_DIR/ohmo"
|
||||
echo ""
|
||||
echo " If this shell does not see the commands yet, run one of:"
|
||||
echo " bash: source ~/.bashrc"
|
||||
echo " zsh: source ~/.zshrc"
|
||||
echo " fish: source ~/.config/fish/config.fish"
|
||||
echo ""
|
||||
echo " Or use immediately in this shell:"
|
||||
echo " export PATH=\"$BIN_DIR:\$PATH\""
|
||||
echo " hash -r"
|
||||
echo ""
|
||||
@@ -9,6 +9,7 @@ import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -26,6 +27,8 @@ class EnvironmentInfo:
|
||||
home_dir: str
|
||||
date: str
|
||||
python_version: str
|
||||
python_executable: str
|
||||
virtual_env: str | None
|
||||
is_git_repo: bool
|
||||
git_branch: str | None = None
|
||||
hostname: str = ""
|
||||
@@ -103,6 +106,14 @@ def get_environment_info(cwd: str | None = None) -> EnvironmentInfo:
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
|
||||
python_executable = str(Path(sys.executable).resolve())
|
||||
virtual_env = os.environ.get("VIRTUAL_ENV")
|
||||
if not virtual_env:
|
||||
executable_path = Path(python_executable)
|
||||
candidate = executable_path.parent.parent
|
||||
if executable_path.parent.name in {"bin", "Scripts"} and (candidate / "pyvenv.cfg").exists():
|
||||
virtual_env = str(candidate)
|
||||
|
||||
os_name, os_version = detect_os()
|
||||
shell = detect_shell()
|
||||
is_git, branch = detect_git_info(cwd)
|
||||
@@ -116,6 +127,8 @@ def get_environment_info(cwd: str | None = None) -> EnvironmentInfo:
|
||||
home_dir=str(Path.home()),
|
||||
date=datetime.now(tz=timezone.utc).strftime("%Y-%m-%d"),
|
||||
python_version=platform.python_version(),
|
||||
python_executable=python_executable,
|
||||
virtual_env=virtual_env,
|
||||
is_git_repo=is_git,
|
||||
git_branch=branch,
|
||||
hostname=platform.node(),
|
||||
|
||||
@@ -70,8 +70,12 @@ def _format_environment_section(env: EnvironmentInfo) -> str:
|
||||
f"- Working directory: {env.cwd}",
|
||||
f"- Date: {env.date}",
|
||||
f"- Python: {env.python_version}",
|
||||
f"- Python executable: {env.python_executable}",
|
||||
]
|
||||
|
||||
if env.virtual_env:
|
||||
lines.append(f"- Virtual environment: {env.virtual_env}")
|
||||
|
||||
if env.is_git_repo:
|
||||
git_line = "- Git: yes"
|
||||
if env.git_branch:
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -17,10 +18,10 @@ from openharness.engine.stream_events import AssistantTextDelta, StatusEvent, To
|
||||
from ohmo.gateway.bridge import OhmoGatewayBridge, _format_gateway_error
|
||||
from ohmo.gateway.models import GatewayState
|
||||
from ohmo.gateway.runtime import OhmoSessionRuntimePool
|
||||
from ohmo.gateway.service import gateway_status, stop_gateway_process
|
||||
from ohmo.gateway.service import OhmoGatewayService, gateway_status, stop_gateway_process
|
||||
from ohmo.gateway.router import session_key_for_message
|
||||
from ohmo.session_storage import save_session_snapshot
|
||||
from ohmo.workspace import initialize_workspace
|
||||
from ohmo.workspace import get_gateway_restart_notice_path, initialize_workspace
|
||||
|
||||
|
||||
def test_gateway_router_uses_thread_when_present():
|
||||
@@ -431,6 +432,110 @@ async def test_gateway_bridge_stop_command_cancels_current_session():
|
||||
assert stopped.content == "⏹️ 已停止当前正在运行的任务。"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_bridge_restart_command_requests_gateway_restart():
|
||||
bus = MessageBus()
|
||||
restarted = asyncio.Event()
|
||||
restart_payloads: list[tuple[str, str, str]] = []
|
||||
|
||||
class FakeRuntimePool:
|
||||
async def stream_message(self, message, session_key):
|
||||
if False:
|
||||
yield
|
||||
|
||||
async def fake_restart(message, session_key: str) -> None:
|
||||
restart_payloads.append((message.channel, message.chat_id, session_key))
|
||||
restarted.set()
|
||||
|
||||
bridge = OhmoGatewayBridge(bus=bus, runtime_pool=FakeRuntimePool(), restart_gateway=fake_restart)
|
||||
task = asyncio.create_task(bridge.run())
|
||||
try:
|
||||
await bus.publish_inbound(
|
||||
InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="/restart")
|
||||
)
|
||||
restarting = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
await asyncio.wait_for(restarted.wait(), timeout=1.0)
|
||||
finally:
|
||||
bridge.stop()
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert restarting.content == (
|
||||
"🔄 正在重启 gateway,马上回来。\n"
|
||||
"Restarting the gateway now. I'll be back in a moment."
|
||||
)
|
||||
assert restart_payloads == [("feishu", "c1", "feishu:c1")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_service_request_restart_waits_before_stop(monkeypatch):
|
||||
service = object.__new__(OhmoGatewayService)
|
||||
service._restart_requested = False
|
||||
service._stop_event = asyncio.Event()
|
||||
service._workspace = "/tmp/ohmo"
|
||||
|
||||
slept: list[float] = []
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
slept.append(delay)
|
||||
|
||||
monkeypatch.setattr("ohmo.gateway.service.asyncio.sleep", fake_sleep)
|
||||
monkeypatch.setattr(
|
||||
"ohmo.gateway.service.get_gateway_restart_notice_path",
|
||||
lambda workspace: Path("/tmp/restart-notice.json"),
|
||||
)
|
||||
writes: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"pathlib.Path.write_text",
|
||||
lambda self, content, encoding=None: writes.append(content) or len(content),
|
||||
)
|
||||
|
||||
message = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="/restart")
|
||||
|
||||
await OhmoGatewayService.request_restart(service, message, "feishu:c1")
|
||||
|
||||
assert service._restart_requested is True
|
||||
assert service._stop_event.is_set() is True
|
||||
assert slept == [0.75]
|
||||
assert writes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_service_publishes_pending_restart_notice(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / ".ohmo-home"
|
||||
initialize_workspace(workspace)
|
||||
notice_path = get_gateway_restart_notice_path(workspace)
|
||||
notice_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"channel": "feishu",
|
||||
"chat_id": "chat-1",
|
||||
"session_key": "feishu:chat-1",
|
||||
"content": "✅ gateway 已经重新连上,可以继续了。\nGateway is back online. We can continue.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
service = object.__new__(OhmoGatewayService)
|
||||
service._workspace = workspace
|
||||
service._bus = MessageBus()
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("ohmo.gateway.service.asyncio.sleep", fake_sleep)
|
||||
|
||||
await OhmoGatewayService._publish_pending_restart_notice(service)
|
||||
|
||||
outbound = await asyncio.wait_for(service._bus.consume_outbound(), timeout=1.0)
|
||||
assert outbound.content == "✅ gateway 已经重新连上,可以继续了。\nGateway is back online. We can continue."
|
||||
assert outbound.chat_id == "chat-1"
|
||||
assert not notice_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_bridge_new_message_interrupts_same_session():
|
||||
bus = MessageBus()
|
||||
|
||||
@@ -87,6 +87,24 @@ def test_get_environment_info_returns_dataclass():
|
||||
assert len(info.cwd) > 0
|
||||
assert len(info.date) == 10 # YYYY-MM-DD
|
||||
assert len(info.python_version) > 0
|
||||
assert len(info.python_executable) > 0
|
||||
|
||||
|
||||
def test_get_environment_info_detects_virtual_env_from_python_executable(monkeypatch, tmp_path: Path):
|
||||
venv_root = tmp_path / ".openharness-venv"
|
||||
bin_dir = venv_root / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(venv_root / "pyvenv.cfg").write_text("home = /usr/bin\n", encoding="utf-8")
|
||||
fake_python = bin_dir / "python"
|
||||
fake_python.write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr("openharness.prompts.environment.sys.executable", str(fake_python))
|
||||
|
||||
info = get_environment_info(cwd=str(tmp_path))
|
||||
|
||||
assert info.python_executable == str(fake_python.resolve())
|
||||
assert info.virtual_env == str(venv_root.resolve())
|
||||
|
||||
|
||||
def test_get_environment_info_cwd_override(tmp_path: Path):
|
||||
|
||||
@@ -16,6 +16,8 @@ def _make_env(**overrides) -> EnvironmentInfo:
|
||||
home_dir="/home/user",
|
||||
date="2026-04-01",
|
||||
python_version="3.10.17",
|
||||
python_executable="/home/user/.openharness-venv/bin/python",
|
||||
virtual_env="/home/user/.openharness-venv",
|
||||
is_git_repo=True,
|
||||
git_branch="main",
|
||||
hostname="testhost",
|
||||
@@ -33,6 +35,8 @@ def test_build_system_prompt_contains_environment():
|
||||
assert "/home/user/project" in prompt
|
||||
assert "2026-04-01" in prompt
|
||||
assert "3.10.17" in prompt
|
||||
assert "/home/user/.openharness-venv/bin/python" in prompt
|
||||
assert "Virtual environment: /home/user/.openharness-venv" in prompt
|
||||
assert "branch: main" in prompt
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user