From 89a151235074036234efd37418e4f8f32b574dc8 Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Tue, 14 Jul 2026 16:10:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Eservice=E5=AD=90=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + README.md | 20 ++ docs/ARCHITECTURE.md | 4 +- .../about-picobot/references/commands.md | 12 + src/gateway/mod.rs | 35 ++- src/lib.rs | 1 + src/main.rs | 34 ++- src/service.rs | 205 ++++++++++++++++++ 8 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 src/service.rs diff --git a/AGENTS.md b/AGENTS.md index 8f92cbd..634de7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ This file is the operational contract for coding agents working in this reposito - `cargo build` — build the binary - `cargo run -- gateway` — start gateway server (binds `127.0.0.1:19876` by default) - `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`) +- `picobot service install|start|stop|status|restart|uninstall` — manage the Linux systemd user service (`picobot.service`) ## Config diff --git a/README.md b/README.md index aef5511..53f5dce 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,26 @@ cargo run -- chat CLI 默认连接 `ws://127.0.0.1:19876/ws`。如需指定地址,可使用 `--gateway-url`。 +### 6. 作为 systemd 用户服务运行(Linux) + +安装会把当前 PicoBot 可执行文件注册为 `picobot.service` 并设置为登录后自动启动;安装本身不会立即启动 Gateway: + +```bash +picobot service install +picobot service start +``` + +服务管理命令: + +```bash +picobot service status +picobot service restart +picobot service stop +picobot service uninstall +``` + +unit 位于 `~/.config/systemd/user/picobot.service`,以执行 `service install` 时的当前目录作为初始工作目录。服务异常退出时由 systemd 自动重启;`stop` 和 `restart` 会通过 SIGTERM 触发 Gateway 的有界优雅关停。 + TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴和多行输入。 常用快捷键: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9487fd2..2619f47 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,6 +23,8 @@ PicoBot 只有一个二进制,提供两种模式: | Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、运行渠道、会话、调度器和后台任务 | | CLI client | `cargo run -- chat` | 运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 | +Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。 + CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。 Gateway 启动时会切换进程工作目录到 `workspace_dir`。因此所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。 @@ -204,7 +206,7 @@ WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handl ### 关停 -1. `Ctrl-C` 触发 Axum graceful shutdown,并取消所有 WebSocket 连接。 +1. `Ctrl-C`/SIGINT 或 SIGTERM 触发 Axum graceful shutdown,并取消所有 WebSocket 连接。systemd 的 `stop`/`restart` 使用 SIGTERM,因此与前台退出共享同一清理链路。 2. `ChannelManager::stop_all` 先停止外部消息入口并注销渠道。 3. 取消 TaskSupervisor,停止接受新后台任务。 4. 在共享的 10 秒总宽限期内等待任务退出,之后 abort 剩余任务。 diff --git a/resources/skills/about-picobot/references/commands.md b/resources/skills/about-picobot/references/commands.md index 8129b94..b4105eb 100644 --- a/resources/skills/about-picobot/references/commands.md +++ b/resources/skills/about-picobot/references/commands.md @@ -10,6 +10,18 @@ cargo run -- gateway # 启动 CLI 客户端 (连接 ws://127.0.0.1:19876/ws) cargo run -- chat +# 安装并启动 Linux systemd 用户服务 +picobot service install +picobot service start + +# 查看、重启或停止服务 +picobot service status +picobot service restart +picobot service stop + +# 停止、禁用并删除服务 +picobot service uninstall + # 运行单元测试 cargo test --lib diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 9c07f5c..c70c447 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -434,9 +434,7 @@ pub async fn run( let connection_shutdown = state.connection_shutdown.clone(); let serve_result = axum::serve(listener, app) .with_graceful_shutdown(async move { - if let Err(error) = tokio::signal::ctrl_c().await { - tracing::error!(error = %error, "Failed to listen for shutdown signal"); - } + wait_for_shutdown_signal().await; tracing::info!("Shutdown signal received"); connection_shutdown.cancel(); }) @@ -456,6 +454,37 @@ pub async fn run( Ok(()) } +async fn wait_for_shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + + match signal(SignalKind::terminate()) { + Ok(mut terminate) => { + tokio::select! { + result = tokio::signal::ctrl_c() => { + if let Err(error) = result { + tracing::error!(error = %error, "Failed to listen for Ctrl-C"); + } + } + _ = terminate.recv() => {} + } + } + Err(error) => { + tracing::error!(error = %error, "Failed to listen for SIGTERM"); + if let Err(error) = tokio::signal::ctrl_c().await { + tracing::error!(error = %error, "Failed to listen for Ctrl-C"); + } + } + } + } + + #[cfg(not(unix))] + if let Err(error) = tokio::signal::ctrl_c().await { + tracing::error!(error = %error, "Failed to listen for Ctrl-C"); + } +} + /// Release default AGENTS.md and USER.md templates to ~/.picobot/ if not already present. fn ensure_default_config_files() { let picobot_dir = dirs::home_dir().unwrap_or_default().join(".picobot"); diff --git a/src/lib.rs b/src/lib.rs index d714629..19b844c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod observability; pub mod protocol; pub mod providers; pub mod scheduler; +pub mod service; pub mod session; pub mod skills; pub mod storage; diff --git a/src/main.rs b/src/main.rs index 299b177..3e8e5d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,20 @@ -use clap::{CommandFactory, Parser}; +use clap::{CommandFactory, Parser, Subcommand}; + +#[derive(Subcommand)] +enum ServiceCommand { + /// Install and enable the systemd user service + Install, + /// Stop the systemd user service + Stop, + /// Show the systemd user service status + Status, + /// Start the systemd user service + Start, + /// Restart the systemd user service + Restart, + /// Stop, disable, and remove the systemd user service + Uninstall, +} #[derive(Parser)] #[command(name = "picobot")] @@ -20,6 +36,11 @@ enum Command { #[arg(long)] port: Option, }, + /// Manage the PicoBot systemd user service + Service { + #[command(subcommand)] + command: ServiceCommand, + }, } #[tokio::main] @@ -44,6 +65,17 @@ async fn main() -> Result<(), Box> { Command::Gateway { host, port } => { picobot::gateway::run(host, port).await?; } + Command::Service { command } => { + let command = match command { + ServiceCommand::Install => picobot::service::ServiceCommand::Install, + ServiceCommand::Stop => picobot::service::ServiceCommand::Stop, + ServiceCommand::Status => picobot::service::ServiceCommand::Status, + ServiceCommand::Start => picobot::service::ServiceCommand::Start, + ServiceCommand::Restart => picobot::service::ServiceCommand::Restart, + ServiceCommand::Uninstall => picobot::service::ServiceCommand::Uninstall, + }; + picobot::service::execute(command).await?; + } } Ok(()) } diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..0d75a6a --- /dev/null +++ b/src/service.rs @@ -0,0 +1,205 @@ +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use anyhow::{Context, Result, bail}; + +const UNIT_NAME: &str = "picobot.service"; + +#[derive(Debug, Clone, Copy)] +pub enum ServiceCommand { + Install, + Start, + Stop, + Status, + Restart, + Uninstall, +} + +pub async fn execute(command: ServiceCommand) -> Result<()> { + ensure_systemd_supported()?; + + match command { + ServiceCommand::Install => install().await, + ServiceCommand::Start => run_systemctl(&["start", UNIT_NAME]).await, + ServiceCommand::Stop => run_systemctl(&["stop", UNIT_NAME]).await, + ServiceCommand::Status => run_systemctl(&["status", "--no-pager", UNIT_NAME]).await, + ServiceCommand::Restart => run_systemctl(&["restart", UNIT_NAME]).await, + ServiceCommand::Uninstall => uninstall().await, + } +} + +fn ensure_systemd_supported() -> Result<()> { + if cfg!(target_os = "linux") { + Ok(()) + } else { + bail!("service management currently requires Linux with a systemd user session") + } +} + +async fn install() -> Result<()> { + let executable = std::env::current_exe() + .context("failed to determine the PicoBot executable path")? + .canonicalize() + .context("failed to canonicalize the PicoBot executable path")?; + let working_directory = std::env::current_dir() + .context("failed to determine the current working directory")? + .canonicalize() + .context("failed to canonicalize the current working directory")?; + let service_path = service_path()?; + + let parent = service_path + .parent() + .context("systemd user service path has no parent directory")?; + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + std::fs::write( + &service_path, + render_systemd_unit(&executable, &working_directory), + ) + .with_context(|| format!("failed to write {}", service_path.display()))?; + + if let Err(error) = async { + run_systemctl(&["daemon-reload"]).await?; + run_systemctl(&["enable", UNIT_NAME]).await + } + .await + { + return Err(error).with_context(|| { + format!( + "service file was written to {}, but systemd registration failed", + service_path.display() + ) + }); + } + + println!( + "Installed and enabled {UNIT_NAME} at {}", + service_path.display() + ); + println!("Start it with: picobot service start"); + Ok(()) +} + +async fn uninstall() -> Result<()> { + let service_path = service_path()?; + if !service_path.exists() { + println!("{UNIT_NAME} is not installed at {}", service_path.display()); + return Ok(()); + } + + run_systemctl(&["disable", "--now", UNIT_NAME]).await?; + std::fs::remove_file(&service_path) + .with_context(|| format!("failed to remove {}", service_path.display()))?; + run_systemctl(&["daemon-reload"]).await?; + let _ = run_systemctl_quiet(&["reset-failed", UNIT_NAME]).await; + + println!("Uninstalled {UNIT_NAME}"); + Ok(()) +} + +fn service_path() -> Result { + let home = dirs::home_dir().context("failed to determine the home directory")?; + Ok(home + .join(".config") + .join("systemd") + .join("user") + .join(UNIT_NAME)) +} + +async fn run_systemctl(args: &[&str]) -> Result<()> { + let status = tokio::process::Command::new("systemctl") + .arg("--user") + .args(args) + .status() + .await + .with_context(|| format!("failed to execute systemctl --user {}", args.join(" ")))?; + + if !status.success() { + bail!( + "systemctl --user {} failed with status {}", + args.join(" "), + status + ); + } + Ok(()) +} + +async fn run_systemctl_quiet(args: &[&str]) -> Result<()> { + let status = tokio::process::Command::new("systemctl") + .arg("--user") + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .with_context(|| format!("failed to execute systemctl --user {}", args.join(" ")))?; + + if !status.success() { + bail!( + "systemctl --user {} failed with status {}", + args.join(" "), + status + ); + } + Ok(()) +} + +fn render_systemd_unit(executable: &Path, working_directory: &Path) -> String { + let executable = quote_systemd_value(executable.to_string_lossy().as_ref()); + let working_directory = quote_systemd_value(working_directory.to_string_lossy().as_ref()); + + format!( + "[Unit]\n\ +Description=PicoBot Gateway\n\ +After=network-online.target\n\ +Wants=network-online.target\n\ +\n\ +[Service]\n\ +Type=simple\n\ +ExecStart={executable} gateway\n\ +WorkingDirectory={working_directory}\n\ +Restart=on-failure\n\ +RestartSec=5\n\ +Environment=RUST_LOG=info\n\ +\n\ +[Install]\n\ +WantedBy=default.target\n" + ) +} + +fn quote_systemd_value(value: &str) -> String { + let escaped = value + .replace('%', "%%") + .replace('\\', "\\\\") + .replace('"', "\\\""); + format!("\"{escaped}\"") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_starts_current_binary_as_gateway_and_restarts_on_failure() { + let unit = render_systemd_unit( + Path::new("/home/alice/bin/picobot"), + Path::new("/home/alice/picobot"), + ); + + assert!(unit.contains("ExecStart=\"/home/alice/bin/picobot\" gateway")); + assert!(unit.contains("WorkingDirectory=\"/home/alice/picobot\"")); + assert!(unit.contains("Restart=on-failure")); + assert!(unit.contains("WantedBy=default.target")); + } + + #[test] + fn unit_quotes_spaces_quotes_backslashes_and_specifiers() { + let unit = render_systemd_unit( + Path::new("/home/alice/Pico Bot/%i/\"picobot\""), + Path::new("/home/alice/work\\space"), + ); + + assert!(unit.contains("ExecStart=\"/home/alice/Pico Bot/%%i/\\\"picobot\\\"\" gateway")); + assert!(unit.contains("WorkingDirectory=\"/home/alice/work\\\\space\"")); + } +}