新增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 build` — build the binary
|
||||||
- `cargo run -- gateway` — start gateway server (binds `127.0.0.1:19876` by default)
|
- `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`)
|
- `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
|
## Config
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@ -91,6 +91,26 @@ cargo run -- chat
|
|||||||
|
|
||||||
CLI 默认连接 `ws://127.0.0.1:19876/ws`。如需指定地址,可使用 `--gateway-url`。
|
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/中文编辑、括号粘贴和多行输入。
|
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴和多行输入。
|
||||||
|
|
||||||
常用快捷键:
|
常用快捷键:
|
||||||
|
|||||||
@ -23,6 +23,8 @@ PicoBot 只有一个二进制,提供两种模式:
|
|||||||
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、运行渠道、会话、调度器和后台任务 |
|
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、运行渠道、会话、调度器和后台任务 |
|
||||||
| CLI client | `cargo run -- chat` | 运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 |
|
| 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。
|
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
|
||||||
|
|
||||||
Gateway 启动时会切换进程工作目录到 `workspace_dir`。因此所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
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` 先停止外部消息入口并注销渠道。
|
2. `ChannelManager::stop_all` 先停止外部消息入口并注销渠道。
|
||||||
3. 取消 TaskSupervisor,停止接受新后台任务。
|
3. 取消 TaskSupervisor,停止接受新后台任务。
|
||||||
4. 在共享的 10 秒总宽限期内等待任务退出,之后 abort 剩余任务。
|
4. 在共享的 10 秒总宽限期内等待任务退出,之后 abort 剩余任务。
|
||||||
|
|||||||
@ -10,6 +10,18 @@ cargo run -- gateway
|
|||||||
# 启动 CLI 客户端 (连接 ws://127.0.0.1:19876/ws)
|
# 启动 CLI 客户端 (连接 ws://127.0.0.1:19876/ws)
|
||||||
cargo run -- chat
|
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
|
cargo test --lib
|
||||||
|
|
||||||
|
|||||||
@ -434,9 +434,7 @@ pub async fn run(
|
|||||||
let connection_shutdown = state.connection_shutdown.clone();
|
let connection_shutdown = state.connection_shutdown.clone();
|
||||||
let serve_result = axum::serve(listener, app)
|
let serve_result = axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(async move {
|
.with_graceful_shutdown(async move {
|
||||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
wait_for_shutdown_signal().await;
|
||||||
tracing::error!(error = %error, "Failed to listen for shutdown signal");
|
|
||||||
}
|
|
||||||
tracing::info!("Shutdown signal received");
|
tracing::info!("Shutdown signal received");
|
||||||
connection_shutdown.cancel();
|
connection_shutdown.cancel();
|
||||||
})
|
})
|
||||||
@ -456,6 +454,37 @@ pub async fn run(
|
|||||||
Ok(())
|
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.
|
/// Release default AGENTS.md and USER.md templates to ~/.picobot/ if not already present.
|
||||||
fn ensure_default_config_files() {
|
fn ensure_default_config_files() {
|
||||||
let picobot_dir = dirs::home_dir().unwrap_or_default().join(".picobot");
|
let picobot_dir = dirs::home_dir().unwrap_or_default().join(".picobot");
|
||||||
|
|||||||
@ -11,6 +11,7 @@ pub mod observability;
|
|||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
pub mod service;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod storage;
|
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)]
|
#[derive(Parser)]
|
||||||
#[command(name = "picobot")]
|
#[command(name = "picobot")]
|
||||||
@ -20,6 +36,11 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
},
|
},
|
||||||
|
/// Manage the PicoBot systemd user service
|
||||||
|
Service {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ServiceCommand,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@ -44,6 +65,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Command::Gateway { host, port } => {
|
Command::Gateway { host, port } => {
|
||||||
picobot::gateway::run(host, port).await?;
|
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(())
|
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