use crate::session::{ModelInfo, Session};
use crate::store::{now_ms, Msg, SessionMeta, Store, WorktreeMeta};
use axum::body::Body;
use axum::extract::{Form, Multipart, Path, Query, State};
use axum::http::{header, Request, StatusCode};
use axum::middleware::Next;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{Html, IntoResponse, Json, Redirect, Response};
use axum::routing::{delete, get, post};
use axum::Router;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use futures_util::StreamExt;
use std::collections::HashMap;
use std::convert::Infallible;
use std::path::{Component, PathBuf};
use std::sync::Arc;
use std::process::Command;
use tokio::sync::Mutex;
use tokio_stream::wrappers::UnboundedReceiverStream;
pub struct Manager {
store: Store,
sessions: Mutex<HashMap<String, Arc<Session>>>,
pi_path: String,
projects: Mutex<Vec<Project>>,
projects_path: PathBuf,
uploads_dir: PathBuf,
default_cwd: PathBuf,
pi_config_dir: PathBuf,
username: String,
password: String,
direct_session: String,
tunnel_token: String,
}
impl Manager {
pub fn new(store: Store, pi_path: String, cwd: PathBuf, state_dir: PathBuf, username: String, password: String, tunnel_token: String) -> anyhow::Result<Arc<Self>> {
std::fs::create_dir_all(&state_dir)?;
let projects_path = state_dir.join("projects.json");
let uploads_dir = state_dir.join("uploads");
std::fs::create_dir_all(&uploads_dir)?;
let canonical_cwd = cwd.canonicalize()?;
let pi_config_dir = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".pi/agent");
let mut projects = std::fs::read(&projects_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<Vec<Project>>(&bytes).ok())
.unwrap_or_default()
.into_iter()
.filter_map(|mut project| {
let path = PathBuf::from(&project.path).canonicalize().ok()?;
if !path.is_dir() || !path.starts_with(&canonical_cwd) { return None; }
project.path = path.to_string_lossy().into_owned();
project.is_git = is_git_dir(&path);
Some(project)
})
.collect::<Vec<_>>();
if !projects.iter().any(|project| project.id == "default") {
projects.insert(0, Project::from_path("default".to_string(), canonical_cwd.clone()));
}
std::fs::write(&projects_path, serde_json::to_vec_pretty(&projects)?)?;
let manager = Arc::new(Self {
store,
sessions: Mutex::new(HashMap::new()),
pi_path,
projects: Mutex::new(projects),
projects_path,
uploads_dir,
default_cwd: canonical_cwd,
pi_config_dir,
username,
password,
direct_session: uuid::Uuid::new_v4().to_string(),
tunnel_token,
});
Ok(manager)
}
async fn persist_projects(&self) -> anyhow::Result<()> {
let bytes = serde_json::to_vec_pretty(&*self.projects.lock().await)?;
let tmp = self.projects_path.with_extension("json.tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, &self.projects_path)?;
Ok(())
}
async fn project_path(&self, id: &str) -> Option<PathBuf> {
self.projects.lock().await.iter().find(|project| project.id == id).map(|project| PathBuf::from(&project.path))
}
async fn get(&self, id: &str) -> Option<Arc<Session>> {
if let Some(s) = self.sessions.lock().await.get(id) {
return Some(Arc::clone(s));
}
if let Some((meta, msgs)) = self.store.load(id) {
let worktree_cwd = meta.worktree.as_ref().map(|worktree| PathBuf::from(&worktree.worktree_path))
.filter(|path| path.is_dir() && path.starts_with(&self.default_cwd));
let cwd = match worktree_cwd {
Some(path) => path,
None => self.project_path(&meta.project_id).await.unwrap_or_else(|| self.default_cwd.clone()),
};
let s = Session::new(
meta,
msgs,
self.pi_path.clone(),
cwd,
self.store.clone(),
);
self.sessions.lock().await.insert(id.to_string(), Arc::clone(&s));
return Some(s);
}
None
}
async fn create_worktree(&self, project_id: &str, name_hint: Option<String>) -> Result<WorktreeMeta, String> {
let project_path = self.project_path(project_id).await.ok_or_else(|| "项目不存在".to_string())?;
let workspace_root = self.default_cwd.clone();
tokio::task::spawn_blocking(move || create_git_worktree(&workspace_root, &project_path, name_hint.as_deref()))
.await
.map_err(|error| error.to_string())?
}
pub fn upload(&self, project_id: &str, name: &str, data: bytes::Bytes) -> anyhow::Result<String> {
let safe = std::path::Path::new(name)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file");
let dir = self.uploads_dir.join(project_id);
std::fs::create_dir_all(&dir)?;
let path = dir.join(safe);
std::fs::write(&path, data)?;
Ok(path.to_string_lossy().into_owned())
}
pub async fn serve(self: Arc<Self>, addr: &str) -> anyhow::Result<()> {
let protected = Router::new()
.route("/api/sessions", get(list).post(create))
.route("/api/projects", get(list_projects).post(create_project))
.route("/api/settings", get(settings_info))
.route("/api/update", post(update_pi))
.route("/api/pi/config/:name", get(read_pi_config).put(write_pi_config))
.route("/api/directories", get(list_directories))
.route("/api/sessions/:id", delete(remove).patch(rename))
.route("/api/sessions/:id/messages", get(messages).post(post_message))
.route("/api/sessions/:id/events", get(events))
.route("/api/sessions/:id/models", get(session_models))
.route("/api/sessions/:id/model", post(set_model))
.route("/api/sessions/:id/thinking", post(set_thinking))
.route("/api/sessions/:id/abort", post(abort_session))
.route("/api/upload", post(upload))
.route("/api/files", get(list_files))
.route("/api/file", get(read_file))
.fallback(crate::web::serve)
.layer(axum::middleware::from_fn_with_state(self.clone(), direct_auth));
let app = Router::new()
.route("/login", get(direct_login_form).post(direct_login))
.route("/logout", post(direct_logout))
.merge(protected)
.with_state(self);
let listener = tokio::net::TcpListener::bind(addr).await?;
log::info!("brain listening on http://{addr} (embedded web)");
axum::serve(listener, app).await?;
Ok(())
}
}
const DIRECT_SESSION_COOKIE: &str = "xagent_pi_session";
fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
headers.get(header::COOKIE)?.to_str().ok()?.split(';').find_map(|part| {
let (key, value) = part.trim().split_once('=')?;
(key == name).then(|| value.to_string())
})
}
async fn direct_auth(
State(mgr): State<Arc<Manager>>,
request: Request<Body>,
next: Next,
) -> Response {
let tunnel_ok = request
.headers()
.get("x-xagent-tunnel-auth")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == mgr.tunnel_token);
let session_ok = cookie_value(request.headers(), DIRECT_SESSION_COOKIE)
.is_some_and(|value| value == mgr.direct_session);
if tunnel_ok || session_ok {
return next.run(request).await;
}
if request.uri().path().starts_with("/api/") {
return (StatusCode::UNAUTHORIZED, "需要登录").into_response();
}
Redirect::temporary("/login").into_response()
}
#[derive(Deserialize)]
struct DirectLoginInput {
username: String,
password: String,
}
async fn direct_login_form() -> Html<String> {
Html(direct_login_page(None))
}
async fn direct_login(
State(mgr): State<Arc<Manager>>,
Form(input): Form<DirectLoginInput>,
) -> Response {
if input.username != mgr.username || input.password != mgr.password {
return (StatusCode::UNAUTHORIZED, Html(direct_login_page(Some("用户名或密码错误"))))
.into_response();
}
let mut response = Redirect::to("/").into_response();
let cookie = format!(
"{DIRECT_SESSION_COOKIE}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200",
mgr.direct_session
);
response.headers_mut().insert(header::SET_COOKIE, cookie.parse().unwrap());
response
}
async fn direct_logout() -> Response {
let mut response = Redirect::to("/login").into_response();
response.headers_mut().insert(
header::SET_COOKIE,
format!("{DIRECT_SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0")
.parse()
.unwrap(),
);
response
}
fn direct_login_page(error: Option<&str>) -> String {
let error = error
.map(|message| format!(r#"<p class="error" role="alert">{message}</p>"#))
.unwrap_or_default();
format!(
r#"<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>XAGENT · 直连登录</title><style>
:root{{color-scheme:light dark}}*{{box-sizing:border-box}}body{{min-height:100vh;margin:0;display:grid;place-items:center;padding:20px;background:#f4f5f7;color:#202124;font:14px/1.5 system-ui,sans-serif}}
main{{width:min(100%,380px);padding:26px;border:1px solid #dfe1e5;border-radius:8px;background:#fff}}h1{{margin:0 0 8px;font-size:18px}}p{{margin:0 0 20px;color:#68707a}}label{{display:block;margin:12px 0 5px;color:#68707a;font-size:13px}}
input{{display:block;width:100%;height:40px;padding:0 10px;border:1px solid #cbd0d6;border-radius:6px;outline:none;background:#fff;color:inherit;font:inherit}}input:focus{{border-color:#202124;box-shadow:0 0 0 1px #202124}}
button{{width:100%;height:40px;margin-top:20px;border:1px solid #202124;border-radius:6px;background:#202124;color:#fff;font:inherit;font-weight:600;cursor:pointer}}.error{{margin:0 0 16px;padding:9px 10px;border:1px solid #efb4b4;border-radius:6px;background:#fff1f1;color:#a32929;font-size:13px}}
@media(prefers-color-scheme:dark){{body{{background:#15161a;color:#e5e7eb}}main{{border-color:#35383e;background:#202228}}p,label{{color:#a6abb4}}input{{border-color:#494d55;background:#17181c}}input:focus{{border-color:#e5e7eb;box-shadow:0 0 0 1px #e5e7eb}}button{{border-color:#e5e7eb;background:#e5e7eb;color:#17181b}}.error{{border-color:#713b3b;background:#352124;color:#ffb4b4}}}}
</style></head><body><main><form method="POST" action="/login" autocomplete="on"><h1>登录 XAGENT</h1><p>输入此设备的账户信息以直接访问工作区。</p>{error}<label for="u">用户名</label><input id="u" name="username" autofocus required autocomplete="username"/><label for="p">密码</label><input id="p" name="password" type="password" required autocomplete="current-password"/><button type="submit">登录</button></form></main></body></html>"#
)
}
#[derive(Serialize)]
struct PiConfigOut {
name: String,
path: String,
content: String,
revision: String,
}
#[derive(Deserialize)]
struct PiConfigIn {
content: String,
revision: String,
}
fn pi_config_path(manager: &Manager, name: &str) -> Option<PathBuf> {
match name {
"settings" => Some(manager.pi_config_dir.join("settings.json")),
"models" => Some(manager.pi_config_dir.join("models.json")),
_ => None,
}
}
fn content_revision(content: &str) -> String {
format!("{:x}", Sha256::digest(content.as_bytes()))
}
fn validate_pi_config(name: &str, content: &str) -> Result<(), StatusCode> {
let value: serde_json::Value = serde_json::from_str(content).map_err(|_| StatusCode::BAD_REQUEST)?;
let object = value.as_object().ok_or(StatusCode::BAD_REQUEST)?;
if name == "settings" {
for key in ["defaultProvider", "defaultModel", "defaultThinkingLevel"] {
if object.get(key).is_some_and(|value| !value.is_string()) {
return Err(StatusCode::BAD_REQUEST);
}
}
} else if !object.get("providers").is_some_and(|value| value.is_object()) {
return Err(StatusCode::BAD_REQUEST);
}
Ok(())
}
async fn read_pi_config(
State(manager): State<Arc<Manager>>,
Path(name): Path<String>,
) -> Result<Json<PiConfigOut>, StatusCode> {
let path = pi_config_path(&manager, &name).ok_or(StatusCode::NOT_FOUND)?;
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => "{}\n".to_string(),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
Ok(Json(PiConfigOut {
name,
path: path.to_string_lossy().into_owned(),
revision: content_revision(&content),
content,
}))
}
async fn write_pi_config(
State(manager): State<Arc<Manager>>,
Path(name): Path<String>,
Json(input): Json<PiConfigIn>,
) -> Result<Json<PiConfigOut>, StatusCode> {
let path = pi_config_path(&manager, &name).ok_or(StatusCode::NOT_FOUND)?;
validate_pi_config(&name, &input.content)?;
let existing = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => "{}\n".to_string(),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
if content_revision(&existing) != input.revision {
return Err(StatusCode::CONFLICT);
}
let value: serde_json::Value = serde_json::from_str(&input.content).map_err(|_| StatusCode::BAD_REQUEST)?;
let content = format!("{}\n", serde_json::to_string_pretty(&value).map_err(|_| StatusCode::BAD_REQUEST)?);
std::fs::create_dir_all(&manager.pi_config_dir).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if path.exists() {
std::fs::copy(&path, path.with_extension("json.backup")).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
let temporary = path.with_extension("json.tmp");
std::fs::write(&temporary, &content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
std::fs::rename(&temporary, &path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(PiConfigOut {
name,
path: path.to_string_lossy().into_owned(),
revision: content_revision(&content),
content,
}))
}
#[derive(Serialize)]
struct SettingsInfo {
username: String,
workspace_root: String,
data_dir: String,
version: &'static str,
}
async fn settings_info(State(mgr): State<Arc<Manager>>) -> Json<SettingsInfo> {
let data_dir = mgr.projects_path.parent().unwrap_or(&mgr.projects_path);
Json(SettingsInfo {
username: mgr.username.clone(),
workspace_root: mgr.default_cwd.to_string_lossy().into_owned(),
data_dir: data_dir.to_string_lossy().into_owned(),
version: env!("CARGO_PKG_VERSION"),
})
}
async fn update_pi(State(mgr): State<Arc<Manager>>) -> Result<StatusCode, (StatusCode, String)> {
let log_path = mgr.projects_path.parent().unwrap_or(&mgr.projects_path).join("update.log");
let prefix = tokio::process::Command::new("npm")
.args(["prefix", "-g"])
.output()
.await
.map_err(|error| (StatusCode::SERVICE_UNAVAILABLE, format!("npm 不可用: {error}")))?;
if !prefix.status.success() {
return Err((StatusCode::SERVICE_UNAVAILABLE, "无法读取 npm 全局目录".to_string()));
}
let prefix = String::from_utf8(prefix.stdout)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "npm 全局目录格式无效".to_string()))?;
let executable = std::path::Path::new(prefix.trim()).join("bin/xagent-pi");
tokio::spawn(async move {
let log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path);
let Ok(log) = log else {
log::error!("cannot open update log {}", log_path.display());
return;
};
let stderr = match log.try_clone() {
Ok(file) => file,
Err(error) => {
log::error!("cannot clone update log: {error}");
return;
}
};
let status = tokio::process::Command::new("npm")
.args(["install", "-g", "xagent-pi"])
.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(stderr))
.status()
.await;
if !status.is_ok_and(|value| value.success()) {
log::error!("npm update failed; see {}", log_path.display());
return;
}
let restart_log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path);
let restart_error = restart_log.as_ref().ok().and_then(|file| file.try_clone().ok());
let mut restart = tokio::process::Command::new("sh");
restart
.args(["-c", "sleep 1; exec \"$1\" start", "xagent-updater"])
.arg(executable)
.stdin(std::process::Stdio::null())
.stdout(restart_log.map(std::process::Stdio::from).unwrap_or_else(|_| std::process::Stdio::null()))
.stderr(restart_error.map(std::process::Stdio::from).unwrap_or_else(std::process::Stdio::null));
match restart.spawn() {
Ok(_) => unsafe { libc::kill(std::process::id() as i32, libc::SIGTERM); },
Err(error) => log::error!("cannot schedule restart after update: {error}"),
}
});
Ok(StatusCode::ACCEPTED)
}
#[derive(Clone, Serialize, Deserialize)]
struct Project {
id: String,
name: String,
path: String,
#[serde(default)]
is_git: bool,
}
impl Project {
fn from_path(id: String, path: PathBuf) -> Self {
let name = path.file_name().and_then(|value| value.to_str()).filter(|value| !value.is_empty()).unwrap_or("项目").to_string();
let is_git = is_git_dir(&path);
Self { id, name, path: path.to_string_lossy().into_owned(), is_git }
}
}
fn is_git_dir(path: &std::path::Path) -> bool {
path.join(".git").exists()
}
fn git_output(cwd: &std::path::Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git").args(args).current_dir(cwd).output().map_err(|error| error.to_string())?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
let error = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if error.is_empty() { "Git 命令执行失败".to_string() } else { error })
}
fn current_git_branch(cwd: &std::path::Path) -> Option<String> {
git_output(cwd, &["branch", "--show-current"])
.ok()
.filter(|branch| !branch.is_empty())
}
fn worktree_slug(value: Option<&str>) -> String {
let slug = value.unwrap_or("").to_ascii_lowercase().chars().map(|character| {
if character.is_ascii_alphanumeric() { character } else { '-' }
}).collect::<String>();
let slug = slug.split('-').filter(|part| !part.is_empty()).collect::<Vec<_>>().join("-");
if slug.is_empty() { uuid::Uuid::new_v4().simple().to_string()[..8].to_string() } else { slug.chars().take(48).collect() }
}
fn create_git_worktree(workspace_root: &std::path::Path, project_path: &std::path::Path, name_hint: Option<&str>) -> Result<WorktreeMeta, String> {
let repo_root = PathBuf::from(git_output(project_path, &["rev-parse", "--show-toplevel"])?).canonicalize().map_err(|error| error.to_string())?;
if !repo_root.starts_with(workspace_root) {
return Err("Git 仓库不在允许的工作目录内".to_string());
}
let repo_name = repo_root.file_name().and_then(|value| value.to_str()).unwrap_or("repository");
let sibling_root = repo_root.parent().unwrap_or(workspace_root).join(format!("{repo_name}-worktrees"));
let worktrees_root = if sibling_root.starts_with(workspace_root) {
sibling_root
} else {
workspace_root.join(".xagent-worktrees").join(repo_name)
};
std::fs::create_dir_all(&worktrees_root).map_err(|error| error.to_string())?;
let base_name = worktree_slug(name_hint);
for attempt in 0..5 {
let name = if attempt == 0 { base_name.clone() } else { format!("{base_name}-{}", &uuid::Uuid::new_v4().simple().to_string()[..4]) };
let branch = format!("xagent-{name}");
let path = worktrees_root.join(&name);
if path.exists() { continue; }
let branch_exists = Command::new("git").args(["show-ref", "--verify", "--quiet", &format!("refs/heads/{branch}")]).current_dir(&repo_root).status().map(|status| status.success()).unwrap_or(false);
if branch_exists { continue; }
let output = Command::new("git").arg("worktree").arg("add").arg("-b").arg(&branch).arg(&path).current_dir(&repo_root).output().map_err(|error| error.to_string())?;
if output.status.success() {
return Ok(WorktreeMeta {
base_path: repo_root.to_string_lossy().into_owned(),
worktree_path: path.to_string_lossy().into_owned(),
branch,
name,
created_at: now_ms(),
});
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if message.is_empty() { "无法创建 Git worktree".to_string() } else { message });
}
Err("worktree 名称或分支重复,请更换名称".to_string())
}
fn rollback_git_worktree(worktree: &WorktreeMeta) {
let _ = Command::new("git").arg("worktree").arg("remove").arg("--force").arg(&worktree.worktree_path).current_dir(&worktree.base_path).status();
let _ = Command::new("git").arg("branch").arg("-D").arg(&worktree.branch).current_dir(&worktree.base_path).status();
}
const FILE_PREVIEW_LIMIT: u64 = 2 * 1024 * 1024;
#[derive(Deserialize)]
struct FileQuery {
#[serde(default)]
path: String,
project_id: Option<String>,
session_id: Option<String>,
}
async fn query_workspace(mgr: &Manager, query: &FileQuery) -> Result<PathBuf, StatusCode> {
if let Some(session_id) = query.session_id.as_deref() {
let (meta, _) = mgr.store.load(session_id).ok_or(StatusCode::NOT_FOUND)?;
if let Some(worktree) = meta.worktree {
let path = PathBuf::from(worktree.worktree_path).canonicalize().map_err(|_| StatusCode::NOT_FOUND)?;
if path.is_dir() && path.starts_with(&mgr.default_cwd) { return Ok(path); }
return Err(StatusCode::FORBIDDEN);
}
return mgr.project_path(&meta.project_id).await.ok_or(StatusCode::NOT_FOUND);
}
match query.project_id.as_deref() {
Some(id) => mgr.project_path(id).await.ok_or(StatusCode::NOT_FOUND),
None => Ok(mgr.default_cwd.clone()),
}
}
#[derive(Serialize)]
struct FileEntry {
name: String,
path: String,
is_dir: bool,
is_git: bool,
size: u64,
}
#[derive(Serialize)]
struct DirectoryOut {
path: String,
absolute_path: String,
entries: Vec<FileEntry>,
}
#[derive(Serialize)]
struct FileOut {
path: String,
name: String,
size: u64,
content: String,
}
fn resolve_workspace_path(root: &std::path::Path, relative: &str) -> Result<PathBuf, StatusCode> {
let relative = std::path::Path::new(relative);
if relative.is_absolute()
|| relative.components().any(|component| {
!matches!(component, Component::Normal(_) | Component::CurDir)
})
{
return Err(StatusCode::BAD_REQUEST);
}
let root = root.canonicalize().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let target = root.join(relative).canonicalize().map_err(|_| StatusCode::NOT_FOUND)?;
if !target.starts_with(&root) {
return Err(StatusCode::FORBIDDEN);
}
Ok(target)
}
async fn list_files(
State(mgr): State<Arc<Manager>>,
Query(query): Query<FileQuery>,
) -> Result<Json<DirectoryOut>, StatusCode> {
let root = query_workspace(&mgr, &query).await?;
let target = resolve_workspace_path(&root, &query.path)?;
if !target.is_dir() {
return Err(StatusCode::BAD_REQUEST);
}
let absolute_path = target.to_string_lossy().into_owned();
let mut entries = Vec::new();
let read_dir = std::fs::read_dir(target).map_err(|_| StatusCode::FORBIDDEN)?;
for entry in read_dir.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue };
let is_dir = metadata.is_dir();
let is_git = is_dir && is_git_dir(&entry.path());
let path = if query.path.is_empty() {
name.clone()
} else {
format!("{}/{}", query.path.trim_end_matches('/'), name)
};
entries.push(FileEntry {
name,
path,
is_dir,
is_git,
size: if metadata.is_file() { metadata.len() } else { 0 },
});
}
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(Json(DirectoryOut { path: query.path, absolute_path, entries }))
}
async fn read_file(
State(mgr): State<Arc<Manager>>,
Query(query): Query<FileQuery>,
) -> Result<Json<FileOut>, StatusCode> {
let root = query_workspace(&mgr, &query).await?;
let target = resolve_workspace_path(&root, &query.path)?;
let metadata = target.metadata().map_err(|_| StatusCode::NOT_FOUND)?;
if !metadata.is_file() {
return Err(StatusCode::BAD_REQUEST);
}
if metadata.len() > FILE_PREVIEW_LIMIT {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
let bytes = std::fs::read(&target).map_err(|_| StatusCode::FORBIDDEN)?;
let content = String::from_utf8(bytes).map_err(|_| StatusCode::UNSUPPORTED_MEDIA_TYPE)?;
let name = target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("file")
.to_string();
Ok(Json(FileOut { path: query.path, name, size: metadata.len(), content }))
}
async fn list(State(mgr): State<Arc<Manager>>) -> Json<Vec<SessionMeta>> {
let projects = mgr.projects.lock().await.clone();
let sessions = mgr.store.list().into_iter().map(|mut meta| {
meta.branch = meta.worktree.as_ref().map(|worktree| worktree.branch.clone()).or_else(|| {
projects
.iter()
.find(|project| project.id == meta.project_id)
.and_then(|project| current_git_branch(std::path::Path::new(&project.path)))
});
meta
}).collect();
Json(sessions)
}
#[derive(Deserialize)]
struct CreateIn {
title: Option<String>,
project_id: Option<String>,
session_type: Option<String>,
worktree_name: Option<String>,
}
async fn create(
State(mgr): State<Arc<Manager>>,
Json(inp): Json<CreateIn>,
) -> Result<Json<SessionMeta>, StatusCode> {
let project_id = inp.project_id.unwrap_or_else(|| "default".to_string());
let project_path = mgr.project_path(&project_id).await.ok_or(StatusCode::BAD_REQUEST)?;
let worktree = match inp.session_type.as_deref().unwrap_or("simple") {
"simple" => None,
"worktree" => Some(mgr.create_worktree(&project_id, inp.worktree_name).await.map_err(|error| {
log::warn!("worktree creation failed: {error}");
StatusCode::BAD_REQUEST
})?),
_ => return Err(StatusCode::BAD_REQUEST),
};
let now = now_ms();
let meta = SessionMeta {
id: uuid::Uuid::new_v4().to_string(),
project_id,
title: inp.title.unwrap_or_else(|| "New chat".to_string()),
created_at: now,
updated_at: now,
branch: worktree
.as_ref()
.map(|value| value.branch.clone())
.or_else(|| current_git_branch(&project_path)),
worktree,
};
if let Err(error) = mgr.store.save(&meta, &[]) {
if let Some(worktree) = meta.worktree.as_ref() { rollback_git_worktree(worktree); }
log::warn!("session persistence failed after creation: {error}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(Json(meta))
}
async fn remove(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
mgr.sessions.lock().await.remove(&id);
mgr.store.delete(&id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
async fn messages(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
) -> Result<Json<Vec<Msg>>, StatusCode> {
let s = mgr.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(s.messages().await))
}
#[derive(Deserialize)]
struct PostIn {
text: String,
}
async fn post_message(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
Json(inp): Json<PostIn>,
) -> Result<StatusCode, StatusCode> {
if inp.text.trim().is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
let s = mgr.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
s.push_user(&inp.text).await;
s.send_prompt(inp.text).await;
Ok(StatusCode::OK)
}
async fn events(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>>, StatusCode> {
let s = mgr.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
let rx = s.subscribe().await;
let stream = UnboundedReceiverStream::new(rx).map(|e| {
let json = serde_json::to_string(&e).unwrap_or_default();
Ok::<_, Infallible>(Event::default().data(json))
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
#[derive(Serialize)]
struct ModelsOut {
current: Option<CurrentModel>,
available: Vec<ModelInfo>,
}
#[derive(Serialize)]
struct CurrentModel {
provider: String,
model_id: String,
}
async fn session_models(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
) -> Result<Json<ModelsOut>, StatusCode> {
let s = mgr.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
let (cur, avail) = s.models().await;
Ok(Json(ModelsOut {
current: cur.map(|(provider, model_id)| CurrentModel { provider, model_id }),
available: avail,
}))
}
#[derive(Deserialize)]
struct ModelIn {
provider: String,
model_id: String,
}
async fn set_model(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
Json(inp): Json<ModelIn>,
) -> Result<StatusCode, StatusCode> {
let s = mgr.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
if s.set_model(inp.provider, inp.model_id).await {
Ok(StatusCode::OK)
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
#[derive(Deserialize)]
struct ThinkingIn {
level: String,
}
async fn set_thinking(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
Json(inp): Json<ThinkingIn>,
) -> Result<StatusCode, StatusCode> {
let s = mgr.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
if s.set_thinking_level(inp.level).await {
Ok(StatusCode::OK)
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
#[derive(Serialize)]
struct UploadOut {
path: String,
name: String,
}
async fn upload(
State(mgr): State<Arc<Manager>>,
Query(query): Query<FileQuery>,
mut multipart: Multipart,
) -> Result<Json<UploadOut>, StatusCode> {
let project_id = query.project_id.as_deref().unwrap_or("default");
if mgr.project_path(project_id).await.is_none() { return Err(StatusCode::NOT_FOUND); }
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.file_name().unwrap_or("file").to_string();
let data = field
.bytes()
.await
.map_err(|_| StatusCode::BAD_REQUEST)?
.clone();
let path = mgr.upload(project_id, &name, data).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
return Ok(Json(UploadOut { path, name }));
}
Err(StatusCode::BAD_REQUEST)
}
async fn list_projects(State(mgr): State<Arc<Manager>>) -> Json<Vec<Project>> {
Json(mgr.projects.lock().await.clone())
}
#[derive(Deserialize)]
struct CreateProjectIn {
path: String,
}
async fn create_project(
State(mgr): State<Arc<Manager>>,
Json(inp): Json<CreateProjectIn>,
) -> Result<Json<Project>, StatusCode> {
let path = PathBuf::from(inp.path).canonicalize().map_err(|_| StatusCode::NOT_FOUND)?;
if !path.is_dir() {
return Err(StatusCode::BAD_REQUEST);
}
if !path.starts_with(&mgr.default_cwd) {
return Err(StatusCode::FORBIDDEN);
}
let path_text = path.to_string_lossy().into_owned();
let mut projects = mgr.projects.lock().await;
if let Some(existing) = projects.iter().find(|project| project.path == path_text) {
return Ok(Json(existing.clone()));
}
let project = Project::from_path(uuid::Uuid::new_v4().to_string(), path);
projects.push(project.clone());
drop(projects);
mgr.persist_projects().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(project))
}
#[derive(Serialize)]
struct DirectoryChoice {
name: String,
path: String,
is_git: bool,
}
#[derive(Serialize)]
struct DirectoryChoicesOut {
path: String,
parent: Option<String>,
directories: Vec<DirectoryChoice>,
}
async fn list_directories(
State(mgr): State<Arc<Manager>>,
Query(query): Query<FileQuery>,
) -> Result<Json<DirectoryChoicesOut>, StatusCode> {
let requested = if query.path.is_empty() { mgr.default_cwd.clone() } else { PathBuf::from(&query.path) };
if !requested.is_absolute() {
return Err(StatusCode::BAD_REQUEST);
}
let path = requested.canonicalize().map_err(|_| StatusCode::NOT_FOUND)?;
if !path.is_dir() {
return Err(StatusCode::BAD_REQUEST);
}
if !path.starts_with(&mgr.default_cwd) {
return Err(StatusCode::FORBIDDEN);
}
let mut directories = Vec::new();
for entry in std::fs::read_dir(&path).map_err(|_| StatusCode::FORBIDDEN)?.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if !metadata.is_dir() { continue; }
let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue };
let Ok(entry_path) = entry.path().canonicalize() else { continue };
if !entry_path.starts_with(&mgr.default_cwd) { continue; }
let is_git = is_git_dir(&entry_path);
directories.push(DirectoryChoice { name, path: entry_path.to_string_lossy().into_owned(), is_git });
}
directories.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(Json(DirectoryChoicesOut {
path: path.to_string_lossy().into_owned(),
parent: if path == mgr.default_cwd { None } else { path.parent().filter(|value| value.starts_with(&mgr.default_cwd)).map(|value| value.to_string_lossy().into_owned()) },
directories,
}))
}
#[derive(Deserialize)]
struct RenameIn {
title: String,
}
async fn rename(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
Json(inp): Json<RenameIn>,
) -> Result<Json<SessionMeta>, StatusCode> {
let s = mgr.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
s.rename(inp.title).await;
Ok(Json(s.meta().await))
}
async fn abort_session(
State(mgr): State<Arc<Manager>>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
let s = mgr.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
s.abort().await;
Ok(StatusCode::OK)
}
#[cfg(test)]
mod worktree_tests {
use super::*;
fn git(cwd: &std::path::Path, args: &[&str]) {
let status = Command::new("git").args(args).current_dir(cwd).status().unwrap();
assert!(status.success(), "git command failed: {args:?}");
}
#[test]
fn slug_normalizes_worktree_names() {
assert_eq!(worktree_slug(Some(" Feature / Login ")), "feature-login");
assert_eq!(worktree_slug(Some("a---b")), "a-b");
assert_eq!(worktree_slug(Some(&"x".repeat(80))).len(), 48);
}
#[test]
fn pi_config_validation_rejects_invalid_shapes() {
assert!(validate_pi_config("settings", r#"{"defaultModel":"model-1"}"#).is_ok());
assert!(validate_pi_config("settings", r#"{"defaultModel":42}"#).is_err());
assert!(validate_pi_config("models", r#"{"providers":{}}"#).is_ok());
assert!(validate_pi_config("models", r#"{"providers":[]}"#).is_err());
assert!(validate_pi_config("models", "not json").is_err());
}
#[test]
fn pi_config_revision_changes_with_content() {
assert_eq!(content_revision("same"), content_revision("same"));
assert_ne!(content_revision("before"), content_revision("after"));
}
#[test]
fn creates_worktree_and_branch_inside_workspace() {
let root = std::env::temp_dir().join(format!("xagent-worktree-test-{}", uuid::Uuid::new_v4()));
let repo = root.join("demo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q"]);
git(&repo, &["config", "user.email", "test@example.com"]);
git(&repo, &["config", "user.name", "Xagent Test"]);
std::fs::write(repo.join("README.md"), "test\n").unwrap();
git(&repo, &["add", "README.md"]);
git(&repo, &["commit", "-q", "-m", "initial"]);
assert!(current_git_branch(&repo).is_some());
let info = create_git_worktree(&root, &repo, Some("Feature Login")).unwrap();
assert_eq!(info.name, "feature-login");
assert_eq!(info.branch, "xagent-feature-login");
assert!(PathBuf::from(&info.worktree_path).is_dir());
assert!(PathBuf::from(&info.worktree_path).starts_with(&root));
git(&repo, &["worktree", "remove", "--force", &info.worktree_path]);
std::fs::remove_dir_all(root).unwrap();
}
}