PicoBot/src/gateway/http.rs
xiaoxixi 5501c539fc feat: remove agent run groups, add WebUI agent definition management
- drop agent_run_groups table and group_id/scope_kind/scope_id columns (schema v8)
- remove group_id from AgentExecutionContext and recovery group counters
- flatten TasksPage background tab into a per-run list
- add WebUI Agents page with definition CRUD and inline provider/model
- bump version to 1.11.0
2026-08-13 14:03:01 +08:00

1525 lines
48 KiB
Rust

use super::GatewayState;
use crate::config::Config;
use crate::memory::MemoryCategory;
use axum::Json;
use axum::body::Body;
use axum::extract::{Multipart, Path, Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio_util::io::ReaderStream;
const REDACTED: &str = "********";
const MAX_CONFIG_BYTES: usize = 1024 * 1024;
const MAX_PROFILE_BYTES: usize = 256 * 1024;
struct TemporaryUpload {
path: PathBuf,
committed: bool,
}
impl Drop for TemporaryUpload {
fn drop(&mut self) {
if !self.committed {
let _ = std::fs::remove_file(&self.path);
}
}
}
#[derive(Serialize)]
pub struct HealthResponse {
status: String,
version: String,
}
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
fn static_response(content_type: &'static str, content: &'static str) -> Response {
Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "no-cache")
.header("X-Content-Type-Options", "nosniff")
.header(
"Content-Security-Policy",
"default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'",
)
.body(Body::from(content))
.expect("valid static response")
}
pub async fn webui_index() -> Response {
static_response(
"text/html; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/index.html")),
)
}
pub async fn webui_script() -> Response {
static_response(
"text/javascript; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/app.js")),
)
}
pub async fn webui_styles() -> Response {
static_response(
"text/css; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/styles.css")),
)
}
pub async fn webui_theme_init() -> Response {
static_response(
"text/javascript; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/theme-init.js")),
)
}
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
(
"space-grotesk.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk.woff2")),
),
(
"jetbrains-mono-400.woff2",
include_bytes!(concat!(
env!("OUT_DIR"),
"/webui/fonts/jetbrains-mono-400.woff2"
)),
),
(
"jetbrains-mono-700.woff2",
include_bytes!(concat!(
env!("OUT_DIR"),
"/webui/fonts/jetbrains-mono-700.woff2"
)),
),
];
pub async fn webui_font(Path(name): Path<String>) -> Response {
let bytes = EMBEDDED_FONTS
.iter()
.find(|(font_name, _)| *font_name == name)
.map(|(_, bytes)| *bytes);
let Some(bytes) = bytes else {
return StatusCode::NOT_FOUND.into_response();
};
Response::builder()
.header(header::CONTENT_TYPE, "font/woff2")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from(bytes))
.expect("valid font response")
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.into(),
}
}
fn payload_too_large(message: impl Into<String>) -> Self {
Self {
status: StatusCode::PAYLOAD_TOO_LARGE,
message: message.into(),
}
}
fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
message: message.into(),
}
}
fn service_unavailable(message: impl Into<String>) -> Self {
Self {
status: StatusCode::SERVICE_UNAVAILABLE,
message: message.into(),
}
}
fn internal(error: impl std::fmt::Display) -> Self {
tracing::error!(error = %error, "WebUI API request failed");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: error.to_string(),
}
}
fn internal_with_message(error: impl std::fmt::Display, message: impl Into<String>) -> Self {
tracing::error!(error = %error, "WebUI API request failed");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.into(),
}
}
}
pub async fn upload_file(
State(state): State<Arc<GatewayState>>,
Path(client_id): Path<String>,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<crate::protocol::UploadDescriptor>), ApiError> {
if !valid_client_id(&client_id) {
return Err(ApiError::bad_request("invalid client id"));
}
if !state.uploads.enabled() {
return Err(ApiError::bad_request("file transfer is disabled"));
}
let mut field = multipart
.next_field()
.await
.map_err(|error| ApiError::bad_request(format!("invalid multipart body: {error}")))?
.ok_or_else(|| ApiError::bad_request("file field is required"))?;
if field.name() != Some("file") {
return Err(ApiError::bad_request("expected multipart field named file"));
}
let original_name = field.file_name().unwrap_or("attachment").to_string();
let safe_name = crate::gateway::uploads::UploadRegistry::safe_file_name(&original_name);
let (temporary_path, final_path, upload_id) = state
.uploads
.allocate_path(&client_id, &safe_name)
.await
.map_err(ApiError::internal)?;
let mut temporary = TemporaryUpload {
path: temporary_path.clone(),
committed: false,
};
let mut output = tokio::fs::File::create(&temporary_path)
.await
.map_err(ApiError::internal)?;
let mut size = 0_u64;
while let Some(chunk) = field
.chunk()
.await
.map_err(|error| ApiError::bad_request(format!("failed to read upload: {error}")))?
{
size = size.saturating_add(chunk.len() as u64);
if size > state.uploads.max_file_bytes() {
drop(output);
let _ = tokio::fs::remove_file(&temporary_path).await;
return Err(ApiError::payload_too_large(format!(
"file exceeds {} bytes",
state.uploads.max_file_bytes()
)));
}
if let Err(error) = output.write_all(&chunk).await {
drop(output);
let _ = tokio::fs::remove_file(&temporary_path).await;
return Err(ApiError::internal(error));
}
}
if size == 0 {
drop(output);
let _ = tokio::fs::remove_file(&temporary_path).await;
return Err(ApiError::bad_request("empty files are not supported"));
}
output.sync_all().await.map_err(ApiError::internal)?;
drop(output);
tokio::fs::rename(&temporary_path, &final_path)
.await
.map_err(ApiError::internal)?;
temporary.committed = true;
let mime_type = mime_guess::from_path(&safe_name)
.first_or_octet_stream()
.to_string();
let media_type = media_type_for_mime(&mime_type).to_string();
let descriptor = match state
.uploads
.register(
upload_id,
client_id,
final_path.clone(),
crate::protocol::UploadDescriptor {
upload_id: String::new(),
name: safe_name,
media_type,
mime_type,
size,
expires_at: 0,
},
)
.await
{
Ok(descriptor) => descriptor,
Err(error) => {
let _ = tokio::fs::remove_file(final_path).await;
return Err(ApiError::bad_request(error.to_string()));
}
};
Ok((StatusCode::CREATED, Json(descriptor)))
}
#[derive(Debug, Default, Deserialize)]
pub struct AttachmentQuery {
disposition: Option<String>,
}
pub async fn download_attachment(
State(state): State<Arc<GatewayState>>,
Path((client_id, session_id, message_id, index)): Path<(String, String, String, usize)>,
Query(query): Query<AttachmentQuery>,
) -> Result<Response, ApiError> {
if !valid_client_id(&client_id) {
return Err(ApiError::not_found("attachment not found"));
}
let unified = crate::session::UnifiedSessionId::parse(&session_id)
.filter(|id| id.channel == "cli_chat" && id.chat_id == client_id)
.ok_or_else(|| ApiError::not_found("attachment not found"))?;
let message = state
.storage
.get_message(&unified.to_string(), &message_id)
.await
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("attachment not found"))?;
let media_refs = message
.media_refs
.as_deref()
.and_then(|value| serde_json::from_str::<Vec<crate::bus::MediaRef>>(value).ok())
.unwrap_or_default();
let media_ref = media_refs
.get(index)
.ok_or_else(|| ApiError::not_found("attachment not found"))?;
let path = FsPath::new(&media_ref.path);
let metadata = tokio::fs::metadata(path)
.await
.map_err(|_| ApiError::not_found("the original file was moved or deleted"))?;
if !metadata.is_file() {
return Err(ApiError::not_found(
"the original file was moved or deleted",
));
}
let file = tokio::fs::File::open(path)
.await
.map_err(|_| ApiError::not_found("the original file was moved or deleted"))?;
let name = path
.file_name()
.and_then(|value| value.to_str())
.map(crate::gateway::uploads::UploadRegistry::safe_file_name)
.unwrap_or_else(|| "attachment".to_string());
let mime_type = mime_guess::from_path(&name)
.first_or_octet_stream()
.to_string();
let inline = query.disposition.as_deref() == Some("inline") && inline_mime_allowed(&mime_type);
let disposition = if inline { "inline" } else { "attachment" };
let encoded_name = encode_header_filename(&name);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_LENGTH, metadata.len())
.header(
header::CONTENT_DISPOSITION,
format!("{disposition}; filename*=UTF-8''{encoded_name}"),
)
.header("X-Content-Type-Options", "nosniff")
.header(header::CACHE_CONTROL, "private, no-store")
.body(Body::from_stream(ReaderStream::new(file)))
.map_err(ApiError::internal)
}
fn valid_client_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
}
fn media_type_for_mime(mime: &str) -> &'static str {
if mime.starts_with("image/") {
"image"
} else if mime.starts_with("audio/") {
"audio"
} else if mime.starts_with("video/") {
"video"
} else {
"file"
}
}
fn inline_mime_allowed(mime: &str) -> bool {
matches!(
mime,
"image/png"
| "image/jpeg"
| "image/gif"
| "image/webp"
| "image/bmp"
| "image/avif"
| "image/x-icon"
| "audio/mpeg"
| "audio/ogg"
| "audio/wav"
| "video/mp4"
| "video/webm"
)
}
fn encode_header_filename(value: &str) -> String {
value
.as_bytes()
.iter()
.map(|byte| match *byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
(*byte as char).to_string()
}
other => format!("%{other:02X}"),
})
.collect()
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}
#[derive(Serialize)]
pub struct ConfigResponse {
config: Value,
path: String,
restart_required: bool,
}
#[derive(Serialize)]
pub struct ReloadResponse {
generation: u64,
message: String,
}
pub async fn reload_config(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<ReloadResponse>, ApiError> {
let accepted = state.reload.request().await.map_err(|error| match error {
super::reload::ReloadError::AlreadyPending => ApiError::conflict(error.to_string()),
super::reload::ReloadError::ShuttingDown
| super::reload::ReloadError::PreparationFailed(_) => {
ApiError::service_unavailable(error.to_string())
}
super::reload::ReloadError::InvalidConfig(_)
| super::reload::ReloadError::ImmutableField(_) => ApiError::bad_request(error.to_string()),
})?;
Ok(Json(ReloadResponse {
generation: accepted.generation,
message: accepted.message,
}))
}
pub async fn reload_status(
State(state): State<Arc<GatewayState>>,
) -> Json<super::reload::ReloadStatus> {
Json(state.reload.status())
}
pub async fn get_config(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<ConfigResponse>, ApiError> {
let raw = tokio::fs::read_to_string(&state.config_path)
.await
.map_err(ApiError::internal)?;
let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
redact_secrets(&mut value);
Ok(Json(ConfigResponse {
config: value,
path: state.config_path.display().to_string(),
restart_required: false,
}))
}
pub async fn put_config(
State(state): State<Arc<GatewayState>>,
Json(mut incoming): Json<Value>,
) -> Result<Json<ConfigResponse>, ApiError> {
if incoming.get("config").is_some() {
incoming = incoming
.get_mut("config")
.map(Value::take)
.ok_or_else(|| ApiError::bad_request("config is required"))?;
}
let encoded_size = serde_json::to_vec(&incoming)
.map_err(|error| ApiError::bad_request(error.to_string()))?
.len();
if encoded_size > MAX_CONFIG_BYTES {
return Err(ApiError::bad_request("config exceeds 1 MiB"));
}
let current_raw = tokio::fs::read_to_string(&state.config_path)
.await
.map_err(ApiError::internal)?;
let current: Value = serde_json::from_str(&current_raw).map_err(ApiError::internal)?;
restore_redacted_secrets(&mut incoming, &current);
let parsed: Config = serde_json::from_value(incoming.clone())
.map_err(|error| ApiError::bad_request(format!("invalid config: {error}")))?;
parsed
.get_provider_config("default")
.map_err(|error| ApiError::bad_request(format!("invalid default agent: {error}")))?;
let pretty = serde_json::to_string_pretty(&incoming).map_err(ApiError::internal)? + "\n";
atomic_write(&state.config_path, pretty.as_bytes()).await?;
tracing::info!(path = %state.config_path.display(), "Configuration updated from WebUI; reload or restart required");
let mut response = incoming;
redact_secrets(&mut response);
Ok(Json(ConfigResponse {
config: response,
path: state.config_path.display().to_string(),
restart_required: true,
}))
}
fn is_secret_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key.contains("api_key")
|| key.contains("secret")
|| key.contains("password")
|| key.ends_with("token")
|| key.ends_with("_token")
|| key == "authorization"
}
fn redact_secrets(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, value) in map {
if is_secret_key(key) && value.is_string() {
*value = Value::String(REDACTED.to_string());
} else {
redact_secrets(value);
}
}
}
Value::Array(values) => values.iter_mut().for_each(redact_secrets),
_ => {}
}
}
fn restore_redacted_secrets(incoming: &mut Value, current: &Value) {
match (incoming, current) {
(Value::Object(incoming), Value::Object(current)) => {
for (key, value) in incoming {
if is_secret_key(key) && value.as_str() == Some(REDACTED) {
if let Some(original) = current.get(key) {
*value = original.clone();
}
} else if let Some(original) = current.get(key) {
restore_redacted_secrets(value, original);
}
}
}
(Value::Array(incoming), Value::Array(current)) => {
for (value, original) in incoming.iter_mut().zip(current) {
restore_redacted_secrets(value, original);
}
}
_ => {}
}
}
async fn atomic_write(path: &FsPath, content: &[u8]) -> Result<(), ApiError> {
let parent = path.parent().unwrap_or_else(|| FsPath::new("."));
tokio::fs::create_dir_all(parent)
.await
.map_err(ApiError::internal)?;
let temp = parent.join(format!(".picobot-webui-{}.tmp", crate::util::short_id()));
tokio::fs::write(&temp, content)
.await
.map_err(ApiError::internal)?;
if let Err(error) = tokio::fs::rename(&temp, path).await {
let _ = tokio::fs::remove_file(&temp).await;
return Err(ApiError::internal(error));
}
Ok(())
}
#[derive(Serialize)]
pub struct ProfileResponse {
name: String,
content: String,
path: String,
}
#[derive(Deserialize)]
pub struct ProfileUpdate {
content: String,
}
fn profile_path(name: &str) -> Result<PathBuf, ApiError> {
let file = match name.to_ascii_lowercase().as_str() {
"user" | "user.md" => "USER.md",
"agents" | "agents.md" => "AGENTS.md",
_ => return Err(ApiError::not_found("profile must be USER.md or AGENTS.md")),
};
Ok(crate::config::get_user_config_dir().join(file))
}
pub async fn get_profile(Path(name): Path<String>) -> Result<Json<ProfileResponse>, ApiError> {
let path = profile_path(&name)?;
let content = tokio::fs::read_to_string(&path)
.await
.map_err(ApiError::internal)?;
Ok(Json(ProfileResponse {
name,
content,
path: path.display().to_string(),
}))
}
pub async fn put_profile(
Path(name): Path<String>,
Json(update): Json<ProfileUpdate>,
) -> Result<Json<ProfileResponse>, ApiError> {
if update.content.len() > MAX_PROFILE_BYTES {
return Err(ApiError::bad_request("profile exceeds 256 KiB"));
}
let path = profile_path(&name)?;
atomic_write(&path, update.content.as_bytes()).await?;
tracing::info!(profile = %name, path = %path.display(), "Assistant profile updated from WebUI");
Ok(Json(ProfileResponse {
name,
content: update.content,
path: path.display().to_string(),
}))
}
#[derive(Default, Deserialize)]
pub struct LogsQuery {
lines: Option<usize>,
search: Option<String>,
}
#[derive(Serialize)]
pub struct LogsResponse {
lines: Vec<String>,
files: Vec<String>,
}
pub async fn get_logs(Query(query): Query<LogsQuery>) -> Result<Json<LogsResponse>, ApiError> {
let limit = query.lines.unwrap_or(500).clamp(1, 5000);
let search = query.search.filter(|value| !value.is_empty());
let log_dir = crate::logging::get_default_log_dir();
let mut entries = tokio::fs::read_dir(&log_dir)
.await
.map_err(ApiError::internal)?;
let mut paths = Vec::new();
while let Some(entry) = entries.next_entry().await.map_err(ApiError::internal)? {
let path = entry.path();
if path.is_file()
&& path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| name.starts_with("picobot.log"))
{
paths.push(path);
}
}
paths.sort();
paths = paths.into_iter().rev().take(7).collect();
paths.sort();
let files = paths
.iter()
.filter_map(|path| path.file_name()?.to_str().map(str::to_string))
.collect();
let mut output = VecDeque::with_capacity(limit);
for path in paths {
let content = read_file_tail(&path, 2 * 1024 * 1024).await?;
for line in content.lines() {
if search.as_ref().is_some_and(|needle| {
!line
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
}) {
continue;
}
if output.len() == limit {
output.pop_front();
}
output.push_back(line.to_string());
}
}
Ok(Json(LogsResponse {
lines: output.into(),
files,
}))
}
async fn read_file_tail(path: &FsPath, max_bytes: u64) -> Result<String, ApiError> {
let mut file = tokio::fs::File::open(path)
.await
.map_err(ApiError::internal)?;
let len = file.metadata().await.map_err(ApiError::internal)?.len();
if len > max_bytes {
file.seek(std::io::SeekFrom::Start(len - max_bytes))
.await
.map_err(ApiError::internal)?;
}
let mut bytes = Vec::with_capacity(len.min(max_bytes) as usize);
file.read_to_end(&mut bytes)
.await
.map_err(ApiError::internal)?;
let text = String::from_utf8_lossy(&bytes).into_owned();
Ok(if len > max_bytes {
text.find('\n')
.map_or(text.clone(), |newline| text[newline + 1..].to_string())
} else {
text
})
}
#[derive(Default, Deserialize)]
pub struct LimitQuery {
limit: Option<usize>,
}
#[derive(serde::Deserialize)]
pub struct AgentRunsQuery {
session_id: String,
cursor: Option<String>,
limit: Option<usize>,
}
fn scheduler_snapshot(jobs: &[crate::storage::ScheduledJob]) -> Value {
let enabled = jobs.iter().filter(|job| job.enabled).count();
let failed_jobs = jobs
.iter()
.filter(|job| {
matches!(
job.last_status.as_deref(),
Some("error" | "timeout" | "delivery_error")
)
})
.count();
let next_run_at = jobs
.iter()
.filter(|job| job.enabled)
.map(|job| job.next_run_at)
.min();
json!({
"jobs": jobs.len(),
"enabled": enabled,
"failed_jobs": failed_jobs,
"next_run_at": next_run_at,
})
}
fn scheduler_status_error(error: impl std::fmt::Display) -> ApiError {
ApiError::internal_with_message(error, "failed to load scheduler status")
}
fn channel_snapshot(mut channels: Vec<(String, bool)>) -> Vec<Value> {
channels.sort_by(|left, right| left.0.cmp(&right.0));
channels
.into_iter()
.map(|(name, running)| {
json!({
"name": name,
"status": if running { "connected" } else { "stopped" },
})
})
.collect()
}
fn sorted_providers(
mut providers: Vec<crate::observability::metrics::ProviderSnapshot>,
) -> Vec<crate::observability::metrics::ProviderSnapshot> {
providers.sort_by(|left, right| left.name.cmp(&right.name));
providers
}
pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let reload = state.reload.status();
let metrics = crate::observability::metrics::global_metrics().snapshot();
let depths = state.bus().queue_depths();
let active_lanes = state
.outbound_lanes
.load(std::sync::atomic::Ordering::Relaxed);
let ws_connections = state
.ws_connections
.load(std::sync::atomic::Ordering::Relaxed);
let background_tasks = state.task_supervisor.running_count();
let sessions_total = state.session_manager.session_count().await;
let active_turns = state.session_manager.active_turn_count().await;
let mut channel_states = Vec::new();
for name in state.channel_manager.list_channel_names().await {
let running = state
.channel_manager
.get_channel(&name)
.await
.is_some_and(|channel| channel.is_running());
channel_states.push((name, running));
}
let channels = channel_snapshot(channel_states);
let jobs = state
.storage
.list_scheduled_jobs()
.await
.map_err(scheduler_status_error)?;
let scheduler = scheduler_snapshot(&jobs);
let mcp = crate::mcp::get_mcp_status()
.into_iter()
.map(|status| {
json!({
"name": status.name,
"connected": status.connected,
"tools": status.tools.len(),
})
})
.collect::<Vec<_>>();
Ok(Json(json!({
"generation": reload.generation,
"version": env!("CARGO_PKG_VERSION"),
"uptime_secs": crate::gateway::process_uptime_secs(),
"phase": reload.phase,
"ws_connections": ws_connections,
"background_tasks": background_tasks,
"sessions": {
"total": sessions_total,
"active_turns": active_turns,
},
"metrics": {
"tokens_in": metrics.tokens_in,
"tokens_out": metrics.tokens_out,
"cost": metrics.cost,
"tool_calls": metrics.tool_calls,
"turns": metrics.turns,
"turn_latency_p95_ms": metrics.turn_latency_p95_ms,
},
"bus": {
"inbound": { "depth": depths.inbound_depth, "cap": depths.inbound_cap },
"outbound": { "depth": depths.outbound_depth, "cap": depths.outbound_cap },
"control": { "depth": depths.control_depth, "cap": depths.control_cap },
"active_lanes": active_lanes,
},
"providers": sorted_providers(metrics.providers),
"channels": channels,
"scheduler": scheduler,
"mcp": mcp,
})))
}
pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let metrics = crate::observability::metrics::global_metrics();
let registry = state.session_manager.tools();
let mut entries = registry.iter();
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
let tools: Vec<Value> = entries
.into_iter()
.map(|(name, tool)| {
let source = if name.contains("__") {
"mcp"
} else {
"builtin"
};
json!({
"name": name,
"description": tool.description(),
"parameters_schema": tool.parameters_schema(),
"source": source,
"read_only": tool.read_only(),
"exclusive": tool.exclusive(),
"concurrency_safe": tool.concurrency_safe(),
"call_count": metrics.tool_call_count(&name),
})
})
.collect();
Ok(Json(json!({ "tools": tools })))
}
/// List Agent definition files (enabled and disabled) from the resolved
/// definitions directory.
pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let mut agents = Vec::new();
let entries = match std::fs::read_dir(&state.agents_dir) {
Ok(entries) => entries,
Err(_error) => {
return Ok(Json(json!({ "agents": [] })));
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
match crate::agent::definition::parse_definition_info(&path) {
Ok(info) => {
let fm = &info.frontmatter;
agents.push(json!({
"id": fm.id,
"description": fm.description,
"enabled": fm.enabled,
"llm_profile": fm.llm_profile,
"provider": fm.provider,
"model": fm.model,
"token_limit": fm.token_limit,
"max_tool_iterations": fm.max_tool_iterations,
"tools": fm.tools,
"delegates": fm.delegates,
"skills": fm.skills,
"limits": fm.limits,
"signal": fm.signal,
"role_prompt": info.role_prompt,
}));
}
Err(error) => {
tracing::warn!(path = %path.display(), error = %error, "Failed to parse Agent definition");
}
}
}
agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
Ok(Json(json!({ "agents": agents })))
}
/// Create or update a definition file under the definitions directory.
pub async fn put_agent(
State(state): State<Arc<GatewayState>>,
Json(body): Json<Value>,
) -> Result<Json<Value>, ApiError> {
let info = agent_info_from_json(&body)?;
let fm = &info.frontmatter;
// Validate referenced provider/model/tools/skills so a broken file is
// rejected at the API boundary instead of breaking the next reload.
if let Some(provider) = fm.provider.as_deref()
&& !state.config.providers.contains_key(provider)
{
return Err(ApiError::bad_request(format!(
"unknown provider '{provider}'"
)));
}
if let Some(model) = fm.model.as_deref()
&& !state.config.models.contains_key(model)
{
return Err(ApiError::bad_request(format!("unknown model '{model}'")));
}
let registry = state.session_manager.tools();
for tool in &fm.tools {
let Some(registered) = registry.get(tool) else {
return Err(ApiError::bad_request(format!("unknown tool '{tool}'")));
};
if registered.runtime_injected() && tool != "get_skill" {
return Err(ApiError::bad_request(format!(
"tool '{tool}' is runtime-injected and cannot be declared"
)));
}
}
let loaded_skills: std::collections::HashSet<String> = state
.session_manager
.skills_loader()
.list_skills()
.into_iter()
.map(|(name, _)| name)
.collect();
for skill in &fm.skills {
if !loaded_skills.contains(skill) {
return Err(ApiError::bad_request(format!("unknown skill '{skill}'")));
}
}
if !fm.skills.is_empty() && !fm.tools.iter().any(|t| t == "get_skill") {
return Err(ApiError::bad_request(
"skills require the get_skill tool in the definition",
));
}
let content = crate::agent::definition::serialize_definition(&info);
tokio::fs::create_dir_all(&state.agents_dir)
.await
.map_err(ApiError::internal)?;
let path = state.agents_dir.join(format!("{}.md", fm.id));
tokio::fs::write(&path, content)
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "id": fm.id, "saved": true })))
}
/// Delete a definition file.
pub async fn delete_agent(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
crate::agent::definition::validate_agent_id(&id)
.map_err(|error| ApiError::bad_request(error.to_string()))?;
let path = state.agents_dir.join(format!("{id}.md"));
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(Json(json!({ "deleted": id }))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err(ApiError::not_found(format!("agent {id} not found")))
}
Err(error) => Err(ApiError::internal(error)),
}
}
/// Available providers/models/tools/skills for the editor UI.
pub async fn get_agent_options(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<Value>, ApiError> {
let providers: Vec<String> = state.config.providers.keys().cloned().collect();
let models: Vec<Value> = state
.config
.models
.iter()
.map(|(name, model)| json!({ "name": name, "model_id": model.model_id }))
.collect();
let registry = state.session_manager.tools();
let mut tools: Vec<Value> = registry
.iter()
.into_iter()
// get_skill is the one runtime-injected tool that may be declared in
// a definition's `tools` list (it turns on the scoped skill wrapper),
// so it must be offered in the editor.
.filter(|(name, tool)| {
(!tool.runtime_injected() || name == "get_skill") && !name.contains("__")
})
.map(|(name, tool)| json!({ "name": name, "description": tool.description() }))
.collect();
tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let skills: Vec<String> = state
.session_manager
.skills_loader()
.list_skills()
.into_iter()
.map(|(name, _)| name)
.collect();
Ok(Json(json!({
"providers": providers,
"models": models,
"tools": tools,
"skills": skills,
})))
}
fn agent_info_from_json(
body: &Value,
) -> Result<crate::agent::definition::AgentDefinitionInfo, ApiError> {
use crate::agent::definition::{AgentDefinitionInfo, AgentFrontmatter, AgentLimits};
let id = body
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| ApiError::bad_request("missing required field: id"))?
.to_string();
crate::agent::definition::validate_agent_id(&id)
.map_err(|error| ApiError::bad_request(error.to_string()))?;
let description = body
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let role_prompt = body
.get("role_prompt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if role_prompt.trim().is_empty() {
return Err(ApiError::bad_request("role_prompt must not be empty"));
}
let provider = body
.get("provider")
.and_then(Value::as_str)
.map(str::to_string);
let model = body
.get("model")
.and_then(Value::as_str)
.map(str::to_string);
let llm_profile = body
.get("llm_profile")
.and_then(Value::as_str)
.map(str::to_string);
if provider.is_none() && model.is_none() && llm_profile.as_deref().is_none_or(str::is_empty) {
return Err(ApiError::bad_request(
"either provider+model or llm_profile is required",
));
}
if provider.is_some() != model.is_some() {
return Err(ApiError::bad_request(
"provider and model must be set together",
));
}
let info = AgentDefinitionInfo {
frontmatter: AgentFrontmatter {
id,
description,
llm_profile: llm_profile.filter(|v| !v.is_empty()),
provider,
model,
token_limit: body
.get("token_limit")
.and_then(Value::as_u64)
.map(|v| v as usize),
max_tool_iterations: body
.get("max_tool_iterations")
.and_then(Value::as_u64)
.map(|v| v as usize),
enabled: body.get("enabled").and_then(Value::as_bool).unwrap_or(true),
tools: string_array(body, "tools"),
delegates: string_array(body, "delegates"),
skills: string_array(body, "skills"),
limits: body
.get("limits")
.and_then(|v| serde_json::from_value::<AgentLimits>(v.clone()).ok())
.unwrap_or_default(),
signal: body
.get("signal")
.and_then(|v| serde_json::from_value(v.clone()).ok()),
},
role_prompt,
};
Ok(info)
}
fn string_array(body: &Value, key: &str) -> Vec<String> {
body.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let loader = state.session_manager.skills_loader();
let skills: Vec<Value> = loader
.get_loaded_skills()
.iter()
.map(|s| {
json!({
"name": s.name,
"description": s.description,
"always": s.always,
"source": loader.source_of(s.path.as_deref()),
})
})
.collect();
Ok(Json(json!({ "skills": skills })))
}
pub async fn get_tasks(
State(state): State<Arc<GatewayState>>,
Query(query): Query<LimitQuery>,
) -> Result<Json<Value>, ApiError> {
let limit = query.limit.unwrap_or(100).clamp(1, 500);
let runs = state
.storage
.list_all_agent_runs(None, limit as i64)
.await
.map_err(ApiError::internal)?;
let tasks: Vec<Value> = runs
.into_iter()
.map(|run| {
json!({
"source": "agent_run",
"id": run.id,
"parent_run_id": run.parent_run_id,
"session_id": run.root_session_id,
"agent_id": run.agent_id,
"mode": run.mode.as_str(),
"depth": run.depth,
"prompt": run.task,
"status": run.status.as_str(),
"result": run.result,
"error": run.error,
"tool_calls_count": run.tool_calls_count,
"iterations": run.iterations,
"started_at": run.started_at,
"finished_at": run.finished_at,
"created_at": run.created_at,
})
})
.collect();
Ok(Json(json!({ "tasks": tasks })))
}
/// Session-scoped durable run listing with `(created_at,id)` cursor paging.
pub async fn get_agent_runs(
State(state): State<Arc<GatewayState>>,
Query(query): Query<AgentRunsQuery>,
) -> Result<Json<Value>, ApiError> {
let Some(coordinator) = state.session_manager.agent_coordinator() else {
return Ok(Json(json!({
"revision": 0,
"runs": [],
"next_cursor": Value::Null,
})));
};
let cursor = query.cursor.as_deref().and_then(|cursor| {
let (created_at, id) = cursor.split_once(':')?;
Some((created_at.parse::<i64>().ok()?, id.to_string()))
});
let limit = query.limit.unwrap_or(100).clamp(1, 200) as i64;
let (revision, runs, next_cursor) = coordinator
.list_runs_for_session(&query.session_id, cursor, limit)
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({
"revision": revision,
"runs": runs,
"next_cursor": next_cursor,
})))
}
pub async fn get_agent_run(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let Some(_coordinator) = state.session_manager.agent_coordinator() else {
return Err(ApiError::not_found("run not found".to_string()));
};
let run = state
.storage
.get_agent_run(&id)
.await
.map_err(ApiError::internal)?;
let Some(run) = run else {
return Err(ApiError::not_found(format!("run {id} not found")));
};
Ok(Json(
json!({ "run": crate::protocol::AgentRunView::from_record(&run, 100_000) }),
))
}
pub async fn get_agent_run_events(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
Query(query): Query<LimitQuery>,
) -> Result<Json<Value>, ApiError> {
let Some(coordinator) = state.session_manager.agent_coordinator() else {
return Ok(Json(json!({ "events": [] })));
};
let limit = query.limit.unwrap_or(100).clamp(1, 200) as i64;
let events = coordinator
.list_run_events(&id, limit)
.await
.map_err(ApiError::internal)?
.iter()
.map(crate::protocol::AgentEventView::from_record)
.collect::<Vec<_>>();
Ok(Json(json!({ "events": events })))
}
pub async fn cancel_agent_run(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let Some(coordinator) = state.session_manager.agent_coordinator() else {
return Err(ApiError::not_found("run not found".to_string()));
};
let Some(run) = state
.storage
.get_agent_run(&id)
.await
.map_err(ApiError::internal)?
else {
return Err(ApiError::not_found(format!("run {id} not found")));
};
let cancelled = coordinator
.cancel_run_for_session(&run.root_session_id, &id, "cancelled from management UI")
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "cancelled": cancelled, "run_id": id })))
}
pub async fn get_jobs(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let jobs = state
.storage
.list_scheduled_jobs()
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "jobs": jobs })))
}
pub async fn get_job_runs(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
Query(query): Query<LimitQuery>,
) -> Result<Json<Value>, ApiError> {
state
.storage
.get_scheduled_job(&id)
.await
.map_err(|error| ApiError::not_found(error.to_string()))?;
let limit = query.limit.unwrap_or(50).clamp(1, 500);
let runs = state
.storage
.list_scheduled_job_runs(&id, limit)
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "runs": runs })))
}
#[derive(Default, Deserialize)]
pub struct MemoriesQuery {
query: Option<String>,
category: Option<String>,
session_id: Option<String>,
limit: Option<usize>,
}
pub async fn get_memories(
State(state): State<Arc<GatewayState>>,
Query(query): Query<MemoriesQuery>,
) -> Result<Json<Value>, ApiError> {
let category = query
.category
.as_deref()
.filter(|value| !value.is_empty())
.map(|value| {
MemoryCategory::parse(value)
.ok_or_else(|| ApiError::bad_request("category must be knowledge or timeline"))
})
.transpose()?;
let limit = query.limit.unwrap_or(100).clamp(1, 500);
let session_id = query
.session_id
.as_deref()
.filter(|value| !value.is_empty());
let memories = if let Some(search) = query
.query
.as_deref()
.filter(|value| !value.trim().is_empty())
{
state
.storage
.search_memories(search, category.as_ref(), session_id, limit)
.await
} else {
state
.storage
.list_memories(category.as_ref(), session_id, limit)
.await
}
.map_err(ApiError::internal)?;
Ok(Json(json!({ "memories": memories })))
}
#[derive(Deserialize)]
pub struct PutMemoryBody {
content: String,
importance: Option<f64>,
}
pub async fn put_memory(
State(state): State<Arc<GatewayState>>,
Path(key): Path<String>,
Json(body): Json<PutMemoryBody>,
) -> Result<Json<Value>, ApiError> {
let existing = state
.storage
.get_memory_by_key(&key)
.await
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("memory not found"))?;
let mut updated = existing;
updated.content = body.content;
if let Some(importance) = body.importance {
updated.importance = importance.clamp(0.0, 1.0);
}
updated.updated_at = chrono::Utc::now().to_rfc3339();
state
.storage
.upsert_memory(&updated)
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "updated": true, "key": key })))
}
pub async fn delete_memory(
State(state): State<Arc<GatewayState>>,
Path(key): Path<String>,
) -> Result<Json<Value>, ApiError> {
state
.storage
.delete_memory(&key)
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "deleted": true })))
}
#[cfg(test)]
mod tests {
use super::*;
fn scheduled_job(
id: &str,
enabled: bool,
next_run_at: i64,
last_status: Option<&str>,
) -> crate::storage::ScheduledJob {
crate::storage::ScheduledJob {
id: id.to_string(),
name: id.to_string(),
schedule: crate::scheduler::Schedule::Every { every_ms: 60_000 },
prompt: String::new(),
channel: "cli_chat".to_string(),
chat_id: "test".to_string(),
model: None,
job_kind: crate::storage::JobKind::Task,
delivery_policy: crate::storage::DeliveryPolicy::Never,
enabled,
delete_after_run: false,
next_run_at,
last_run_at: None,
last_status: last_status.map(str::to_string),
last_error: None,
created_at: 0,
updated_at: 0,
}
}
#[test]
fn scheduler_snapshot_classifies_failures_and_next_enabled_run() {
let jobs = vec![
scheduled_job("healthy", true, 300, Some("ok")),
scheduled_job("error", true, 200, Some("error")),
scheduled_job("timeout", false, 100, Some("timeout")),
scheduled_job("delivery", true, 400, Some("delivery_error")),
scheduled_job("other", false, 50, Some("cancelled")),
];
assert_eq!(
scheduler_snapshot(&jobs),
json!({
"jobs": 5,
"enabled": 3,
"failed_jobs": 3,
"next_run_at": 200,
})
);
}
#[test]
fn scheduler_snapshot_has_null_next_run_without_enabled_jobs() {
let jobs = vec![scheduled_job("disabled", false, 100, None)];
assert_eq!(
scheduler_snapshot(&jobs),
json!({
"jobs": 1,
"enabled": 0,
"failed_jobs": 0,
"next_run_at": null,
})
);
}
#[test]
fn channel_snapshot_is_sorted_and_maps_running_state() {
let channels = channel_snapshot(vec![
("zeta".to_string(), false),
("alpha".to_string(), true),
]);
assert_eq!(
channels,
vec![
json!({ "name": "alpha", "status": "connected" }),
json!({ "name": "zeta", "status": "stopped" }),
]
);
}
#[test]
fn provider_snapshot_is_sorted_by_name() {
let provider = |name: &str| crate::observability::metrics::ProviderSnapshot {
name: name.to_string(),
model: String::new(),
status: "ok".to_string(),
latency_ms: 0,
latencies: Vec::new(),
tokens_in: 0,
tokens_out: 0,
cost: 0.0,
};
let providers = sorted_providers(vec![provider("zeta"), provider("alpha")]);
assert_eq!(
providers
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
vec!["alpha", "zeta"]
);
}
#[test]
fn scheduler_status_error_hides_internal_details() {
let error = scheduler_status_error("database contained private payload");
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.message, "failed to load scheduler status");
assert!(!error.message.contains("private payload"));
}
#[test]
fn secrets_are_redacted_and_restored() {
let current = json!({"api_key":"real", "nested":{"access_token":"token"}, "safe":"yes"});
let mut shown = current.clone();
redact_secrets(&mut shown);
assert_eq!(shown["api_key"], REDACTED);
assert_eq!(shown["nested"]["access_token"], REDACTED);
restore_redacted_secrets(&mut shown, &current);
assert_eq!(shown, current);
}
#[test]
fn profile_names_are_allowlisted() {
assert!(profile_path("USER.md").unwrap().ends_with("USER.md"));
assert!(profile_path("../config.json").is_err());
}
#[tokio::test]
async fn embedded_webui_has_security_headers() {
let response = webui_index().await;
assert_eq!(
response.headers().get("X-Content-Type-Options").unwrap(),
"nosniff"
);
assert!(response.headers().contains_key("Content-Security-Policy"));
}
#[test]
fn attachment_headers_are_safely_encoded_and_inline_is_allowlisted() {
assert_eq!(
encode_header_filename("报告 1.pdf"),
"%E6%8A%A5%E5%91%8A%201.pdf"
);
assert!(inline_mime_allowed("image/png"));
assert!(inline_mime_allowed("image/bmp"));
assert!(!inline_mime_allowed("image/svg+xml"));
assert!(!inline_mime_allowed("text/html"));
}
}