新增service子命令

This commit is contained in:
xiaoxixi 2026-07-14 16:10:08 +08:00
parent 27d126cf73
commit 89a1512350
8 changed files with 307 additions and 5 deletions

View File

@ -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

View File

@ -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/中文编辑、括号粘贴和多行输入。
常用快捷键:

View File

@ -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重连时恢复内存中的当前 dialogGateway 重启后则恢复该 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 剩余任务。

View File

@ -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

View File

@ -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");

View File

@ -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;

View File

@ -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<u16>,
},
/// Manage the PicoBot systemd user service
Service {
#[command(subcommand)]
command: ServiceCommand,
},
}
#[tokio::main]
@ -44,6 +65,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}

205
src/service.rs Normal file
View File

@ -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<PathBuf> {
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\""));
}
}