新增service子命令
This commit is contained in:
parent
27d126cf73
commit
89a1512350
@ -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
|
||||
|
||||
|
||||
20
README.md
20
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/中文编辑、括号粘贴和多行输入。
|
||||
|
||||
常用快捷键:
|
||||
|
||||
@ -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 剩余任务。
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
34
src/main.rs
34
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<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
205
src/service.rs
Normal 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\""));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user