1use axum::extract::State;
4use axum::http::{HeaderValue, StatusCode};
5use axum::response::sse::{Event, Sse};
6use axum::response::{IntoResponse, Response};
7use axum::routing::get;
8use axum::{Json, Router};
9use futures_util::StreamExt;
10use futures_util::stream;
11use serde_json::{Value, json};
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::Duration;
15
16pub const FREEZE_VERSION: &str = "1.18.31";
18
19pub fn deep_merge(base: &mut serde_json::Value, patch: serde_json::Value) {
24 match (base, patch) {
25 (serde_json::Value::Object(b), serde_json::Value::Object(p)) => {
26 for (k, v) in p {
27 match b.get_mut(&k) {
28 Some(bv) => deep_merge(bv, v),
29 None => {
30 b.insert(k, v);
31 }
32 }
33 }
34 }
35 (b, p) => *b = p,
36 }
37}
38
39pub fn config_write_path() -> std::path::PathBuf {
44 let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
45 if std::env::var("OCSERVE_CONFIG_WRITE")
46 .map(|v| v == "overlay")
47 .unwrap_or(false)
48 {
49 std::path::PathBuf::from(home).join(".config/ocserve/config.json")
50 } else {
51 std::path::PathBuf::from(home).join(".config/opencode/opencode.json")
52 }
53}
54
55pub type ConfigReloader =
60 std::sync::Arc<dyn Fn() -> anyhow::Result<(Payloads, LlmRegistry)> + Send + Sync>;
61
62#[derive(Default)]
63pub struct Payloads {
64 pub config: serde_json::Value,
65 pub agent: Vec<serde_json::Value>,
66 pub api_agent: Vec<serde_json::Value>,
67 pub command: Vec<serde_json::Value>,
68 pub config_providers: serde_json::Value,
69 pub provider: serde_json::Value,
70 pub console: serde_json::Value,
71 pub capabilities: serde_json::Value,
72 pub compaction: serde_json::Value,
74}
75
76async fn run_blocking<T, F>(db: &std::path::Path, op: F) -> anyhow::Result<T>
83where
84 F: FnOnce(&std::path::Path) -> anyhow::Result<T> + Send + 'static,
85 T: Send + 'static,
86{
87 let db = db.to_path_buf();
88 tokio::task::spawn_blocking(move || op(&db))
89 .await
90 .map_err(|e| anyhow::anyhow!("blocking task join: {e}"))?
91}
92
93pub mod tui;
94pub mod ui;
95
96pub struct ApiError {
98 pub status: StatusCode,
99 pub name: &'static str,
100 pub message: String,
101}
102
103impl ApiError {
104 pub fn not_found(msg: impl Into<String>) -> Self {
105 Self {
106 status: StatusCode::NOT_FOUND,
107 name: "NotFoundError",
108 message: msg.into(),
109 }
110 }
111}
112
113impl IntoResponse for ApiError {
114 fn into_response(self) -> Response {
115 let body = json!({"name": self.name, "data": {"message": self.message}});
116 (self.status, Json(body)).into_response()
117 }
118}
119
120pub mod sync;
123pub mod watch;
124
125pub enum HttpError {
126 Api(ApiError),
127 Tagged {
128 status: StatusCode,
129 tag: &'static str,
130 },
131 Query {
134 message: String,
135 },
136 TaggedData {
139 status: StatusCode,
140 tag: &'static str,
141 fields: serde_json::Value,
142 },
143}
144
145impl From<ApiError> for HttpError {
146 fn from(e: ApiError) -> Self {
147 HttpError::Api(e)
148 }
149}
150
151impl IntoResponse for HttpError {
152 fn into_response(self) -> Response {
153 match self {
154 HttpError::Api(e) => e.into_response(),
155 HttpError::Tagged { status, tag } => {
156 let body = format!("{{\"_tag\":\"{tag}\"}}");
157 (
158 status,
159 [(axum::http::header::CONTENT_TYPE, "application/json")],
160 body,
161 )
162 .into_response()
163 }
164 HttpError::TaggedData {
165 status,
166 tag,
167 fields,
168 } => {
169 let mut obj = serde_json::Map::new();
170 obj.insert("_tag".into(), serde_json::Value::String(tag.into()));
171 if let serde_json::Value::Object(f) = fields {
172 obj.extend(f);
173 }
174 (
175 status,
176 [(axum::http::header::CONTENT_TYPE, "application/json")],
177 serde_json::Value::Object(obj).to_string(),
178 )
179 .into_response()
180 }
181 HttpError::Query { message } => {
182 let body = serde_json::json!({
183 "name": "BadRequest",
184 "data": {"message": message, "kind": "Query"}
185 })
186 .to_string();
187 (
188 StatusCode::BAD_REQUEST,
189 [(axum::http::header::CONTENT_TYPE, "application/json")],
190 body,
191 )
192 .into_response()
193 }
194 }
195 }
196}
197
198pub type PromptTask = (u64, tokio::task::JoinHandle<()>);
200
201pub struct AppState {
203 pub payloads: parking_lot::RwLock<Payloads>,
206 pub wire: parking_lot::RwLock<HashMap<&'static str, bytes::Bytes>>,
213 pub wire_off: std::sync::atomic::AtomicBool,
214 pub reloader: parking_lot::RwLock<Option<ConfigReloader>>,
218 pub sessions: parking_lot::RwLock<HashMap<String, serde_json::Value>>,
220 pub paths: serde_json::Value,
222 pub auth: Option<(String, String)>,
224 pub requests: std::sync::atomic::AtomicU64,
226 pub bus: ocserve_core::EventBus,
229 pub db: std::path::PathBuf,
230 pub blobs: std::sync::Arc<ocserve_store::BlobStore>,
231 pub writer: std::sync::Arc<ocserve_store::Writer>,
232 pub llm: parking_lot::RwLock<LlmRegistry>,
236 pub watch: parking_lot::RwLock<watch::WatchState>,
238 pub reconcile_lock: tokio::sync::Mutex<()>,
241 pub max_rounds: usize,
244 pub cost_ceiling: f64,
245 pub gate: std::sync::Arc<ocserve_core::PermissionGate>,
247 pub mcp: std::sync::OnceLock<std::sync::Arc<ocserve_mcp::McpHub>>,
249 pub plugins: std::sync::OnceLock<std::sync::Arc<tokio::sync::Mutex<ocserve_plugin::Sidecar>>>,
251 pub prompt_locks: std::sync::Arc<
255 parking_lot::Mutex<
256 std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
257 >,
258 >,
259 pub prompt_tasks:
263 std::sync::Arc<parking_lot::Mutex<std::collections::HashMap<String, PromptTask>>>,
264 pub prompt_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
266 pub question_gate: std::sync::Arc<ocserve_core::question::QuestionGate>,
268 pub tui: crate::tui::TuiControl,
270 pub ui: crate::ui::UiProxy,
272 pub cors_extra: Vec<String>,
274}
275
276#[derive(Default)]
278pub struct LlmRegistry {
279 pub endpoints: std::collections::HashMap<String, (String, String)>,
281 pub pricing: std::collections::HashMap<(String, String), (f64, f64, f64)>,
283 pub limits: std::collections::HashMap<(String, String), serde_json::Value>,
285 pub default_model: (String, String),
287 pub systems: std::collections::HashMap<String, String>,
289 pub default_agent: String,
291}
292
293pub struct Wires {
295 pub db: std::path::PathBuf,
296 pub blobs: std::sync::Arc<ocserve_store::BlobStore>,
297 pub writer: std::sync::Arc<ocserve_store::Writer>,
298 pub llm: LlmRegistry,
299}
300
301pub(crate) fn rebuild_wire(p: &Payloads, off: bool) -> HashMap<&'static str, bytes::Bytes> {
304 let mut m = HashMap::new();
305 if off {
306 return m;
307 }
308 fn one<T: serde::Serialize>(
310 m: &mut HashMap<&'static str, bytes::Bytes>,
311 k: &'static str,
312 v: &T,
313 ) {
314 if let Ok(b) = serde_json::to_vec(v) {
315 m.insert(k, bytes::Bytes::from(b));
316 }
317 }
318 one(&mut m, "config", &p.config);
319 one(&mut m, "agent", &p.agent);
320 one(&mut m, "command", &p.command);
321 one(&mut m, "config_providers", &p.config_providers);
322 one(&mut m, "provider", &p.provider);
323 one(&mut m, "console", &p.console);
324 one(&mut m, "capabilities", &p.capabilities);
325 m
326}
327
328fn wire_json(st: &AppState, key: &'static str) -> Option<axum::response::Response> {
330 if st.wire_off.load(std::sync::atomic::Ordering::Relaxed) {
331 return None;
332 }
333 let b = st.wire.read().get(key).cloned()?;
334 Some(
335 axum::response::Response::builder()
336 .header(axum::http::header::CONTENT_TYPE, "application/json")
337 .body(axum::body::Body::from(b))
338 .expect("static response"),
339 )
340}
341
342impl AppState {
343 pub fn new() -> Arc<Self> {
344 Self::with_auth(None)
345 }
346
347 pub fn with_auth(auth: Option<(String, String)>) -> Arc<Self> {
348 Self::with_payloads(auth, Payloads::default())
349 }
350
351 pub fn with_payloads(auth: Option<(String, String)>, p: Payloads) -> Arc<Self> {
354 let dir = std::env::temp_dir().join(format!(
355 "ocserve-http-{}-{}",
356 std::process::id(),
357 std::time::SystemTime::now()
358 .duration_since(std::time::UNIX_EPOCH)
359 .map(|d| d.as_nanos())
360 .unwrap_or(0)
361 ));
362 std::fs::create_dir_all(&dir).expect("temp data dir");
363 let db = ocserve_store::writer::db_path(&dir);
364 let writer =
365 std::sync::Arc::new(ocserve_store::Writer::spawn(db.clone()).expect("temp writer"));
366 let blobs = std::sync::Arc::new(
367 ocserve_store::BlobStore::new(dir.join("blobs")).expect("temp blobs"),
368 );
369 Self::with_wiring(
370 auth,
371 p,
372 Wires {
373 db,
374 blobs,
375 writer,
376 llm: LlmRegistry::default(),
377 },
378 )
379 }
380
381 pub fn with_wiring(auth: Option<(String, String)>, p: Payloads, w: Wires) -> Arc<Self> {
383 let home = std::env::var("HOME").unwrap_or_default();
384 let worktree = std::env::current_dir()
385 .map(|p| p.display().to_string())
386 .unwrap_or_else(|_| "/".into());
387 let wire_off = std::env::var("OCSERVE_WIRE_CACHE")
388 .map(|v| v == "0")
389 .unwrap_or(false);
390 let wire_map = rebuild_wire(&p, wire_off);
391 Arc::new(Self {
392 payloads: parking_lot::RwLock::new(p),
393 wire: parking_lot::RwLock::new(wire_map),
394 wire_off: std::sync::atomic::AtomicBool::new(wire_off),
395 reloader: parking_lot::RwLock::new(None),
396 sessions: parking_lot::RwLock::new(HashMap::new()),
397 paths: json!({
399 "home": home,
400 "state": format!("{home}/.local/state/opencode"),
401 "config": format!("{home}/.config/opencode"),
402 "worktree": worktree,
403 "directory": worktree,
404 }),
405 auth,
406 requests: std::sync::atomic::AtomicU64::new(0),
407 bus: ocserve_core::EventBus::new(),
408 gate: ocserve_core::PermissionGate::new(),
409 mcp: std::sync::OnceLock::new(),
410 plugins: std::sync::OnceLock::new(),
411 prompt_locks: std::sync::Arc::new(parking_lot::Mutex::new(
412 std::collections::HashMap::new(),
413 )),
414 prompt_tasks: std::sync::Arc::new(parking_lot::Mutex::new(
415 std::collections::HashMap::new(),
416 )),
417 prompt_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)),
418 question_gate: ocserve_core::question::QuestionGate::new(),
419 tui: crate::tui::TuiControl::default(),
420 ui: crate::ui::UiProxy::from_env(),
421 cors_extra: read_cors_env(),
422 db: w.db,
423 blobs: w.blobs,
424 writer: w.writer,
425 llm: parking_lot::RwLock::new(w.llm),
426 watch: parking_lot::RwLock::new(watch::WatchState::default()),
427 reconcile_lock: tokio::sync::Mutex::new(()),
428 max_rounds: std::env::var("OCSERVE_PROMPT_MAX_ROUNDS")
429 .ok()
430 .and_then(|v| v.parse().ok())
431 .unwrap_or(0),
432 cost_ceiling: std::env::var("OCSERVE_PROMPT_MAX_COST_USD")
433 .ok()
434 .and_then(|v| v.parse().ok())
435 .unwrap_or(0.0),
436 })
437 }
438}
439
440async fn health() -> impl IntoResponse {
441 Json(json!({"healthy": true, "version": FREEZE_VERSION}))
442}
443
444async fn get_config(State(st): State<Arc<AppState>>) -> axum::response::Response {
445 wire_json(&st, "config")
446 .unwrap_or_else(|| Json(st.payloads.read().config.clone()).into_response())
447}
448
449async fn get_agent(State(st): State<Arc<AppState>>) -> axum::response::Response {
450 wire_json(&st, "agent")
451 .unwrap_or_else(|| Json(st.payloads.read().agent.clone()).into_response())
452}
453
454async fn get_command(State(st): State<Arc<AppState>>) -> axum::response::Response {
455 wire_json(&st, "command")
456 .unwrap_or_else(|| Json(st.payloads.read().command.clone()).into_response())
457}
458
459async fn get_config_providers(State(st): State<Arc<AppState>>) -> axum::response::Response {
460 wire_json(&st, "config_providers")
461 .unwrap_or_else(|| Json(st.payloads.read().config_providers.clone()).into_response())
462}
463
464async fn get_provider(State(st): State<Arc<AppState>>) -> axum::response::Response {
465 wire_json(&st, "provider")
466 .unwrap_or_else(|| Json(st.payloads.read().provider.clone()).into_response())
467}
468
469async fn get_console(State(st): State<Arc<AppState>>) -> axum::response::Response {
470 wire_json(&st, "console")
471 .unwrap_or_else(|| Json(st.payloads.read().console.clone()).into_response())
472}
473
474async fn get_capabilities(State(st): State<Arc<AppState>>) -> axum::response::Response {
475 wire_json(&st, "capabilities")
476 .unwrap_or_else(|| Json(st.payloads.read().capabilities.clone()).into_response())
477}
478
479fn api_location(st: &AppState) -> Value {
481 let dir = st.paths["directory"].as_str().unwrap_or("/");
482 json!({
483 "directory": dir,
484 "project": {"id": "global", "directory": "/"},
485 })
486}
487
488async fn get_api_location(State(st): State<Arc<AppState>>) -> impl IntoResponse {
490 Json(api_location(&st))
491}
492
493async fn get_api_agent(State(st): State<Arc<AppState>>) -> impl IntoResponse {
496 let payloads = st.payloads.read();
497 let data: Vec<Value> = payloads
498 .api_agent
499 .iter()
500 .map(|a| {
501 let perms: Vec<Value> = a["permission"]
502 .as_array()
503 .map(|rules| {
504 rules
505 .iter()
506 .map(|r| {
507 json!({
508 "action": r["permission"],
509 "resource": r["pattern"],
510 "effect": r["action"],
511 })
512 })
513 .collect()
514 })
515 .unwrap_or_default();
516 let mut out = json!({
517 "id": a["name"],
518 "hidden": a.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false),
519 "mode": a["mode"],
520 "permissions": perms,
521 "request": {"headers": {}, "body": {}},
522 });
523 if let Some(d) = a.get("description") {
524 out["description"] = d.clone();
525 }
526 if let Some(p) = a.get("prompt") {
528 out["system"] = p.clone();
529 } else if a["name"] == "build" {
530 out["system"] = json!(BUILD_SYSTEM_BLURB);
531 }
532 out
533 })
534 .collect();
535 Json(json!({"location": api_location(&st), "data": data}))
536}
537
538const BUILD_SYSTEM_BLURB: &str = "You are an AI coding agent. Help the user accomplish software \
540engineering tasks by inspecting the workspace, making targeted changes, and using tools according \
541to the configured permissions.";
542
543async fn get_api_command(State(st): State<Arc<AppState>>) -> impl IntoResponse {
545 let payloads = st.payloads.read();
546 let data: Vec<Value> = payloads
547 .command
548 .iter()
549 .map(|c| {
550 json!({
551 "name": c["name"],
552 "description": c.get("description").and_then(|v| v.as_str()).unwrap_or(""),
553 "template": c.get("template").and_then(|v| v.as_str()).unwrap_or(""),
554 })
555 })
556 .collect();
557 Json(json!({"location": api_location(&st), "data": data}))
558}
559
560async fn get_api_reference(State(st): State<Arc<AppState>>) -> impl IntoResponse {
562 Json(json!({"location": api_location(&st), "data": []}))
563}
564
565async fn get_experimental_tool(
572 State(st): State<Arc<AppState>>,
573 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
574) -> Result<Json<Value>, ApiError> {
575 let has = |k: &str| q.get(k).map(|s| !s.is_empty()).unwrap_or(false);
576 if !has("provider") || !has("model") {
577 return Err(ApiError {
578 status: StatusCode::BAD_REQUEST,
579 name: "BadRequest",
580 message: "Invalid query: provider, model required".into(),
581 });
582 }
583 let mut schemas = ocserve_tools::schemas();
584 if let Some(hub) = st.mcp.get() {
585 let mcp_tools =
586 tokio::time::timeout(std::time::Duration::from_secs(10), hub.tool_schemas())
587 .await
588 .unwrap_or_else(|_| {
589 tracing::warn!("mcp tool schema fetch timed out (/experimental/tool)");
590 Vec::new()
591 });
592 schemas.extend(mcp_tools);
593 }
594 let items: Vec<Value> = schemas
595 .iter()
596 .filter_map(|t| {
597 let f = t.get("function")?;
598 Some(json!({
599 "id": f.get("name")?,
600 "description": f.get("description")?,
601 "parameters": f.get("parameters")?,
602 }))
603 })
604 .collect();
605 Ok(Json(Value::Array(items)))
606}
607
608async fn get_experimental_tool_ids() -> Json<Value> {
610 let ids: Vec<Value> = ocserve_tools::schemas()
611 .iter()
612 .filter_map(|t| t.pointer("/function/name").and_then(|n| n.as_str()))
613 .map(|n| json!(n))
614 .collect();
615 Json(Value::Array(ids))
616}
617
618async fn experimental_resource() -> Json<Value> {
619 Json(json!({}))
620}
621async fn experimental_workspace() -> Json<Value> {
622 Json(json!([]))
623}
624
625async fn experimental_workspace_status() -> Json<Value> {
627 Json(json!([]))
628}
629async fn formatter_list() -> Json<Value> {
630 Json(json!([]))
631}
632async fn lsp_list() -> Json<Value> {
633 Json(json!([]))
634}
635async fn project_directories() -> Json<Value> {
636 Json(json!([]))
637}
638
639async fn get_mcp(State(st): State<Arc<AppState>>) -> Json<Value> {
641 let hub = st
642 .mcp
643 .get()
644 .cloned()
645 .unwrap_or_else(|| std::sync::Arc::new(ocserve_mcp::McpHub::default()));
646 Json(hub.statuses())
647}
648
649async fn get_experimental_sessions(
653 State(st): State<Arc<AppState>>,
654 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
655) -> Json<Value> {
656 let mut v = run_blocking(&st.db, ocserve_store::load_sessions_wire)
657 .await
658 .unwrap_or_else(|e| {
659 tracing::error!("session list read failed: {e:#}");
660 Vec::new()
661 });
662 if let Some(search) = q.get("search").filter(|s| !s.is_empty()) {
663 let needle = search.to_lowercase();
664 v.retain(|s| {
665 s["title"]
666 .as_str()
667 .unwrap_or_default()
668 .to_lowercase()
669 .contains(&needle)
670 });
671 }
672 if let Some(limit) = q.get("limit").and_then(|l| l.parse::<usize>().ok()) {
673 v.truncate(limit);
674 }
675 for row in &mut v {
677 row["project"] = json!({"id": "global", "worktree": "/"});
678 }
679 Json(Value::Array(v))
680}
681
682async fn post_permission_reply(
684 State(st): State<Arc<AppState>>,
685 axum::extract::Path(id): axum::extract::Path<String>,
686 Json(body): Json<Value>,
687) -> Result<Json<Value>, ApiError> {
688 let reply = body
689 .get("reply")
690 .and_then(|v| v.as_str())
691 .unwrap_or("reject")
692 .to_string();
693 if !st.gate.reply(&id, &reply) {
694 return Err(ApiError::not_found(format!(
695 "Permission request not found: {id}"
696 )));
697 }
698 Ok(Json(json!({})))
699}
700
701async fn get_permissions(State(st): State<Arc<AppState>>) -> Json<Value> {
703 Json(serde_json::Value::Array(st.gate.list()))
704}
705
706async fn vcs_info() -> impl IntoResponse {
708 let git = |args: &[&str]| -> Option<String> {
709 let out = std::process::Command::new("git").args(args).output().ok()?;
710 if !out.status.success() {
711 return None;
712 }
713 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
714 (!s.is_empty()).then_some(s)
715 };
716 Json(json!({
717 "branch": git(&["rev-parse", "--abbrev-ref", "HEAD"]),
718 "default_branch": git(&["symbolic-ref", "refs/remotes/origin/HEAD"])
719 .map(|r| r.trim_start_matches("refs/remotes/origin/").to_string()),
720 }))
721}
722
723pub const PAGE_MEMO_ENTRY_CAP: usize = 4 * 1024 * 1024;
737pub const PAGE_MEMO_MAX_ENTRIES: usize = 2048;
744pub const PAGE_MEMO_MAX_BYTES: usize = 64 * 1024 * 1024;
745
746struct PageEntry {
747 body: bytes::Bytes,
748 next: Option<String>,
749}
750
751struct PageMemoState {
752 epoch: u64,
753 entries: std::collections::VecDeque<(String, PageEntry)>,
754 bytes: usize,
755}
756
757impl PageMemoState {
758 fn new() -> Self {
759 Self {
760 epoch: 0,
761 entries: std::collections::VecDeque::new(),
762 bytes: 0,
763 }
764 }
765 fn get(&mut self, key: &str, epoch: u64) -> Option<PageEntry> {
766 if self.epoch != epoch {
767 self.entries.clear();
768 self.bytes = 0;
769 self.epoch = epoch;
770 }
771 self.entries
772 .iter()
773 .find(|(k, _)| k == key)
774 .map(|(_, e)| PageEntry {
775 body: e.body.clone(),
776 next: e.next.clone(),
777 })
778 }
779 fn put(&mut self, key: String, entry: PageEntry, epoch: u64) {
780 if entry.body.len() > PAGE_MEMO_ENTRY_CAP {
781 return;
782 }
783 if self.epoch != epoch {
784 self.entries.clear();
785 self.bytes = 0;
786 self.epoch = epoch;
787 }
788 if let Some(pos) = self.entries.iter().position(|(k, _)| k == &key) {
790 let (_, old) = self.entries.remove(pos).expect("position found");
791 self.bytes -= old.body.len();
792 }
793 let budget = std::env::var("OCSERVE_PAGE_MEMO_MB")
794 .ok()
795 .and_then(|v| v.parse::<usize>().ok())
796 .unwrap_or(64)
797 * 1024
798 * 1024;
799 self.bytes += entry.body.len();
800 self.entries.push_back((key, entry));
801 while self.entries.len() > PAGE_MEMO_MAX_ENTRIES
802 || (self.bytes > budget && self.entries.len() > 1)
803 {
804 if let Some((_, old)) = self.entries.pop_front() {
805 self.bytes -= old.body.len();
806 } else {
807 break;
808 }
809 }
810 }
811 fn len(&self) -> usize {
812 self.entries.len()
813 }
814}
815
816static PAGE_MEMO: std::sync::OnceLock<parking_lot::Mutex<PageMemoState>> =
817 std::sync::OnceLock::new();
818static PAGE_MEMO_HITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
819static PAGE_MEMO_MISSES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
820
821fn page_memo() -> &'static parking_lot::Mutex<PageMemoState> {
822 PAGE_MEMO.get_or_init(|| parking_lot::Mutex::new(PageMemoState::new()))
823}
824
825pub fn page_memo_enabled() -> bool {
826 std::env::var("OCSERVE_PAGE_MEMO")
827 .map(|v| v != "0")
828 .unwrap_or(true)
829}
830
831pub fn page_memo_stats() -> (u64, u64, usize) {
833 (
834 PAGE_MEMO_HITS.load(std::sync::atomic::Ordering::Relaxed),
835 PAGE_MEMO_MISSES.load(std::sync::atomic::Ordering::Relaxed),
836 page_memo().lock().len(),
837 )
838}
839
840fn page_response(
845 headers: &axum::http::HeaderMap,
846 uri: &axum::http::Uri,
847 page_n: &Option<u64>,
848 page_next: &Option<String>,
849 body: axum::body::Body,
850) -> Result<axum::response::Response, ApiError> {
851 let mut builder = axum::response::Response::builder()
852 .header(axum::http::header::CONTENT_TYPE, "application/json");
853 if let (Some(n), Some(cur)) = (page_n, page_next) {
854 let scheme = headers
855 .get("x-forwarded-proto")
856 .and_then(|v| v.to_str().ok())
857 .unwrap_or("http");
858 let host = headers
859 .get("host")
860 .and_then(|v| v.to_str().ok())
861 .unwrap_or("localhost");
862 let link = format!(
863 "<{scheme}://{host}{}?limit={n}&before={cur}>; rel=\"next\"",
864 uri.path()
865 );
866 builder = builder
867 .header("x-next-cursor", cur.as_str())
868 .header("link", link)
869 .header("access-control-expose-headers", "Link, X-Next-Cursor");
870 }
871 builder.body(body).map_err(|e| ApiError {
872 status: StatusCode::INTERNAL_SERVER_ERROR,
873 name: "InternalError",
874 message: format!("response build: {e}"),
875 })
876}
877
878async fn get_sessions(State(st): State<Arc<AppState>>) -> axum::response::Response {
879 match run_blocking(&st.db, ocserve_store::load_sessions_wire_bytes).await {
884 Ok(b) => axum::response::Response::builder()
885 .header(axum::http::header::CONTENT_TYPE, "application/json")
886 .body(axum::body::Body::from((*b).clone()))
887 .expect("static response"),
888 Err(e) => {
889 tracing::error!("session list read failed: {e:#}");
890 Json(Vec::<serde_json::Value>::new()).into_response()
891 }
892 }
893}
894
895async fn get_session(
896 State(st): State<Arc<AppState>>,
897 axum::extract::Path(id): axum::extract::Path<String>,
898) -> Result<impl IntoResponse, ApiError> {
899 let wire_id = id.clone();
900 run_blocking(&st.db, move |db| {
901 ocserve_store::load_session_wire(db, &wire_id)
902 })
903 .await
904 .map_err(|e| ApiError {
905 status: StatusCode::INTERNAL_SERVER_ERROR,
906 name: "InternalError",
907 message: format!("{e:#}"),
908 })?
909 .map(Json)
910 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
911}
912
913async fn session_status(State(st): State<Arc<AppState>>) -> impl IntoResponse {
918 let mut out = serde_json::Map::new();
919 let mut busy = |sid: &str| {
920 out.insert(sid.to_string(), serde_json::json!({"type": "busy"}));
921 };
922 for (sid, arc) in st.prompt_locks.lock().iter() {
923 if arc.try_lock().is_err() {
924 busy(sid);
925 }
926 }
927 for (sid, (_gen, handle)) in st.prompt_tasks.lock().iter() {
928 if !handle.is_finished() {
929 busy(sid);
930 }
931 }
932 Json(serde_json::Value::Object(out))
933}
934
935async fn get_path(State(st): State<Arc<AppState>>) -> impl IntoResponse {
937 Json(st.paths.clone())
938}
939
940async fn get_projects(State(st): State<Arc<AppState>>) -> impl IntoResponse {
942 let now_ms = std::time::SystemTime::now()
943 .duration_since(std::time::UNIX_EPOCH)
944 .map(|d| d.as_millis() as i64)
945 .unwrap_or(0);
946 let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
947 let project = |id: &str, wt: &str| {
948 json!({
949 "id": id,
950 "worktree": wt,
951 "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
952 "sandboxes": [],
953 })
954 };
955 Json(json!([
956 project("global", "/"),
957 project("current", &worktree),
958 ]))
959}
960
961async fn get_project_current(State(st): State<Arc<AppState>>) -> impl IntoResponse {
963 let now_ms = std::time::SystemTime::now()
964 .duration_since(std::time::UNIX_EPOCH)
965 .map(|d| d.as_millis() as i64)
966 .unwrap_or(0);
967 let worktree = st.paths["worktree"].as_str().unwrap_or("/");
968 Json(json!({
969 "id": "global",
970 "worktree": worktree,
971 "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
972 "sandboxes": [],
973 }))
974}
975
976async fn metrics(State(_st): State<Arc<AppState>>) -> impl IntoResponse {
978 ocserve_metrics::sample_rss();
981 ocserve_metrics::gauge(
990 "ocserve_splice_rows_total",
991 ocserve_store::splice_rows() as i64,
992 );
993 ocserve_metrics::gauge(
994 "ocserve_splice_fallbacks_total",
995 ocserve_store::splice_fallbacks() as i64,
996 );
997 let (search_hits, search_misses, list_hits, list_misses, _pad, search_len, _epoch) =
998 ocserve_store::memo_stats();
999 ocserve_metrics::gauge("ocserve_memo_search_hits", search_hits as i64);
1000 ocserve_metrics::gauge("ocserve_memo_search_misses", search_misses as i64);
1001 ocserve_metrics::gauge("ocserve_memo_list_hits", list_hits as i64);
1002 ocserve_metrics::gauge("ocserve_memo_list_misses", list_misses as i64);
1003 ocserve_metrics::gauge("ocserve_memo_search_entries", search_len as i64);
1004 let body = ocserve_metrics::render();
1005 (
1006 [(
1007 axum::http::header::CONTENT_TYPE,
1008 "text/plain; version=0.0.4",
1009 )],
1010 body,
1011 )
1012}
1013
1014fn slug_for(id: &str) -> String {
1018 const A: &[&str] = &[
1019 "stellar", "amber", "quiet", "cobalt", "rapid", "lunar", "vivid", "crisp", "amber", "ivory",
1020 ];
1021 const B: &[&str] = &[
1022 "orchid", "falcon", "meadow", "canyon", "ember", "drift", "quartz", "harbor", "summit",
1023 "ripple",
1024 ];
1025 let h = id
1026 .bytes()
1027 .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
1028 format!(
1029 "{}-{}",
1030 A[(h % A.len() as u64) as usize],
1031 B[((h >> 16) % B.len() as u64) as usize]
1032 )
1033}
1034
1035fn now_ms() -> i64 {
1036 std::time::SystemTime::now()
1037 .duration_since(std::time::UNIX_EPOCH)
1038 .map(|d| d.as_millis() as i64)
1039 .unwrap_or(0)
1040}
1041
1042fn iso8601_z(ms: i64) -> String {
1045 let ms = ms.rem_euclid(86_400_000_000_000);
1046 let (days, rem) = (ms.div_euclid(86_400_000), ms.rem_euclid(86_400_000));
1047 let (h, rem) = (rem.div_euclid(3_600_000), rem.rem_euclid(3_600_000));
1048 let (mi, rem) = (rem.div_euclid(60_000), rem.rem_euclid(60_000));
1049 let (sec, milli) = (rem.div_euclid(1_000), rem.rem_euclid(1_000));
1050 let z = days + 719_468;
1052 let era = z.div_euclid(146_097);
1053 let doe = z.rem_euclid(146_097);
1054 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1055 let y = yoe + era * 400;
1056 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1057 let mp = (5 * doy + 2) / 153;
1058 let d = doy - (153 * mp + 2) / 5 + 1;
1059 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1060 let y = if m <= 2 { y + 1 } else { y };
1061 format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}.{milli:03}Z")
1062}
1063
1064fn default_session_title(now_ms: i64) -> String {
1067 format!("New session - {}", iso8601_z(now_ms))
1068}
1069
1070pub fn forked_title(title: &str) -> String {
1075 const PAT: &str = " (fork #";
1076 if let Some(idx) = title.rfind(PAT)
1077 && idx > 0
1078 && let Some(num) = title[idx + PAT.len()..].strip_suffix(')')
1079 && !num.is_empty()
1080 && num.bytes().all(|b| b.is_ascii_digit())
1081 && let Ok(n) = num.parse::<u64>()
1082 {
1083 return format!("{} (fork #{})", &title[..idx], n + 1);
1084 }
1085 format!("{title} (fork #1)")
1086}
1087
1088pub fn start_plugin_event_pump(st: &Arc<AppState>) {
1092 let mut rx = st.bus.subscribe();
1097 let st = st.clone();
1098 tokio::spawn(async move {
1099 loop {
1100 match rx.recv().await {
1101 Ok(frame_str) => {
1102 let Some(plug) = st.plugins.get() else {
1103 continue; };
1105 let Ok(v) = serde_json::from_str::<Value>(&frame_str) else {
1106 continue;
1107 };
1108 let Some(payload) = v.get("payload").cloned() else {
1109 continue;
1110 };
1111 let mut guard = plug.lock().await;
1112 if let Err(e) = guard.emit_event(payload).await {
1113 tracing::debug!("plugin event pump: {e:#}");
1114 }
1115 }
1116 Err(ocserve_core::event::RecvError::Lagged(n)) => {
1117 tracing::warn!("plugin event pump lagged {n} frames (skipped)");
1118 }
1119 Err(ocserve_core::event::RecvError::Closed) => break,
1120 }
1121 }
1122 });
1123}
1124
1125async fn post_session(
1128 State(st): State<Arc<AppState>>,
1129 Json(body): Json<Value>,
1130) -> Result<impl IntoResponse, ApiError> {
1131 let id = ocserve_core::ids::ses_id();
1132 let now = now_ms();
1133 let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
1134 let title = body
1135 .get("title")
1136 .and_then(|t| t.as_str())
1137 .unwrap_or("")
1138 .to_string();
1139 let title = if title.trim().is_empty() {
1142 default_session_title(now)
1143 } else {
1144 title
1145 };
1146 let info = json!({
1147 "id": id,
1148 "projectID": "global",
1149 "directory": worktree,
1150 "path": worktree.trim_start_matches('/'),
1151 "slug": slug_for(&id),
1152 "title": title,
1153 "version": FREEZE_VERSION,
1154 "time": {"created": now, "updated": now},
1155 "cost": 0,
1156 "tokens": {"input": 0, "output": 0, "reasoning": 0,
1157 "cache": {"read": 0, "write": 0}},
1158 });
1159 ocserve_store::insert_session(&st.writer, &info).map_err(|e| ApiError {
1160 status: StatusCode::INTERNAL_SERVER_ERROR,
1161 name: "InternalError",
1162 message: format!("{e:#}"),
1163 })?;
1164 st.bus.publish(ocserve_core::event::frame(
1165 st.paths["directory"].as_str().unwrap_or("/"),
1166 "session.created",
1167 json!({"sessionID": id, "info": info}),
1168 ));
1169 Ok(Json(info))
1170}
1171
1172async fn post_fork(
1180 State(st): State<Arc<AppState>>,
1181 axum::extract::Path(id): axum::extract::Path<String>,
1182 body: axum::body::Bytes,
1183) -> Result<Json<Value>, HttpError> {
1184 let upto: Option<String> = {
1185 let text = String::from_utf8_lossy(&body);
1186 if text.trim().is_empty() {
1187 None } else {
1189 let v: Value = serde_json::from_str(&text).map_err(|_| tagged_bad_request())?;
1190 if !v.is_object() {
1191 return Err(tagged_bad_request()); }
1193 match v.get("messageID") {
1194 None => None,
1195 Some(Value::String(s)) => Some(s.clone()),
1196 Some(_) => return Err(tagged_bad_request()), }
1198 }
1199 };
1200 let src_id = id.clone();
1201 let src = run_blocking(&st.db, move |db| {
1202 ocserve_store::load_session_wire(db, &src_id)
1203 })
1204 .await
1205 .map_err(|e| ApiError {
1206 status: StatusCode::INTERNAL_SERVER_ERROR,
1207 name: "InternalError",
1208 message: format!("{e:#}"),
1209 })?
1210 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))?;
1211
1212 let new_id = ocserve_core::ids::ses_id();
1213 let now = now_ms();
1214 let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
1215 let info = json!({
1219 "id": new_id,
1220 "projectID": "global",
1221 "directory": worktree,
1222 "path": worktree.trim_start_matches('/'),
1223 "slug": slug_for(&new_id),
1224 "title": forked_title(src["title"].as_str().unwrap_or("")),
1225 "version": FREEZE_VERSION,
1226 "time": {"created": now, "updated": now},
1227 "cost": 0,
1228 "tokens": {"input": 0, "output": 0, "reasoning": 0,
1229 "cache": {"read": 0, "write": 0}},
1230 });
1231 let writer = st.writer.clone();
1234 let blobs = st.blobs.clone();
1235 let db = st.db.clone();
1236 let fork_info = info.clone();
1237 let fork_id = id.clone();
1238 let fork_upto = upto.clone();
1239 let stats = run_blocking(&db, move |db| {
1240 let env = ocserve_store::fork::ForkEnv {
1241 writer: &writer,
1242 blobs: Some(&*blobs),
1243 db,
1244 };
1245 ocserve_store::fork_session(
1246 &env,
1247 &fork_info,
1248 &fork_id,
1249 fork_upto.as_deref(),
1250 ocserve_core::ids::msg_id,
1251 ocserve_core::ids::prt_id,
1252 )
1253 })
1254 .await
1255 .map_err(|e| ApiError {
1256 status: StatusCode::INTERNAL_SERVER_ERROR,
1257 name: "InternalError",
1258 message: format!("{e:#}"),
1259 })?;
1260 ocserve_metrics::counter("ocserve_session_fork_total", 1);
1261 tracing::info!(
1262 fork = %new_id,
1263 source = %id,
1264 upto = ?upto,
1265 messages = stats.messages,
1266 parts = stats.parts,
1267 "session forked"
1268 );
1269 st.bus.publish(ocserve_core::event::frame(
1274 st.paths["directory"].as_str().unwrap_or("/"),
1275 "session.created",
1276 json!({"sessionID": new_id, "info": info}),
1277 ));
1278 Ok(Json(info))
1279}
1280
1281async fn get_messages(
1283 State(st): State<Arc<AppState>>,
1284 axum::extract::Path(id): axum::extract::Path<String>,
1285 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1286 uri: axum::http::Uri,
1287 headers: axum::http::HeaderMap,
1288) -> Result<impl IntoResponse, HttpError> {
1289 let limit: Option<u64> = match q.get("limit") {
1293 None => None,
1294 Some(raw) if raw.is_empty() => None, Some(raw) => Some(parse_limit(raw).map_err(|message| HttpError::Query { message })?),
1296 };
1297 let before: Option<(String, i64)> = match (q.get("before").map(String::as_str), limit) {
1298 (Some(_), None) => return Err(tagged_bad_request()),
1299 (Some(b), Some(_)) => {
1300 Some(ocserve_store::decode_cursor(b).map_err(|_| tagged_bad_request())?)
1301 }
1302 (None, _) => None,
1303 };
1304 let exists_id = id.clone();
1305 if !run_blocking(&st.db, move |db| {
1306 ocserve_store::session_exists(db, &exists_id)
1307 })
1308 .await
1309 .map_err(|e| ApiError {
1310 status: StatusCode::INTERNAL_SERVER_ERROR,
1311 name: "InternalError",
1312 message: format!("{e:#}"),
1313 })? {
1314 return Err(ApiError::not_found(format!("Session not found: {id}")).into());
1315 }
1316 let mut page_n: Option<u64> = None;
1319 let mut page_next: Option<String> = None;
1320 let walk: ocserve_store::MessageWalk = match limit.filter(|n| *n > 0) {
1321 Some(n) => {
1322 let page_id = id.clone();
1323 let before_owned = before.clone();
1324 let (rows, _more, next) = run_blocking(&st.db, move |db| {
1325 ocserve_store::page_messages(
1326 db,
1327 &page_id,
1328 n,
1329 before_owned.as_ref().map(|(b, t)| (b.as_str(), *t)),
1330 )
1331 })
1332 .await
1333 .map_err(|e| ApiError {
1334 status: StatusCode::INTERNAL_SERVER_ERROR,
1335 name: "InternalError",
1336 message: format!("{e:#}"),
1337 })?;
1338 page_n = Some(n);
1339 page_next = next;
1340 ocserve_store::MessageWalk::Window(rows)
1341 }
1342 None => ocserve_store::MessageWalk::Seq { limit: None },
1343 };
1344 if page_n.is_some() && page_memo_enabled() {
1348 let memo_key = format!("{}\u{1}{id}\u{1}{limit:?}\u{1}{before:?}", st.db.display());
1349 let e0 = ocserve_store::write_epoch();
1350 {
1351 let mut m = page_memo().lock();
1352 if let Some(e) = m.get(&memo_key, e0) {
1353 drop(m);
1354 PAGE_MEMO_HITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1355 return page_response(
1356 &headers,
1357 &uri,
1358 &page_n,
1359 &page_next,
1360 axum::body::Body::from(e.body),
1361 )
1362 .map_err(Into::into);
1363 }
1364 }
1365 PAGE_MEMO_MISSES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1366 let walk_asm = walk.clone();
1370 let db_asm = st.db.clone();
1371 let sid_asm = id.clone();
1372 let asm: anyhow::Result<std::result::Result<Vec<u8>, ()>> =
1373 run_blocking(&db_asm, move |db| {
1374 let mut buf: Vec<u8> = Vec::new();
1375 let mut total = 0usize;
1376 let mut first = true;
1377 let r = ocserve_store::for_each_message_json(db, &sid_asm, walk_asm, |chunk| {
1378 let framed = if first {
1379 first = false;
1380 format!("[{chunk}")
1381 } else {
1382 format!(",{chunk}")
1383 };
1384 if total + framed.len() > PAGE_MEMO_ENTRY_CAP {
1385 return Err(anyhow::anyhow!("page memo entry cap"));
1386 }
1387 total += framed.len();
1388 buf.extend_from_slice(framed.as_bytes());
1389 Ok(())
1390 });
1391 match r {
1392 Ok(()) => {
1393 if first {
1396 buf.extend_from_slice(b"[]");
1397 } else {
1398 buf.push(b']');
1399 }
1400 Ok(Ok(buf))
1401 }
1402 Err(e) if e.to_string().contains("entry cap") => Ok(Err(())),
1403 Err(e) => Err(e),
1404 }
1405 })
1406 .await;
1407 if let Ok(Ok(buf)) = asm {
1408 let bytes = bytes::Bytes::from(buf);
1409 let entry = PageEntry {
1410 body: bytes.clone(),
1411 next: page_next.clone(),
1412 };
1413 page_memo().lock().put(memo_key, entry, e0);
1414 return page_response(
1415 &headers,
1416 &uri,
1417 &page_n,
1418 &page_next,
1419 axum::body::Body::from(bytes),
1420 )
1421 .map_err(Into::into);
1422 }
1423 }
1425 let (tx, rx) = tokio::sync::mpsc::channel::<String>(4);
1430 let db = st.db.clone();
1431 let sid = id.clone();
1432 tokio::task::spawn_blocking(move || {
1438 let mut first = true;
1439 let r = ocserve_store::for_each_message_json(&db, &sid, walk, |chunk| {
1440 let framed = if first {
1441 first = false;
1442 format!("[{chunk}")
1443 } else {
1444 format!(",{chunk}")
1445 };
1446 tx.blocking_send(framed)
1447 .map_err(|_| anyhow::anyhow!("client disconnected"))
1448 });
1449 if let Err(e) = r {
1450 tracing::error!("message stream aborted session={sid}: {e:#}");
1451 }
1452 let _ = tx.blocking_send(if first {
1454 "[]".to_string()
1455 } else {
1456 "]".to_string()
1457 });
1458 });
1459 let body_stream = futures_util::stream::unfold(rx, |mut rx| async move {
1460 rx.recv().await.map(|chunk| {
1461 (
1462 Ok::<axum::body::Bytes, std::convert::Infallible>(axum::body::Bytes::from(chunk)),
1463 rx,
1464 )
1465 })
1466 });
1467 page_response(
1468 &headers,
1469 &uri,
1470 &page_n,
1471 &page_next,
1472 axum::body::Body::from_stream(body_stream),
1473 )
1474 .map_err(Into::into)
1475}
1476
1477async fn patch_config(
1485 State(st): State<Arc<AppState>>,
1486 Json(payload): Json<Value>,
1487) -> Result<Json<Value>, ApiError> {
1488 let path = config_write_path();
1489 let mut base: Value = if path.exists() {
1490 let raw = std::fs::read_to_string(&path).map_err(|e| ApiError {
1491 status: StatusCode::INTERNAL_SERVER_ERROR,
1492 name: "InternalError",
1493 message: format!("read {}: {e}", path.display()),
1494 })?;
1495 serde_json::from_str(&raw).map_err(|e| ApiError {
1496 status: StatusCode::INTERNAL_SERVER_ERROR,
1497 name: "InternalError",
1498 message: format!("parse {}: {e}", path.display()),
1499 })?
1500 } else {
1501 json!({})
1502 };
1503 deep_merge(&mut base, payload.clone());
1504 let dir = path.parent().ok_or_else(|| ApiError {
1505 status: StatusCode::INTERNAL_SERVER_ERROR,
1506 name: "InternalError",
1507 message: "config path has no parent".into(),
1508 })?;
1509 std::fs::create_dir_all(dir).map_err(|e| ApiError {
1510 status: StatusCode::INTERNAL_SERVER_ERROR,
1511 name: "InternalError",
1512 message: format!("mkdir {}: {e}", dir.display()),
1513 })?;
1514 let tmp = dir.join(format!(".opencode.json.tmp-{}", std::process::id()));
1515 let data = serde_json::to_vec_pretty(&base).map_err(|e| ApiError {
1516 status: StatusCode::INTERNAL_SERVER_ERROR,
1517 name: "InternalError",
1518 message: format!("serialize: {e}"),
1519 })?;
1520 std::fs::write(&tmp, &data).map_err(|e| ApiError {
1521 status: StatusCode::INTERNAL_SERVER_ERROR,
1522 name: "InternalError",
1523 message: format!("write {}: {e}", tmp.display()),
1524 })?;
1525 if path.exists() {
1526 let _ = std::fs::copy(&path, dir.join(".opencode.json.bak"));
1527 }
1528 std::fs::rename(&tmp, &path).map_err(|e| ApiError {
1529 status: StatusCode::INTERNAL_SERVER_ERROR,
1530 name: "InternalError",
1531 message: format!("rename {}: {e}", path.display()),
1532 })?;
1533 if let Err(e) = watch::reconcile(&st).await {
1537 tracing::warn!("config reload failed (restart to apply): {e:#}");
1538 }
1539 tracing::info!("config patched: {}", path.display());
1540 Ok(Json(payload))
1541}
1542
1543async fn post_summarize(
1550 State(st): State<Arc<AppState>>,
1551 axum::extract::Path(sid): axum::extract::Path<String>,
1552 Json(payload): Json<Value>,
1553) -> Result<Json<Value>, ApiError> {
1554 let exists_id = sid.clone();
1555 if !run_blocking(&st.db, move |db| {
1556 ocserve_store::session_exists(db, &exists_id)
1557 })
1558 .await
1559 .map_err(|e| ApiError {
1560 status: StatusCode::INTERNAL_SERVER_ERROR,
1561 name: "InternalError",
1562 message: format!("{e:#}"),
1563 })? {
1564 return Err(ApiError::not_found(format!("Session not found: {sid}")));
1565 }
1566 let provider = payload
1567 .get("providerID")
1568 .and_then(|v| v.as_str())
1569 .filter(|s| !s.is_empty());
1570 let model_id = payload
1571 .get("modelID")
1572 .and_then(|v| v.as_str())
1573 .filter(|s| !s.is_empty());
1574 let (Some(provider), Some(model_id)) = (provider, model_id) else {
1575 return Err(ApiError {
1576 status: StatusCode::BAD_REQUEST,
1577 name: "BadRequest",
1578 message: "providerID and modelID are required".into(),
1579 });
1580 };
1581
1582 let release = lock_session(&st, &sid).await?;
1583 let Ok(_guard) = release.arc().try_lock() else {
1584 return Err(session_busy(&sid));
1585 };
1586
1587 let history = run_blocking(&st.db, {
1595 let sid = sid.clone();
1596 move |db| ocserve_store::load_messages(db, &sid, None) })
1598 .await
1599 .map_err(|e| ApiError {
1600 status: StatusCode::INTERNAL_SERVER_ERROR,
1601 name: "InternalError",
1602 message: format!("{e:#}"),
1603 })?;
1604 let agent = history
1605 .iter()
1606 .rev()
1607 .find(|(info, _)| info["role"] == "user")
1608 .and_then(|(info, _)| info["agent"].as_str())
1609 .unwrap_or("build")
1610 .to_string();
1611 let prompt_payload = json!({
1612 "model": {"providerID": provider, "modelID": model_id},
1613 "agent": agent,
1614 "parts": [],
1615 });
1616 let ctx = build_prompt_context(&st, &prompt_payload, &sid)?;
1617 let writer = st.writer.clone();
1618 ocserve_core::compaction::persist_anchor(&ctx, &writer, &sid, &agent, false, false)
1619 .await
1620 .map_err(|e| ApiError {
1621 status: StatusCode::INTERNAL_SERVER_ERROR,
1622 name: "InternalError",
1623 message: format!("{e:#}"),
1624 })?;
1625 ocserve_core::prompt::run_prompt_with(
1626 &ctx,
1627 &writer,
1628 &sid,
1629 &prompt_payload,
1630 ocserve_core::prompt::RunOpts {
1631 persist_user: false,
1636 skip_history: false,
1637 prelude: None,
1638 tools_enabled: false,
1639 ..Default::default()
1640 },
1641 )
1642 .await
1643 .map_err(prompt_err)?;
1644 st.bus.publish(ocserve_core::event::frame(
1646 st.paths["directory"].as_str().unwrap_or("/"),
1647 "session.compacted",
1648 json!({"sessionID": sid}),
1649 ));
1650 Ok(Json(json!(true)))
1651}
1652
1653fn mcp_not_found(name: &str) -> HttpError {
1659 HttpError::TaggedData {
1660 status: StatusCode::NOT_FOUND,
1661 tag: "McpServerNotFoundError",
1662 fields: json!({"name": name, "message": format!("MCP server not found: {name}")}),
1663 }
1664}
1665
1666async fn mcp_action(
1667 State(st): State<Arc<AppState>>,
1668 axum::extract::Path((name, action)): axum::extract::Path<(String, String)>,
1669) -> Result<Json<Value>, HttpError> {
1670 let hub = st.mcp.get().cloned().ok_or_else(|| HttpError::TaggedData {
1671 status: StatusCode::SERVICE_UNAVAILABLE,
1672 tag: "McpUnavailable",
1673 fields: json!({"message": "MCP hub not initialized"}),
1674 })?;
1675 let res = match action.as_str() {
1676 "connect" => hub.connect(&name).await,
1677 "disconnect" => hub.disconnect(&name).await,
1678 _ => Err(anyhow::anyhow!("unknown MCP action: {action}")),
1679 };
1680 res.map_err(|e| {
1681 if e.to_string().contains("not found") {
1682 mcp_not_found(&name)
1683 } else {
1684 HttpError::Api(ApiError {
1685 status: StatusCode::INTERNAL_SERVER_ERROR,
1686 name: "InternalError",
1687 message: format!("{e:#}"),
1688 })
1689 }
1690 })?;
1691 Ok(Json(json!(true)))
1692}
1693
1694async fn mcp_auth_remove(
1695 State(st): State<Arc<AppState>>,
1696 axum::extract::Path(name): axum::extract::Path<String>,
1697) -> Result<Json<Value>, HttpError> {
1698 let known = st
1700 .mcp
1701 .get()
1702 .map(|h| {
1703 let m = h.statuses();
1704 m.as_object()
1705 .map(|o| o.contains_key(&name))
1706 .unwrap_or(false)
1707 || h.known(&name)
1708 })
1709 .unwrap_or(false);
1710 if !known {
1711 return Err(mcp_not_found(&name));
1712 }
1713 Ok(Json(json!({"success": true})))
1716}
1717
1718fn auth_overlay_path(st: &AppState) -> std::path::PathBuf {
1721 st.db
1722 .parent()
1723 .unwrap_or(std::path::Path::new("."))
1724 .join("auth-overlay.json")
1725}
1726
1727fn write_json_atomic(path: &std::path::Path, value: &Value) -> Result<(), ApiError> {
1728 let dir = path.parent().ok_or_else(|| ApiError {
1729 status: StatusCode::INTERNAL_SERVER_ERROR,
1730 name: "InternalError",
1731 message: "path has no parent".into(),
1732 })?;
1733 std::fs::create_dir_all(dir).map_err(|e| ApiError {
1734 status: StatusCode::INTERNAL_SERVER_ERROR,
1735 name: "InternalError",
1736 message: format!("mkdir: {e}"),
1737 })?;
1738 let tmp = dir.join(format!(".tmp-{}-auth", std::process::id()));
1739 let data = serde_json::to_vec_pretty(value).map_err(|e| ApiError {
1740 status: StatusCode::INTERNAL_SERVER_ERROR,
1741 name: "InternalError",
1742 message: format!("serialize: {e}"),
1743 })?;
1744 std::fs::write(&tmp, data).map_err(|e| ApiError {
1745 status: StatusCode::INTERNAL_SERVER_ERROR,
1746 name: "InternalError",
1747 message: format!("write: {e}"),
1748 })?;
1749 std::fs::rename(&tmp, path).map_err(|e| ApiError {
1750 status: StatusCode::INTERNAL_SERVER_ERROR,
1751 name: "InternalError",
1752 message: format!("rename: {e}"),
1753 })?;
1754 Ok(())
1755}
1756
1757async fn auth_put(
1758 State(st): State<Arc<AppState>>,
1759 axum::extract::Path(pid): axum::extract::Path<String>,
1760 Json(body): Json<Value>,
1761) -> Result<Json<Value>, ApiError> {
1762 if !body.is_object() {
1763 return Err(ApiError {
1764 status: StatusCode::BAD_REQUEST,
1765 name: "BadRequest",
1766 message: "auth payload must be an object".into(),
1767 });
1768 }
1769 let path = auth_overlay_path(&st);
1770 let mut overlay: Value = if path.exists() {
1771 std::fs::read_to_string(&path)
1772 .ok()
1773 .and_then(|r| serde_json::from_str(&r).ok())
1774 .unwrap_or_else(|| json!({}))
1775 } else {
1776 json!({})
1777 };
1778 overlay[&pid] = body; write_json_atomic(&path, &overlay)?;
1780 if let Err(e) = watch::reconcile(&st).await {
1781 tracing::warn!("payload reload failed: {e:#}");
1782 }
1783 tracing::info!("auth overlay set provider={pid}");
1784 Ok(Json(json!(true)))
1785}
1786
1787async fn auth_delete(
1788 State(st): State<Arc<AppState>>,
1789 axum::extract::Path(pid): axum::extract::Path<String>,
1790) -> Result<Json<Value>, ApiError> {
1791 let path = auth_overlay_path(&st);
1792 if path.exists() {
1793 let mut overlay: Value = std::fs::read_to_string(&path)
1794 .ok()
1795 .and_then(|r| serde_json::from_str(&r).ok())
1796 .unwrap_or_else(|| json!({}));
1797 if let Some(obj) = overlay.as_object_mut() {
1798 obj.remove(&pid);
1799 }
1800 write_json_atomic(&path, &overlay)?;
1801 if let Err(e) = watch::reconcile(&st).await {
1802 tracing::warn!("payload reload failed: {e:#}");
1803 }
1804 }
1805 tracing::info!("auth overlay remove provider={pid}");
1806 Ok(Json(json!(true)))
1807}
1808
1809async fn provider_auth_methods(State(st): State<Arc<AppState>>) -> Json<Value> {
1814 let payloads = st.payloads.read();
1815 let mut out = serde_json::Map::new();
1816 if let Some(list) = payloads
1819 .config_providers
1820 .get("providers")
1821 .and_then(|p| p.as_array())
1822 {
1823 for e in list {
1824 if let Some(pid) = e.get("id").and_then(|v| v.as_str()) {
1825 out.insert(
1826 pid.to_string(),
1827 json!([{"type": "api", "label": "API Key"}]),
1828 );
1829 }
1830 }
1831 }
1832 Json(serde_json::Value::Object(out))
1833}
1834
1835async fn global_dispose(State(st): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
1840 ocserve_store::append_event(&st.writer, None, "server.instance.disposed", &json!({})).map_err(
1841 |e| ApiError {
1842 status: StatusCode::INTERNAL_SERVER_ERROR,
1843 name: "InternalError",
1844 message: format!("{e:#}"),
1845 },
1846 )?;
1847 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1848 st.bus.publish(ocserve_core::event::frame(
1849 &dir,
1850 "server.instance.disposed",
1851 json!({}),
1852 ));
1853 if let Err(e) = watch::reconcile(&st).await {
1854 tracing::warn!("payload reload failed: {e:#}");
1855 }
1856 tracing::info!("global dispose: emitted + payloads reloaded");
1857 Ok(Json(json!(true)))
1858}
1859
1860async fn search_messages(
1865 State(st): State<Arc<AppState>>,
1866 Json(body): Json<Value>,
1867) -> Result<Json<Value>, ApiError> {
1868 let query = body
1869 .get("query")
1870 .and_then(|v| v.as_str())
1871 .unwrap_or("")
1872 .trim()
1873 .to_string();
1874 if query.is_empty() || query.chars().count() > 512 {
1875 return Err(ApiError {
1876 status: StatusCode::BAD_REQUEST,
1877 name: "BadRequest",
1878 message: "query must be a non-empty string of at most 512 characters".into(),
1879 });
1880 }
1881 let scope = body
1882 .get("sessionID")
1883 .and_then(|v| v.as_str())
1884 .map(str::to_string);
1885 let limit = body
1886 .get("limit")
1887 .and_then(|v| v.as_u64())
1888 .unwrap_or(50)
1889 .clamp(1, 200) as u32;
1890 let offset = body.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
1891 let needle = query.clone();
1892 let (hits, truncated) = run_blocking(&st.db, move |db| {
1893 ocserve_store::search_parts(db, &needle, scope.as_deref(), limit, offset)
1894 })
1895 .await
1896 .map_err(|e| ApiError {
1897 status: StatusCode::INTERNAL_SERVER_ERROR,
1898 name: "InternalError",
1899 message: format!("{e:#}"),
1900 })?;
1901 let out = json!({
1902 "hits": hits
1903 .iter()
1904 .map(|h| json!({
1905 "sessionID": h.session_id,
1906 "messageID": h.message_id,
1907 "partID": h.part_id,
1908 "role": h.role,
1909 "time": h.time,
1910 "snippet": ocserve_store::snippet(&h.text, &query),
1911 }))
1912 .collect::<Vec<_>>(),
1913 "truncated": truncated,
1914 });
1915 Ok(Json(out))
1916}
1917
1918fn resolve_model(st: &Arc<AppState>, payload: &Value, session_id: &str) -> (String, String) {
1923 if let (Some(p), Some(m)) = (
1924 payload
1925 .pointer("/model/providerID")
1926 .and_then(|v| v.as_str()),
1927 payload.pointer("/model/modelID").and_then(|v| v.as_str()),
1928 ) {
1929 return (p.to_string(), m.to_string());
1930 }
1931 if let Ok(conn) = ocserve_store::pragma::open_reader(&st.db) {
1932 let stored: Option<String> = conn
1933 .query_row(
1934 "SELECT model FROM session WHERE id = ?1",
1935 [session_id],
1936 |r| r.get(0),
1937 )
1938 .ok()
1939 .flatten();
1940 if let Some(raw) = stored
1941 && let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
1942 {
1943 let p = v.get("providerID").and_then(|x| x.as_str()).unwrap_or("");
1944 let m = v.get("id").and_then(|x| x.as_str()).unwrap_or("");
1945 if !p.is_empty() && !m.is_empty() {
1946 return (p.to_string(), m.to_string());
1947 }
1948 }
1949 }
1950 st.llm.read().default_model.clone()
1951}
1952
1953fn build_prompt_context(
1956 st: &Arc<AppState>,
1957 payload: &Value,
1958 session_id: &str,
1959) -> Result<ocserve_core::prompt::PromptContext, ApiError> {
1960 let agent = payload
1961 .get("agent")
1962 .and_then(|a| a.as_str())
1963 .filter(|a| !a.is_empty())
1964 .map(String::from)
1965 .unwrap_or_else(|| st.llm.read().default_agent.clone());
1966 let system = st
1967 .llm
1968 .read()
1969 .systems
1970 .get(&agent)
1971 .cloned()
1972 .unwrap_or_else(|| crate::BUILD_SYSTEM_BLURB.to_string());
1973 let (pid, mid) = resolve_model(st, payload, session_id);
1974 let (base_url, api_key) =
1975 st.llm
1976 .read()
1977 .endpoints
1978 .get(&pid)
1979 .cloned()
1980 .ok_or_else(|| ApiError {
1981 status: StatusCode::BAD_REQUEST,
1982 name: "BadRequest",
1983 message: format!("no endpoint configured for provider {pid}"),
1984 })?;
1985 let pricing = st
1986 .llm
1987 .read()
1988 .pricing
1989 .get(&(pid.clone(), mid.clone()))
1990 .copied();
1991 let rules: Vec<ocserve_tools::Rule> = st
1993 .payloads
1994 .read()
1995 .agent
1996 .iter()
1997 .find(|a| a["name"].as_str() == Some(agent.as_str()))
1998 .and_then(|a| a["permission"].as_array())
1999 .map(|arr| {
2000 arr.iter()
2001 .filter_map(|r| {
2002 Some(ocserve_tools::Rule {
2003 permission: r["permission"].as_str()?.to_string(),
2004 pattern: r["pattern"].as_str()?.to_string(),
2005 action: r["action"].as_str()?.to_string(),
2006 })
2007 })
2008 .collect()
2009 })
2010 .unwrap_or_default();
2011 let model_limit = st
2012 .llm
2013 .read()
2014 .limits
2015 .get(&(pid.clone(), mid.clone()))
2016 .cloned()
2017 .unwrap_or(serde_json::Value::Null);
2018 let compaction = {
2019 let payloads = st.payloads.read();
2020 ocserve_core::compact::CompactionCfg::from_value(Some(&payloads.compaction))
2021 };
2022 let compaction_system = st
2023 .llm
2024 .read()
2025 .systems
2026 .get("compaction")
2027 .cloned()
2028 .unwrap_or_else(|| system.clone());
2029 let ctx = ocserve_core::prompt::PromptContext {
2030 db: st.db.clone(),
2031 blobs: st.blobs.clone(),
2032 bus: st.bus.clone(),
2033 directory: st.paths["directory"].as_str().unwrap_or("/").to_string(),
2034 agent: agent.clone(),
2035 system,
2036 endpoint: ocserve_core::prompt::LlmEndpoint {
2037 base_url,
2038 api_key,
2039 pricing,
2040 },
2041 model_id: mid,
2042 provider_id: pid,
2043 rules,
2044 gate: st.gate.clone(),
2045 mcp: st.mcp.get().cloned(),
2046 plugins: st.plugins.get().cloned(),
2047 questions: st.question_gate.clone(),
2048 model_limit,
2049 max_rounds: st.max_rounds,
2050 cost_ceiling: st.cost_ceiling,
2051 compaction,
2052 compaction_system,
2053 };
2054 Ok(ctx)
2055}
2056
2057fn fmt_js(v: f64) -> String {
2062 if v.is_finite() && v.abs() >= 1e21 {
2063 let raw = format!("{v:e}"); if let Some(pos) = raw.find('e') {
2065 return format!("{}e+{}", &raw[..pos], &raw[pos + 1..]);
2066 }
2067 raw
2068 } else {
2069 format!("{v}")
2070 }
2071}
2072
2073fn parse_limit(raw: &str) -> Result<u64, String> {
2081 const INT_MSG: &str = "Expected an integer, got {}\n at [\"limit\"]";
2082 match raw.parse::<f64>() {
2083 Err(_) => Err(INT_MSG.replace("{}", "NaN")),
2084 Ok(v) if v.is_nan() => Err(INT_MSG.replace("{}", "NaN")),
2085 Ok(v) if v.is_infinite() => {
2086 Err(INT_MSG.replace("{}", if v > 0.0 { "Infinity" } else { "-Infinity" }))
2087 }
2088 Ok(v) if v.trunc() != v => Err(INT_MSG.replace("{}", &fmt_js(v))),
2089 Ok(v) if v.abs() > 9007199254740991.0 => Err(INT_MSG.replace("{}", &fmt_js(v))),
2090 Ok(v) if v < 0.0 => Err(format!(
2091 "Expected a value greater than or equal to 0, got {}\n at [\"limit\"]",
2092 fmt_js(v)
2093 )),
2094 Ok(v) => Ok(v as u64),
2095 }
2096}
2097
2098fn tagged_bad_request() -> HttpError {
2099 HttpError::Tagged {
2100 status: StatusCode::BAD_REQUEST,
2101 tag: "BadRequest",
2102 }
2103}
2104
2105struct LockRelease {
2112 locks: std::sync::Arc<
2113 parking_lot::Mutex<
2114 std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
2115 >,
2116 >,
2117 sid: String,
2118 lock: std::sync::Arc<tokio::sync::Mutex<()>>,
2119}
2120
2121impl LockRelease {
2122 fn arc(&self) -> &std::sync::Arc<tokio::sync::Mutex<()>> {
2123 &self.lock
2124 }
2125}
2126
2127impl Drop for LockRelease {
2128 fn drop(&mut self) {
2129 let mut map = self.locks.lock();
2134 if let Some(arc) = map.get(&self.sid)
2135 && std::sync::Arc::strong_count(arc) <= 2
2136 {
2137 map.remove(&self.sid);
2138 }
2139 }
2140}
2141
2142async fn lock_session(st: &Arc<AppState>, sid: &str) -> Result<LockRelease, ApiError> {
2148 let lock = {
2149 let mut map = st.prompt_locks.lock();
2150 if map.len() >= 64 && !map.contains_key(sid) {
2151 return Err(ApiError {
2152 status: StatusCode::SERVICE_UNAVAILABLE,
2153 name: "ServiceUnavailableError",
2154 message: "too many concurrent prompts".into(),
2155 });
2156 }
2157 map.entry(sid.to_string())
2158 .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
2159 .clone()
2160 };
2161 Ok(LockRelease {
2162 locks: std::sync::Arc::clone(&st.prompt_locks),
2163 sid: sid.to_string(),
2164 lock,
2165 })
2166}
2167
2168fn prompt_err(e: anyhow::Error) -> ApiError {
2169 let msg = format!("{e:#}");
2170 if msg.starts_with("Session not found") {
2171 ApiError::not_found(msg)
2172 } else {
2173 ApiError {
2174 status: StatusCode::INTERNAL_SERVER_ERROR,
2175 name: "InternalError",
2176 message: msg,
2177 }
2178 }
2179}
2180
2181async fn post_message(
2182 State(st): State<Arc<AppState>>,
2183 axum::extract::Path(id): axum::extract::Path<String>,
2184 Json(payload): Json<Value>,
2185) -> Result<impl IntoResponse, ApiError> {
2186 let ctx = build_prompt_context(&st, &payload, &id)?;
2187 let _release = lock_session(&st, &id).await?;
2188 let _guard = _release.arc().lock().await;
2189 let writer = st.writer.clone();
2190 let result = ocserve_core::prompt::run_prompt(&ctx, &writer, &id, &payload)
2191 .await
2192 .map_err(prompt_err)?;
2193 drop(_guard);
2194 let (info, parts) = result;
2195 Ok(Json(json!({"info": info, "parts": parts})))
2196}
2197
2198async fn post_prompt_async(
2204 State(st): State<Arc<AppState>>,
2205 axum::extract::Path(id): axum::extract::Path<String>,
2206 Json(payload): Json<Value>,
2207) -> Result<StatusCode, ApiError> {
2208 let exists_id = id.clone();
2209 if !run_blocking(&st.db, move |db| {
2210 ocserve_store::session_exists(db, &exists_id)
2211 })
2212 .await
2213 .map_err(|e| ApiError {
2214 status: StatusCode::INTERNAL_SERVER_ERROR,
2215 name: "InternalError",
2216 message: format!("{e:#}"),
2217 })? {
2218 return Err(ApiError::not_found(format!("Session not found: {id}")));
2219 }
2220 let ctx = build_prompt_context(&st, &payload, &id)?;
2221 let release = lock_session(&st, &id).await?;
2225 let writer = st.writer.clone();
2226 let sid = id.clone();
2227 let generation = st
2228 .prompt_gen
2229 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2230 let tasks = st.prompt_tasks.clone();
2231 let tasks_insert = st.prompt_tasks.clone();
2232 let handle = tokio::spawn(async move {
2233 let _release = release;
2234 let guard = _release.arc().lock().await;
2235 match ocserve_core::prompt::run_prompt(&ctx, &writer, &sid, &payload).await {
2236 Ok(_) => {}
2237 Err(e) => tracing::error!("prompt_async failed session={sid}: {e:#}"),
2238 }
2239 drop(guard);
2240 let mut t = tasks.lock();
2241 if let Some((g, _)) = t.get(&sid)
2242 && *g == generation
2243 {
2244 t.remove(&sid);
2245 }
2246 });
2247 tasks_insert.lock().insert(id.clone(), (generation, handle));
2248 Ok(StatusCode::NO_CONTENT)
2249}
2250
2251async fn get_children(
2254 State(st): State<Arc<AppState>>,
2255 axum::extract::Path(id): axum::extract::Path<String>,
2256) -> Result<Json<Value>, ApiError> {
2257 run_blocking(&st.db, move |db| ocserve_store::load_children(db, &id))
2258 .await
2259 .map(|v| Json(Value::Array(v)))
2260 .map_err(|e| ApiError {
2261 status: StatusCode::INTERNAL_SERVER_ERROR,
2262 name: "InternalError",
2263 message: format!("{e:#}"),
2264 })
2265}
2266
2267async fn get_todos(
2268 State(st): State<Arc<AppState>>,
2269 axum::extract::Path(id): axum::extract::Path<String>,
2270) -> Result<Json<Value>, ApiError> {
2271 run_blocking(&st.db, move |db| ocserve_store::load_todos(db, &id))
2272 .await
2273 .map(|v| Json(Value::Array(v)))
2274 .map_err(|e| ApiError {
2275 status: StatusCode::INTERNAL_SERVER_ERROR,
2276 name: "InternalError",
2277 message: format!("{e:#}"),
2278 })
2279}
2280
2281async fn patch_session(
2285 State(st): State<Arc<AppState>>,
2286 axum::extract::Path(id): axum::extract::Path<String>,
2287 Json(payload): Json<Value>,
2288) -> Result<Json<Value>, ApiError> {
2289 let exists_id = id.clone();
2290 if !run_blocking(&st.db, move |db| {
2291 ocserve_store::session_exists(db, &exists_id)
2292 })
2293 .await
2294 .map_err(|e| ApiError {
2295 status: StatusCode::INTERNAL_SERVER_ERROR,
2296 name: "InternalError",
2297 message: format!("{e:#}"),
2298 })? {
2299 return Err(ApiError::not_found(format!("Session not found: {id}")));
2300 }
2301 let mut dirty = false;
2302 if let Some(title) = payload.get("title").and_then(|t| t.as_str()) {
2303 ocserve_store::update_session_title(&st.writer, &id, title).map_err(|e| ApiError {
2304 status: StatusCode::INTERNAL_SERVER_ERROR,
2305 name: "InternalError",
2306 message: format!("{e:#}"),
2307 })?;
2308 dirty = true;
2309 }
2310 if payload.get("model").is_some() {
2315 let raw = payload.get("model").cloned().unwrap_or(Value::Null);
2316 let model_json: Value = match raw {
2317 Value::Null => Value::Null, Value::Object(ref m) => {
2319 let ok = m
2320 .get("id")
2321 .and_then(|v| v.as_str())
2322 .is_some_and(|v| !v.is_empty())
2323 && m.get("providerID")
2324 .and_then(|v| v.as_str())
2325 .is_some_and(|v| !v.is_empty());
2326 if !ok {
2327 return Err(ApiError {
2328 status: StatusCode::BAD_REQUEST,
2329 name: "BadRequest",
2330 message: "model must be {id, providerID, variant?} or null".into(),
2331 });
2332 }
2333 Value::String(
2334 serde_json::to_string(&json!({
2335 "id": m["id"],
2336 "providerID": m["providerID"],
2337 "variant": m.get("variant").cloned().unwrap_or(json!("default")),
2338 }))
2339 .map_err(|e| ApiError {
2340 status: StatusCode::INTERNAL_SERVER_ERROR,
2341 name: "InternalError",
2342 message: format!("{e:#}"),
2343 })?,
2344 )
2345 }
2346 _ => {
2347 return Err(ApiError {
2348 status: StatusCode::BAD_REQUEST,
2349 name: "BadRequest",
2350 message: "model must be {id, providerID, variant?} or null".into(),
2351 });
2352 }
2353 };
2354 st.writer
2355 .write(vec![ocserve_store::WriteOp::Sql {
2356 sql: "UPDATE session SET model = ?2 WHERE id = ?1".into(),
2357 params: vec![id.clone().into(), model_json],
2358 }])
2359 .map_err(|e| ApiError {
2360 status: StatusCode::INTERNAL_SERVER_ERROR,
2361 name: "InternalError",
2362 message: format!("{e:#}"),
2363 })?;
2364 dirty = true;
2365 }
2366 if payload.get("agent").is_some() {
2367 let agent = match payload.get("agent") {
2368 Some(Value::Null) => String::new(),
2369 Some(Value::String(a)) if !a.is_empty() => a.clone(),
2370 _ => {
2371 return Err(ApiError {
2372 status: StatusCode::BAD_REQUEST,
2373 name: "BadRequest",
2374 message: "agent must be a non-empty string or null".into(),
2375 });
2376 }
2377 };
2378 st.writer
2379 .write(vec![ocserve_store::WriteOp::Sql {
2380 sql: "UPDATE session SET agent = ?2 WHERE id = ?1".into(),
2381 params: vec![id.clone().into(), agent.into()],
2382 }])
2383 .map_err(|e| ApiError {
2384 status: StatusCode::INTERNAL_SERVER_ERROR,
2385 name: "InternalError",
2386 message: format!("{e:#}"),
2387 })?;
2388 dirty = true;
2389 }
2390 let info_id = id.clone();
2391 let info = run_blocking(&st.db, move |db| {
2392 ocserve_store::load_session_wire(db, &info_id)
2393 })
2394 .await
2395 .map_err(|e| ApiError {
2396 status: StatusCode::INTERNAL_SERVER_ERROR,
2397 name: "InternalError",
2398 message: format!("{e:#}"),
2399 })?
2400 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))?;
2401 if dirty {
2402 st.bus.publish(ocserve_core::event::frame(
2405 st.paths["directory"].as_str().unwrap_or("/"),
2406 "session.updated",
2407 json!({"sessionID": id, "info": {"id": id, "title": info["title"],
2408 "model": info["model"], "agent": info["agent"]}}),
2409 ));
2410 }
2411 Ok(Json(info))
2412}
2413
2414async fn delete_session_route(
2417 State(st): State<Arc<AppState>>,
2418 axum::extract::Path(id): axum::extract::Path<String>,
2419) -> Result<Json<Value>, ApiError> {
2420 if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
2421 handle.abort();
2422 }
2423 let found = ocserve_store::delete_session(&st.writer, &st.db, &id).map_err(|e| ApiError {
2424 status: StatusCode::INTERNAL_SERVER_ERROR,
2425 name: "InternalError",
2426 message: format!("{e:#}"),
2427 })?;
2428 if !found {
2429 return Err(ApiError::not_found(format!("Session not found: {id}")));
2430 }
2431 st.bus.publish(ocserve_core::event::frame(
2434 st.paths["directory"].as_str().unwrap_or("/"),
2435 "session.deleted",
2436 json!({"sessionID": id}),
2437 ));
2438 Ok(Json(json!(true)))
2439}
2440
2441async fn post_abort(
2443 State(st): State<Arc<AppState>>,
2444 axum::extract::Path(id): axum::extract::Path<String>,
2445) -> Result<Json<Value>, ApiError> {
2446 if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
2447 handle.abort();
2448 tracing::info!("abort: cancelled prompt task for {id}");
2449 }
2450 Ok(Json(json!(true)))
2451}
2452
2453async fn delete_message_route(
2454 State(st): State<Arc<AppState>>,
2455 axum::extract::Path((sid, mid)): axum::extract::Path<(String, String)>,
2456) -> Result<Json<Value>, ApiError> {
2457 let found =
2458 ocserve_store::delete_message(&st.writer, &st.db, &sid, &mid).map_err(|e| ApiError {
2459 status: StatusCode::INTERNAL_SERVER_ERROR,
2460 name: "InternalError",
2461 message: format!("{e:#}"),
2462 })?;
2463 if !found {
2464 return Err(ApiError::not_found(format!("Message not found: {mid}")));
2465 }
2466 st.bus.publish(ocserve_core::event::frame(
2469 st.paths["directory"].as_str().unwrap_or("/"),
2470 "message.removed",
2471 json!({"sessionID": sid, "messageID": mid}),
2472 ));
2473 Ok(Json(json!(true)))
2474}
2475
2476async fn delete_part_route(
2477 State(st): State<Arc<AppState>>,
2478 axum::extract::Path((_sid, mid, pid)): axum::extract::Path<(String, String, String)>,
2479) -> Result<Json<Value>, ApiError> {
2480 ocserve_store::delete_part(&st.writer, &st.db, &mid, &pid)
2481 .map_err(|e| ApiError {
2482 status: StatusCode::INTERNAL_SERVER_ERROR,
2483 name: "InternalError",
2484 message: format!("{e:#}"),
2485 })?
2486 .then_some(Json(json!(true)))
2487 .ok_or_else(|| ApiError::not_found(format!("Part not found: {pid}")))
2488}
2489
2490async fn patch_part_route(
2493 State(st): State<Arc<AppState>>,
2494 axum::extract::Path((sid, mid, pid)): axum::extract::Path<(String, String, String)>,
2495 Json(part): Json<Value>,
2496) -> Result<Json<Value>, ApiError> {
2497 let body_sid = part.get("sessionID").and_then(|v| v.as_str()).unwrap_or("");
2498 let body_mid = part.get("messageID").and_then(|v| v.as_str()).unwrap_or("");
2499 let body_pid = part.get("id").and_then(|v| v.as_str()).unwrap_or("");
2500 if body_sid != sid || body_mid != mid || body_pid != pid {
2501 return Err(ApiError {
2502 status: StatusCode::BAD_REQUEST,
2503 name: "BadRequest",
2504 message: "part id/sessionID/messageID must match the path".into(),
2505 });
2506 }
2507 ocserve_store::update_part(&st.writer, &st.blobs, &sid, &mid, &pid, &part)
2508 .map_err(|e| ApiError {
2509 status: StatusCode::INTERNAL_SERVER_ERROR,
2510 name: "InternalError",
2511 message: format!("{e:#}"),
2512 })?
2513 .then_some(Json(part))
2514 .ok_or_else(|| ApiError::not_found(format!("Part not found: {pid}")))
2515}
2516
2517fn scope_path(base: &std::path::Path, raw: &str) -> Result<std::path::PathBuf, ApiError> {
2520 let joined = if raw.is_empty() || raw == "." {
2521 base.to_path_buf()
2522 } else {
2523 let p = std::path::Path::new(raw);
2524 if p.is_absolute() {
2525 p.to_path_buf()
2526 } else {
2527 base.join(p)
2528 }
2529 };
2530 let canon_base = std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
2531 let canon = std::fs::canonicalize(&joined).unwrap_or(joined.clone());
2532 if !canon.starts_with(&canon_base) {
2533 return Err(ApiError {
2534 status: StatusCode::BAD_REQUEST,
2535 name: "BadRequest",
2536 message: format!("path escapes the session directory: {raw}"),
2537 });
2538 }
2539 Ok(canon)
2540}
2541
2542async fn list_directory(
2544 State(st): State<Arc<AppState>>,
2545 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2546) -> Result<Json<Value>, ApiError> {
2547 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
2548 let raw = q.get("path").map(String::as_str).unwrap_or(".");
2549 let dir = scope_path(base, raw)?;
2550 let dir_for_worker = dir.clone();
2554 let raw_owned = raw.to_string();
2555 let rd = tokio::task::spawn_blocking(move || std::fs::read_dir(&dir_for_worker))
2556 .await
2557 .map_err(|e| ApiError {
2558 status: StatusCode::INTERNAL_SERVER_ERROR,
2559 name: "InternalServerError",
2560 message: format!("list worker: {e}"),
2561 })?
2562 .map_err(|e| ApiError {
2563 status: StatusCode::BAD_REQUEST,
2564 name: "BadRequest",
2565 message: format!("list {}: {e}", dir.display()),
2566 })?;
2567 let mut nodes: Vec<Value> = Vec::new();
2568 for entry in rd.flatten() {
2569 let name = entry.file_name().to_string_lossy().to_string();
2570 let path = entry.path();
2571 let meta = entry.metadata().ok();
2572 let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
2573 let rel = if raw_owned.is_empty() || raw_owned == "." {
2574 name.clone()
2575 } else {
2576 format!("{}/{}", raw_owned.trim_end_matches('/'), name)
2577 };
2578 let ignored =
2579 name.starts_with('.') || name == "node_modules" || name == "target" || name == ".git";
2580 nodes.push(json!({
2581 "name": name,
2582 "path": rel,
2583 "type": if is_dir { "directory" } else { "file" },
2584 "absolute": path.to_string_lossy(),
2585 "ignored": ignored,
2586 "size": meta.as_ref().filter(|m| m.is_file()).map(|m| m.len()),
2587 "modified": meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
2588 t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as i64)
2589 }),
2590 }));
2591 }
2592 nodes.sort_by(|a, b| {
2594 let da = a["type"] == "directory";
2595 let db = b["type"] == "directory";
2596 db.cmp(&da)
2597 .then_with(|| a["name"].as_str().cmp(&b["name"].as_str()))
2598 });
2599 nodes.truncate(5000);
2600 Ok(Json(Value::Array(nodes)))
2601}
2602
2603async fn read_file_content(
2605 State(st): State<Arc<AppState>>,
2606 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2607) -> Result<Json<Value>, ApiError> {
2608 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
2609 let raw = q.get("path").map(String::as_str).ok_or_else(|| ApiError {
2610 status: StatusCode::BAD_REQUEST,
2611 name: "BadRequest",
2612 message: "missing path".into(),
2613 })?;
2614 let path = scope_path(base, raw)?;
2615 let bytes = std::fs::read(&path).map_err(|e| ApiError {
2616 status: StatusCode::BAD_REQUEST,
2617 name: "BadRequest",
2618 message: format!("read {}: {e}", path.display()),
2619 })?;
2620 let truncated = bytes.len() > 1024 * 1024;
2622 let bytes = if truncated {
2623 &bytes[..1024 * 1024]
2624 } else {
2625 &bytes[..]
2626 };
2627 let (kind, content) = match std::str::from_utf8(bytes) {
2628 Ok(text) => ("text", text.to_string()),
2629 Err(_) => ("binary", String::from_utf8_lossy(bytes).into_owned()),
2630 };
2631 Ok(Json(json!({
2632 "type": kind,
2633 "content": content,
2634 "encoding": "utf-8",
2635 })))
2636}
2637
2638async fn find_files(
2641 State(st): State<Arc<AppState>>,
2642 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2643) -> Result<Json<Value>, ApiError> {
2644 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
2645 let query = q
2646 .get("query")
2647 .map(String::as_str)
2648 .unwrap_or("")
2649 .to_lowercase();
2650 if query.is_empty() {
2651 return Ok(Json(json!([])));
2652 }
2653 let want_type = q.get("type").map(String::as_str).unwrap_or("").to_string();
2654 let limit: usize = q
2655 .get("limit")
2656 .and_then(|l| l.parse().ok())
2657 .unwrap_or(50)
2658 .clamp(1, 200);
2659 let glob_mode = query.contains('*') || query.contains('?');
2660 let out = tokio::task::spawn_blocking(move || {
2667 walk_files(&base, &query, &want_type, limit, glob_mode)
2668 })
2669 .await
2670 .map_err(|e| ApiError {
2671 status: StatusCode::INTERNAL_SERVER_ERROR,
2672 name: "InternalServerError",
2673 message: format!("find_files worker: {e}"),
2674 })?
2675 .map_err(|e| ApiError {
2676 status: StatusCode::BAD_REQUEST,
2677 name: "BadRequest",
2678 message: format!("find_files: {e:#}"),
2679 })?;
2680 Ok(Json(Value::Array(
2681 out.into_iter().map(Value::String).collect(),
2682 )))
2683}
2684
2685static WALK_THREAD: std::sync::Mutex<Option<std::thread::ThreadId>> = std::sync::Mutex::new(None);
2692
2693fn walk_files(
2697 base: &std::path::Path,
2698 query: &str,
2699 want_type: &str,
2700 limit: usize,
2701 glob_mode: bool,
2702) -> anyhow::Result<Vec<String>> {
2703 if let Ok(mut g) = WALK_THREAD.lock() {
2710 *g = Some(std::thread::current().id());
2711 }
2712 let mut out: Vec<String> = Vec::new();
2713 let mut stack = vec![(base.to_path_buf(), String::new())];
2714 let mut visited = 0usize;
2715 while let Some((dir, rel)) = stack.pop() {
2716 visited += 1;
2717 if visited > 20_000 || out.len() >= limit * 4 {
2718 break; }
2720 let Ok(rd) = std::fs::read_dir(&dir) else {
2721 continue;
2722 };
2723 for entry in rd.flatten() {
2724 let name = entry.file_name().to_string_lossy().to_string();
2725 if name == ".git" || name == "node_modules" {
2726 continue;
2727 }
2728 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
2729 let child_rel = if rel.is_empty() {
2730 name.clone()
2731 } else {
2732 format!("{rel}/{name}")
2733 };
2734 let matched = if glob_mode {
2735 ocserve_tools::wildcard_match(&name, query)
2736 } else {
2737 child_rel.to_lowercase().contains(query)
2738 };
2739 if matched
2740 && (want_type.is_empty()
2741 || (want_type == "dir" && is_dir)
2742 || (want_type == "file" && !is_dir))
2743 {
2744 out.push(child_rel.clone());
2745 }
2746 if is_dir {
2747 stack.push((entry.path(), child_rel));
2748 }
2749 }
2750 }
2751 out.sort_by(|a, b| {
2753 let pa = a.to_lowercase().starts_with(query);
2754 let pb = b.to_lowercase().starts_with(query);
2755 pb.cmp(&pa).then_with(|| a.cmp(b))
2756 });
2757 out.truncate(limit);
2758 Ok(out)
2759}
2760
2761async fn find_text(
2766 State(st): State<Arc<AppState>>,
2767 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2768) -> Result<Json<Value>, ApiError> {
2769 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
2770 let pattern = q.get("pattern").map(String::as_str).unwrap_or("");
2771 if pattern.is_empty() {
2772 return Ok(Json(json!([])));
2773 }
2774 let out = std::process::Command::new("grep")
2775 .args([
2776 "-rnE",
2777 "--binary-files=without-match",
2778 "-m",
2779 "50",
2780 "--",
2781 pattern,
2782 ])
2783 .arg(base)
2784 .env("LC_ALL", "C")
2785 .output();
2786 let out = match out {
2787 Ok(o) => o,
2788 Err(e) => {
2789 return Err(ApiError {
2790 status: StatusCode::INTERNAL_SERVER_ERROR,
2791 name: "InternalError",
2792 message: format!("grep spawn: {e}"),
2793 });
2794 }
2795 };
2796 if out.status.code().unwrap_or(0) > 1 {
2798 return Err(ApiError {
2799 status: StatusCode::BAD_REQUEST,
2800 name: "BadRequest",
2801 message: "invalid pattern".into(),
2802 });
2803 }
2804 let mut matches: Vec<Value> = Vec::new();
2805 for line in String::from_utf8_lossy(&out.stdout).lines().take(200) {
2806 let (p1, rest) = match line.split_once(':') {
2808 Some(x) => x,
2809 None => continue,
2810 };
2811 let (ln, text) = match rest.split_once(':') {
2812 Some(x) => x,
2813 None => continue,
2814 };
2815 let ln: i64 = ln.parse().unwrap_or(0);
2816 let rel = std::path::Path::new(p1)
2817 .strip_prefix(base)
2818 .map(|r| r.to_string_lossy().to_string())
2819 .unwrap_or_else(|_| p1.to_string());
2820 matches.push(json!({
2821 "path": rel,
2822 "lines": text,
2823 "lineNumber": ln,
2824 "absoluteOffset": 0,
2825 }));
2826 }
2827 Ok(Json(Value::Array(matches)))
2828}
2829
2830async fn get_questions(State(st): State<Arc<AppState>>) -> Json<Value> {
2833 Json(Value::Array(st.question_gate.list()))
2834}
2835
2836async fn post_question_reply(
2838 State(st): State<Arc<AppState>>,
2839 axum::extract::Path(id): axum::extract::Path<String>,
2840 Json(body): Json<Value>,
2841) -> Result<Json<Value>, ApiError> {
2842 let raw = body
2843 .get("answers")
2844 .and_then(|a| a.as_array())
2845 .ok_or_else(|| ApiError {
2846 status: StatusCode::BAD_REQUEST,
2847 name: "BadRequest",
2848 message: "body must be {answers: [[String]]}".into(),
2849 })?;
2850 let mut answers: Vec<Vec<String>> = Vec::with_capacity(raw.len());
2851 for a in raw {
2852 let row = a.as_array().ok_or_else(|| ApiError {
2853 status: StatusCode::BAD_REQUEST,
2854 name: "BadRequest",
2855 message: "each answer must be an array of labels".into(),
2856 })?;
2857 answers.push(
2858 row.iter()
2859 .map(|l| l.as_str().unwrap_or_default().to_string())
2860 .collect(),
2861 );
2862 }
2863 if st.question_gate.reply(&id, answers) {
2864 Ok(Json(json!(true)))
2865 } else {
2866 Err(ApiError::not_found(format!("Question not found: {id}")))
2867 }
2868}
2869
2870async fn post_question_reject(
2872 State(st): State<Arc<AppState>>,
2873 axum::extract::Path(id): axum::extract::Path<String>,
2874) -> Result<Json<Value>, ApiError> {
2875 if st.question_gate.reject(&id) {
2876 Ok(Json(json!(true)))
2877 } else {
2878 Err(ApiError::not_found(format!("Question not found: {id}")))
2879 }
2880}
2881
2882pub fn split_command_args(arguments: &str) -> Vec<String> {
2887 let bytes = arguments.as_bytes();
2888 let mut out = Vec::new();
2889 let mut i = 0;
2890 while i < bytes.len() {
2891 let c = bytes[i];
2892 if c.is_ascii_whitespace() {
2893 i += 1;
2894 continue;
2895 }
2896 let (tok, next) = if c == b'"' || c == b'\'' {
2897 let quote = c;
2898 let mut j = i + 1;
2899 while j < bytes.len() && bytes[j] != quote {
2900 j += 1;
2901 }
2902 let end = (j + 1).min(bytes.len());
2903 (
2904 std::str::from_utf8(&bytes[i..end])
2905 .unwrap_or("")
2906 .to_string(),
2907 end,
2908 )
2909 } else {
2910 let mut j = i;
2911 while j < bytes.len() && !bytes[j].is_ascii_whitespace() {
2912 j += 1;
2913 }
2914 (
2915 std::str::from_utf8(&bytes[i..j]).unwrap_or("").to_string(),
2916 j,
2917 )
2918 };
2919 let mut t = tok.as_str();
2921 if t.starts_with(['"', '\'']) {
2922 t = &t[1..];
2923 }
2924 if t.ends_with(['"', '\'']) && !t.is_empty() {
2925 t = &t[..t.len() - 1];
2926 }
2927 out.push(t.to_string());
2928 i = next;
2929 }
2930 out
2931}
2932
2933pub fn expand_command(template: &str, arguments: &str) -> String {
2938 let args = split_command_args(arguments);
2939 let mut last = 0usize;
2941 let b = template.as_bytes();
2942 let mut i = 0;
2943 while i + 1 < b.len() {
2944 if b[i] == b'$' && b[i + 1].is_ascii_digit() {
2945 let mut j = i + 1;
2946 while j < b.len() && b[j].is_ascii_digit() {
2947 j += 1;
2948 }
2949 if let Some(n) = std::str::from_utf8(&b[i + 1..j])
2950 .ok()
2951 .and_then(|d| d.parse::<usize>().ok())
2952 {
2953 last = last.max(n);
2954 }
2955 i = j;
2956 } else {
2957 i += 1;
2958 }
2959 }
2960 let mut with_args = String::new();
2962 let mut i = 0;
2963 let mut had_placeholder = false;
2964 while i < b.len() {
2965 if b[i] == b'$' && i + 1 < b.len() && b[i + 1].is_ascii_digit() {
2966 let mut j = i + 1;
2967 while j < b.len() && b[j].is_ascii_digit() {
2968 j += 1;
2969 }
2970 let n: usize = std::str::from_utf8(&b[i + 1..j])
2971 .unwrap_or("0")
2972 .parse()
2973 .unwrap_or(0);
2974 had_placeholder = true;
2975 let arg_index = n.saturating_sub(1);
2976 let value = if arg_index >= args.len() {
2977 String::new()
2978 } else if n == last {
2979 args[arg_index..].join(" ")
2980 } else {
2981 args[arg_index].clone()
2982 };
2983 with_args.push_str(&value);
2984 i = j;
2985 } else {
2986 let ch_len = {
2988 let ch = template[i..].chars().next().unwrap_or(' ');
2989 ch.len_utf8()
2990 };
2991 with_args.push_str(&template[i..i + ch_len]);
2992 i += ch_len;
2993 }
2994 }
2995 let uses_arguments = template.contains("$ARGUMENTS");
2996 let mut expanded = with_args.replace("$ARGUMENTS", arguments);
2997 if !had_placeholder && !uses_arguments && !arguments.trim().is_empty() {
2998 expanded = format!("{expanded}\n\n{arguments}");
2999 }
3000 expanded.trim().to_string()
3001}
3002
3003fn emit_session_error_event(st: &Arc<AppState>, sid: &str, message: &str) {
3006 let props = json!({
3007 "sessionID": sid,
3008 "error": {"name": "UnknownError", "data": {"message": message}},
3009 });
3010 if let Err(e) = ocserve_store::append_event(&st.writer, Some(sid), "session.error", &props) {
3011 tracing::warn!("session.error persist failed: {e:#}");
3012 }
3013 let seq = ocserve_store::next_event_seq(&st.db, sid).unwrap_or(0);
3014 let dir = st.paths["directory"].as_str().unwrap_or("/");
3015 st.bus.publish(ocserve_core::event::frame(
3016 dir,
3017 "session.error",
3018 props.clone(),
3019 ));
3020 st.bus.publish(ocserve_core::event::sync_frame(
3021 dir,
3022 "session.error",
3023 props,
3024 seq as u64,
3025 sid,
3026 ));
3027}
3028
3029fn session_busy(id: &str) -> ApiError {
3032 ApiError {
3033 status: StatusCode::CONFLICT,
3034 name: "SessionBusyError",
3035 message: format!("Session is busy: {id}"),
3036 }
3037}
3038
3039async fn post_command(
3043 State(st): State<Arc<AppState>>,
3044 axum::extract::Path(id): axum::extract::Path<String>,
3045 Json(payload): Json<Value>,
3046) -> Result<Json<Value>, ApiError> {
3047 let exists_id = id.clone();
3048 if !run_blocking(&st.db, move |db| {
3049 ocserve_store::session_exists(db, &exists_id)
3050 })
3051 .await
3052 .map_err(|e| ApiError {
3053 status: StatusCode::INTERNAL_SERVER_ERROR,
3054 name: "InternalError",
3055 message: format!("{e:#}"),
3056 })? {
3057 return Err(ApiError::not_found(format!("Session not found: {id}")));
3058 }
3059 let name = payload
3060 .get("command")
3061 .and_then(|c| c.as_str())
3062 .unwrap_or("");
3063 let arguments = payload
3064 .get("arguments")
3065 .and_then(|a| a.as_str())
3066 .unwrap_or("");
3067 let (template, available_owned): (Option<String>, Vec<String>) = {
3070 let payloads = st.payloads.read();
3071 let t = payloads
3072 .command
3073 .iter()
3074 .find(|c| c["name"].as_str() == Some(name))
3075 .and_then(|c| c["template"].as_str())
3076 .map(str::to_string);
3077 let names = payloads
3078 .command
3079 .iter()
3080 .filter_map(|c| c["name"].as_str().map(str::to_string))
3081 .collect();
3082 (t, names)
3083 };
3084 let available: Vec<&str> = available_owned.iter().map(String::as_str).collect();
3085 let Some(template) = template.as_deref() else {
3086 let msg = format!(
3087 "Command not found: \"{name}\".{}",
3088 if available.is_empty() {
3089 String::new()
3090 } else {
3091 format!(" Available commands: {}", available.join(", "))
3092 }
3093 );
3094 emit_session_error_event(&st, &id, &msg);
3095 return Err(ApiError {
3096 status: StatusCode::BAD_REQUEST,
3097 name: "BadRequest",
3098 message: msg,
3099 });
3100 };
3101 let expanded = expand_command(template, arguments);
3102 let mut cmd_payload = json!({
3103 "parts": [{"type": "text", "text": expanded}],
3104 });
3105 if let Some(a) = payload.get("agent").and_then(|a| a.as_str()) {
3106 cmd_payload["agent"] = json!(a);
3107 }
3108 if let Some(m) = payload.get("model") {
3109 cmd_payload["model"] = m.clone();
3110 }
3111 if let Some(mid) = payload.get("messageID").and_then(|m| m.as_str()) {
3112 cmd_payload["messageId"] = json!(mid);
3113 }
3114 let parts_out = ocserve_core::prompt::hook_mutate(
3118 st.plugins.get(),
3119 "command.execute.before",
3120 json!({
3121 "command": name,
3122 "sessionID": id,
3123 "arguments": arguments,
3124 }),
3125 json!({"parts": cmd_payload["parts"].clone()}),
3126 )
3127 .await;
3128 if let Some(p) = parts_out.get("parts").and_then(|p| p.as_array()) {
3129 cmd_payload["parts"] = Value::Array(p.clone());
3130 }
3131
3132 let ctx = build_prompt_context(&st, &cmd_payload, &id)?;
3133 let _release = lock_session(&st, &id).await?;
3134 let Ok(_guard) = _release.arc().try_lock() else {
3135 return Err(session_busy(&id));
3136 };
3137 let writer = st.writer.clone();
3138 let (info, parts) = ocserve_core::prompt::run_prompt_with(
3139 &ctx,
3140 &writer,
3141 &id,
3142 &cmd_payload,
3143 ocserve_core::prompt::RunOpts {
3144 auto_title: false,
3146 ..Default::default()
3147 },
3148 )
3149 .await
3150 .map_err(prompt_err)?;
3151 drop(_guard);
3152 Ok(Json(json!({"info": info, "parts": parts})))
3153}
3154
3155async fn post_shell(
3160 State(st): State<Arc<AppState>>,
3161 axum::extract::Path(id): axum::extract::Path<String>,
3162 Json(payload): Json<Value>,
3163) -> Result<Json<Value>, ApiError> {
3164 let exists_id = id.clone();
3165 if !run_blocking(&st.db, move |db| {
3166 ocserve_store::session_exists(db, &exists_id)
3167 })
3168 .await
3169 .map_err(|e| ApiError {
3170 status: StatusCode::INTERNAL_SERVER_ERROR,
3171 name: "InternalError",
3172 message: format!("{e:#}"),
3173 })? {
3174 return Err(ApiError::not_found(format!("Session not found: {id}")));
3175 }
3176 let command = payload
3177 .get("command")
3178 .and_then(|c| c.as_str())
3179 .ok_or_else(|| ApiError {
3180 status: StatusCode::BAD_REQUEST,
3181 name: "BadRequest",
3182 message: "command required".into(),
3183 })?;
3184 let agent = payload
3185 .get("agent")
3186 .and_then(|a| a.as_str())
3187 .ok_or_else(|| ApiError {
3188 status: StatusCode::BAD_REQUEST,
3189 name: "BadRequest",
3190 message: "agent required".into(),
3191 })?;
3192 let (pid, mid) = resolve_model(&st, &payload, &id);
3193
3194 let _release = lock_session(&st, &id).await?;
3195 let Ok(_guard) = _release.arc().try_lock() else {
3196 return Err(session_busy(&id));
3197 };
3198 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
3199
3200 let status_frame = |kind: &str| {
3202 ocserve_core::event::frame(
3203 &dir,
3204 "session.status",
3205 json!({"sessionID": id, "status": {"type": kind}}),
3206 )
3207 };
3208 st.bus.publish(status_frame("busy"));
3209
3210 let started = now_ms_local();
3211 let user_msg_id = ocserve_core::ids::msg_id();
3212 let user_part = json!({
3213 "type": "text", "id": ocserve_core::ids::prt_id(),
3214 "sessionID": id, "messageID": user_msg_id,
3215 "text": "The following tool was executed by the user",
3216 "synthetic": true,
3217 });
3218 let user_info = json!({
3219 "id": user_msg_id,
3220 "sessionID": id,
3221 "role": "user",
3222 "agent": agent,
3223 "model": {"providerID": pid, "modelID": mid},
3224 "time": {"created": started},
3225 "summary": {"diffs": []},
3226 });
3227 let assistant_id = ocserve_core::ids::msg_id();
3228 let call_id = ocserve_core::ids::evt_id();
3229 let mut tool_part = json!({
3230 "type": "tool", "id": ocserve_core::ids::prt_id(),
3231 "sessionID": id, "messageID": assistant_id,
3232 "callID": call_id, "tool": "bash",
3233 "state": {
3234 "status": "running",
3235 "input": {"command": command},
3236 "time": {"start": started},
3237 },
3238 });
3239
3240 let writer = st.writer.clone();
3242 insert_message_http(
3243 &writer,
3244 &st,
3245 &id,
3246 &user_info,
3247 std::slice::from_ref(&user_part),
3248 )?;
3249 let assistant_info = json!({
3250 "id": assistant_id,
3251 "parentID": user_msg_id,
3252 "role": "assistant",
3253 "mode": agent,
3254 "agent": agent,
3255 "path": {"cwd": dir, "root": "/"},
3256 "cost": 0.0,
3257 "tokens": {"total": 0, "input": 0, "output": 0, "reasoning": 0,
3258 "cache": {"write": 0, "read": 0}},
3259 "modelID": mid,
3260 "providerID": pid,
3261 "time": {"created": started, "completed": started},
3262 "finish": "stop",
3263 "id": assistant_id,
3264 "sessionID": id,
3265 });
3266 insert_message_http(&writer, &st, &id, &assistant_info, &[])?;
3267 emit_durable_http(
3268 &st,
3269 &id,
3270 "message.updated",
3271 json!({"sessionID": id, "info": user_info.clone()}),
3272 )?;
3273 emit_durable_http(
3274 &st,
3275 &id,
3276 "message.updated",
3277 json!({"sessionID": id, "info": assistant_info.clone()}),
3278 )?;
3279 emit_durable_http(
3280 &st,
3281 &id,
3282 "message.part.updated",
3283 json!({"sessionID": id, "part": user_part}),
3284 )?;
3285 emit_durable_http(
3286 &st,
3287 &id,
3288 "message.part.updated",
3289 json!({"sessionID": id, "part": tool_part.clone()}),
3290 )?;
3291
3292 let exec = {
3296 let cmd = json!({"command": command});
3297 let d = dir.clone();
3298 tokio::task::spawn_blocking(move || {
3299 ocserve_tools::execute("bash", &cmd, std::path::Path::new(&d))
3300 })
3301 .await
3302 .map_err(|e| ApiError {
3303 status: StatusCode::INTERNAL_SERVER_ERROR,
3304 name: "InternalError",
3305 message: format!("tool join: {e}"),
3306 })?
3307 };
3308 let (status, output) = match exec {
3309 Ok(r) => ("completed", r.output),
3310 Err(e) => ("error", format!("Error: {e:#}")),
3311 };
3312 let completed = now_ms_local();
3313 tool_part["state"] = json!({
3316 "status": status,
3317 "input": {"command": command},
3318 "output": output,
3319 "title": "",
3320 "metadata": {"output": ""},
3321 "time": {"start": started, "end": completed},
3322 });
3323 tool_part["state"]["metadata"]["output"] = tool_part["state"]["output"].clone();
3324 insert_message_http(
3325 &writer,
3326 &st,
3327 &id,
3328 &assistant_info,
3329 std::slice::from_ref(&tool_part),
3330 )?;
3331 emit_durable_http(
3332 &st,
3333 &id,
3334 "message.part.updated",
3335 json!({"sessionID": id, "part": tool_part.clone()}),
3336 )?;
3337 st.bus.publish(status_frame("idle"));
3338
3339 Ok(Json(json!({"info": assistant_info, "parts": [tool_part]})))
3340}
3341
3342fn now_ms_local() -> i64 {
3343 std::time::SystemTime::now()
3344 .duration_since(std::time::UNIX_EPOCH)
3345 .map(|d| d.as_millis() as i64)
3346 .unwrap_or(0)
3347}
3348
3349fn insert_message_http(
3351 writer: &ocserve_store::Writer,
3352 st: &Arc<AppState>,
3353 sid: &str,
3354 info: &Value,
3355 parts: &[Value],
3356) -> Result<(), ApiError> {
3357 ocserve_store::insert_message(writer, Some(&*st.blobs), sid, info, parts).map_err(|e| {
3358 ApiError {
3359 status: StatusCode::INTERNAL_SERVER_ERROR,
3360 name: "InternalError",
3361 message: format!("{e:#}"),
3362 }
3363 })
3364}
3365
3366fn emit_durable_http(
3369 st: &Arc<AppState>,
3370 sid: &str,
3371 event_type: &str,
3372 props: Value,
3373) -> Result<(), ApiError> {
3374 ocserve_store::append_event(&st.writer, Some(sid), event_type, &props).map_err(|e| {
3375 ApiError {
3376 status: StatusCode::INTERNAL_SERVER_ERROR,
3377 name: "InternalError",
3378 message: format!("{e:#}"),
3379 }
3380 })?;
3381 let seq = ocserve_store::next_event_seq(&st.db, sid).unwrap_or(0);
3382 let dir = st.paths["directory"].as_str().unwrap_or("/");
3383 st.bus
3384 .publish(ocserve_core::event::frame(dir, event_type, props.clone()));
3385 st.bus.publish(ocserve_core::event::sync_frame(
3386 dir, event_type, props, seq as u64, sid,
3387 ));
3388 Ok(())
3389}
3390
3391fn evt_id(kind: &str) -> String {
3395 use sha2::{Digest, Sha256};
3396 let nanos = std::time::SystemTime::now()
3397 .duration_since(std::time::UNIX_EPOCH)
3398 .map(|d| d.as_nanos())
3399 .unwrap_or(0);
3400 let mut h = Sha256::new();
3401 h.update(nanos.to_le_bytes());
3402 h.update(kind.as_bytes());
3403 let hex = hex::encode(h.finalize());
3404 format!("evt_{}", &hex[..26])
3405}
3406
3407async fn global_event(State(st): State<Arc<AppState>>) -> Response {
3415 let connected = Event::default().data(
3416 json!({"payload":{"id":evt_id("connected"),"type":"server.connected","properties":{}}})
3417 .to_string(),
3418 );
3419 let rx = st.bus.subscribe();
3420 ocserve_metrics::gauge(
3421 "ocserve_sse_clients",
3422 st.bus.subscriber_count().max(1) as i64,
3423 );
3424 let bus_keepalive = st.bus.clone();
3427 struct SseClientGuard;
3429 impl Drop for SseClientGuard {
3430 fn drop(&mut self) {
3431 ocserve_metrics::gauge_delta("ocserve_sse_clients", -1);
3432 }
3433 }
3434 let sse_guard = SseClientGuard;
3435 let events = stream::once(async move { Ok::<_, std::convert::Infallible>(connected) }).chain(
3439 stream::unfold(
3440 (
3441 rx,
3442 tokio::time::Instant::now() + Duration::from_secs(10),
3443 bus_keepalive,
3444 sse_guard,
3445 ),
3446 |(mut rx, mut next_hb, bus, guard)| async move {
3447 {
3448 let dur = next_hb.saturating_duration_since(tokio::time::Instant::now());
3449 match tokio::time::timeout(dur.max(Duration::from_millis(1)), rx.recv()).await {
3450 Ok(Ok(frame)) => {
3451 ocserve_metrics::counter("ocserve_sse_events_total", 1);
3452 Some((Ok(Event::default().data(&frame)), (rx, next_hb, bus, guard)))
3453 }
3454 Ok(Err(_lagged_or_closed)) => {
3455 ocserve_metrics::labeled_counter(
3456 "ocserve_event_ring_lag_total",
3457 "reason=\"lagged_or_closed\"",
3458 1,
3459 );
3460 None
3461 }
3462 Err(_elapsed) => {
3463 let hb = Event::default().data(
3464 json!({"payload":{"id":evt_id("heartbeat"),
3465 "type":"server.heartbeat","properties":{}}})
3466 .to_string(),
3467 );
3468 next_hb += Duration::from_secs(10);
3469 Some((Ok(hb), (rx, next_hb, bus, guard)))
3470 }
3471 }
3472 }
3473 },
3474 ),
3475 );
3476 let mut resp = Sse::new(events).into_response();
3479 let h = resp.headers_mut();
3481 for (k, v) in sse_expected_headers() {
3482 if let (Ok(name), Ok(val)) = (
3483 axum::http::HeaderName::try_from(k),
3484 HeaderValue::from_str(v),
3485 ) {
3486 h.insert(name, val);
3487 }
3488 }
3489 resp
3490}
3491
3492async fn auth_gate(
3496 State(st): State<Arc<AppState>>,
3497 req: axum::extract::Request,
3498 next: axum::middleware::Next,
3499) -> Result<Response, ApiError> {
3500 st.requests
3501 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3502 ocserve_metrics::counter("ocserve_requests_total", 1);
3504 let metric_t0 = std::time::Instant::now();
3505 let metric_label = format!(
3506 "route=\"{}\",method=\"{}\"",
3507 ocserve_metrics::route_label(req.uri().path()),
3508 req.method()
3509 );
3510 let record = move |t0: std::time::Instant| {
3511 ocserve_metrics::observe(
3512 "ocserve_http_request_duration_seconds",
3513 &metric_label,
3514 t0.elapsed().as_micros() as u64,
3515 );
3516 };
3517 tracing::info!("req {} {}", req.method(), req.uri().path());
3520 if let Some((user, pass)) = &st.auth {
3521 let authorized = req
3522 .headers()
3523 .get(axum::http::header::AUTHORIZATION)
3524 .and_then(|v| v.to_str().ok())
3525 .and_then(|v| v.strip_prefix("Basic "))
3526 .and_then(|b64| {
3527 use base64::Engine;
3528 base64::engine::general_purpose::STANDARD.decode(b64).ok()
3529 })
3530 .map(|raw| {
3531 let text = String::from_utf8_lossy(&raw);
3532 text == format!("{user}:{pass}")
3533 })
3534 .unwrap_or(false);
3535 if !authorized {
3536 record(metric_t0);
3537 return Err(ApiError {
3538 status: StatusCode::UNAUTHORIZED,
3539 name: "UnauthorizedError",
3540 message: "unauthorized".into(),
3541 });
3542 }
3543 }
3544 let resp = next.run(req).await;
3545 record(metric_t0);
3546 Ok(resp)
3547}
3548
3549pub fn router(state: Arc<AppState>) -> Router {
3551 Router::new()
3552 .route("/global/health", get(health))
3553 .route("/config", get(get_config))
3554 .route("/config/providers", get(get_config_providers))
3555 .route("/provider", get(get_provider))
3556 .route("/agent", get(get_agent))
3557 .route("/command", get(get_command))
3558 .route("/experimental/console", get(get_console))
3559 .route("/experimental/tool", get(get_experimental_tool))
3560 .route("/experimental/tool/ids", get(get_experimental_tool_ids))
3561 .route("/experimental/capabilities", get(get_capabilities))
3562 .route("/path", get(get_path))
3563 .route("/project", get(get_projects))
3564 .route("/project/current", get(get_project_current))
3565 .route("/session", get(get_sessions).post(post_session))
3566 .route("/session/{id}/fork", axum::routing::post(post_fork))
3567 .route("/session/status", get(session_status))
3568 .route("/session/search", axum::routing::post(search_messages))
3569 .route("/mcp/{name}/{action}", axum::routing::post(mcp_action))
3570 .route("/mcp/{name}/auth", axum::routing::delete(mcp_auth_remove))
3571 .route(
3572 "/auth/{providerID}",
3573 axum::routing::put(auth_put).delete(auth_delete),
3574 )
3575 .route("/provider/auth", axum::routing::get(provider_auth_methods))
3576 .route("/global/dispose", axum::routing::post(global_dispose))
3577 .route("/config", axum::routing::patch(patch_config))
3578 .route("/global/config", axum::routing::patch(patch_config))
3579 .route(
3580 "/session/{id}/summarize",
3581 axum::routing::post(post_summarize),
3582 )
3583 .route(
3584 "/session/{id}",
3585 get(get_session)
3586 .patch(patch_session)
3587 .delete(delete_session_route),
3588 )
3589 .route(
3590 "/session/{id}/message",
3591 get(get_messages).post(post_message),
3592 )
3593 .route("/global/event", get(global_event))
3594 .route("/event", get(global_event))
3598 .route("/metrics", get(metrics))
3599 .route("/api/location", get(get_api_location))
3601 .route("/api/agent", get(get_api_agent))
3602 .route("/api/command", get(get_api_command))
3603 .route("/api/reference", get(get_api_reference))
3604 .route("/experimental/resource", get(experimental_resource))
3605 .route("/experimental/workspace", get(experimental_workspace))
3606 .route(
3609 "/experimental/workspace/status",
3610 get(experimental_workspace_status),
3611 )
3612 .route("/formatter", get(formatter_list))
3613 .route("/lsp", get(lsp_list))
3614 .route("/project/{id}/directories", get(project_directories))
3615 .route("/vcs", get(vcs_info))
3616 .route(
3617 "/session/{id}/prompt_async",
3618 axum::routing::post(post_prompt_async),
3619 )
3620 .route("/session/{id}/children", get(get_children))
3621 .route("/file", get(list_directory))
3622 .route("/file/content", get(read_file_content))
3623 .route("/find/file", get(find_files))
3624 .route("/find", get(find_text))
3625 .route("/question", get(get_questions))
3626 .route(
3627 "/question/{id}/reply",
3628 axum::routing::post(post_question_reply),
3629 )
3630 .route(
3631 "/question/{id}/reject",
3632 axum::routing::post(post_question_reject),
3633 )
3634 .route("/session/{id}/todo", get(get_todos))
3635 .route("/session/{id}/abort", axum::routing::post(post_abort))
3636 .route("/session/{id}/command", axum::routing::post(post_command))
3637 .route("/session/{id}/shell", axum::routing::post(post_shell))
3638 .route(
3639 "/session/{id}/message/{mid}",
3640 axum::routing::delete(delete_message_route),
3641 )
3642 .route(
3643 "/session/{id}/message/{mid}/part/{pid}",
3644 axum::routing::delete(delete_part_route).patch(patch_part_route),
3645 )
3646 .route("/experimental/session", get(get_experimental_sessions))
3647 .route(
3649 "/tui/append-prompt",
3650 axum::routing::post(tui::append_prompt),
3651 )
3652 .route("/tui/open-help", axum::routing::post(tui::open_help))
3653 .route(
3654 "/tui/open-sessions",
3655 axum::routing::post(tui::open_sessions),
3656 )
3657 .route("/tui/open-themes", axum::routing::post(tui::open_themes))
3658 .route("/tui/open-models", axum::routing::post(tui::open_models))
3659 .route(
3660 "/tui/submit-prompt",
3661 axum::routing::post(tui::submit_prompt),
3662 )
3663 .route("/tui/clear-prompt", axum::routing::post(tui::clear_prompt))
3664 .route(
3665 "/tui/execute-command",
3666 axum::routing::post(tui::execute_command),
3667 )
3668 .route("/tui/show-toast", axum::routing::post(tui::show_toast))
3669 .route("/tui/publish", axum::routing::post(tui::publish_event))
3670 .route(
3671 "/tui/select-session",
3672 axum::routing::post(tui::select_session),
3673 )
3674 .route("/tui/control/next", get(tui::control_next))
3675 .route(
3676 "/tui/control/response",
3677 axum::routing::post(tui::control_response),
3678 )
3679 .route("/mcp", get(get_mcp))
3680 .route("/permission", get(get_permissions))
3681 .route(
3682 "/permission/{id}/reply",
3683 axum::routing::post(post_permission_reply),
3684 )
3685 .layer(axum::middleware::from_fn_with_state(
3686 state.clone(),
3687 auth_gate,
3688 ))
3689 .layer(axum::middleware::from_fn_with_state(
3692 state.clone(),
3693 crate::ui::cors_gate,
3694 ))
3695 .fallback(crate::ui::ui_fallback)
3699 .with_state(state)
3700}
3701
3702fn read_cors_env() -> Vec<String> {
3706 std::env::var("OCSERVE_CORS")
3707 .ok()
3708 .map(|v| {
3709 v.split(',')
3710 .map(str::trim)
3711 .filter(|s| !s.is_empty())
3712 .map(str::to_string)
3713 .collect()
3714 })
3715 .unwrap_or_default()
3716}
3717
3718pub fn keypaths(v: &serde_json::Value, prefix: &str, out: &mut std::collections::BTreeSet<String>) {
3721 match v {
3722 serde_json::Value::Object(m) => {
3723 for (k, val) in m {
3724 let p = format!("{prefix}.{k}");
3725 out.insert(p.clone());
3726 keypaths(val, &p, out);
3727 }
3728 }
3729 serde_json::Value::Array(a) => {
3730 let p = format!("{prefix}[]");
3731 if let Some(first) = a.first() {
3732 keypaths(first, &p, out);
3733 } else {
3734 out.insert(p);
3735 }
3736 }
3737 _ => {}
3738 }
3739}
3740
3741pub fn sse_expected_headers() -> Vec<(&'static str, &'static str)> {
3743 vec![
3744 ("cache-control", "no-cache, no-transform"),
3745 ("x-content-type-options", "nosniff"),
3746 ("content-type", "text/event-stream"),
3747 ("x-accel-buffering", "no"),
3748 ]
3749}
3750
3751#[allow(dead_code)]
3752fn _assert_header_value(v: &str) -> HeaderValue {
3753 HeaderValue::from_str(v).expect("static header value")
3754}
3755
3756#[cfg(test)]
3757mod limit_matrix {
3758 use super::parse_limit;
3759
3760 #[test]
3761 fn matches_probe_envelopes() {
3762 assert_eq!(
3764 parse_limit("abc").unwrap_err(),
3765 "Expected an integer, got NaN\n at [\"limit\"]"
3766 );
3767 assert_eq!(
3768 parse_limit("5.5").unwrap_err(),
3769 "Expected an integer, got 5.5\n at [\"limit\"]"
3770 );
3771 assert_eq!(
3772 parse_limit("-0.5").unwrap_err(),
3773 "Expected an integer, got -0.5\n at [\"limit\"]"
3774 );
3775 assert_eq!(
3776 parse_limit("-1").unwrap_err(),
3777 "Expected a value greater than or equal to 0, got -1\n at [\"limit\"]"
3778 );
3779 assert_eq!(
3780 parse_limit("999999999999999999999").unwrap_err(),
3781 "Expected an integer, got 1e+21\n at [\"limit\"]"
3782 );
3783 assert_eq!(
3784 parse_limit("Infinity").unwrap_err(),
3785 "Expected an integer, got Infinity\n at [\"limit\"]"
3786 );
3787 }
3788
3789 #[test]
3790 fn accepts_integers_like_js_number() {
3791 assert_eq!(parse_limit("+5"), Ok(5));
3792 assert_eq!(parse_limit("1e3"), Ok(1000)); assert_eq!(parse_limit("0"), Ok(0));
3794 assert_eq!(parse_limit("9007199254740991"), Ok(9007199254740991)); }
3796}
3797
3798#[cfg(test)]
3799mod f1_blocking_tests {
3800 use super::{AppState, Payloads, router, run_blocking};
3801 use std::sync::Arc;
3802 use std::sync::atomic::{AtomicU64, Ordering};
3803 use std::time::Duration;
3804
3805 #[tokio::test]
3818 async fn find_files_walk_runs_off_the_worker() {
3819 use tower::ServiceExt as _;
3820 let dir = std::env::temp_dir().join(format!(
3821 "ocserve-m3-walk-{}-{}",
3822 std::process::id(),
3823 std::time::SystemTime::now()
3824 .duration_since(std::time::UNIX_EPOCH)
3825 .unwrap()
3826 .as_nanos()
3827 ));
3828 std::fs::create_dir_all(&dir).unwrap();
3829 for i in 0..200 {
3830 std::fs::write(dir.join(format!("f{i}.txt")), b"x").unwrap();
3831 }
3832
3833 let mut payloads = Payloads::default();
3834 payloads.config["directory"] = serde_json::Value::String(dir.to_string_lossy().to_string());
3835 let st = AppState::with_payloads(None, payloads);
3836
3837 let handler_thread = std::thread::current().id();
3838 let req = axum::http::Request::builder()
3839 .uri("/find/file?query=f1&limit=10")
3840 .body(axum::body::Body::empty())
3841 .unwrap();
3842 let resp = tokio::spawn(async move { router(st).oneshot(req).await })
3843 .await
3844 .expect("route task")
3845 .expect("infallible");
3846 assert_eq!(resp.status(), 200);
3847
3848 let walked_on = super::WALK_THREAD.lock().ok().and_then(|g| *g);
3849 let walked_on = walked_on.expect("walk_files must record its thread");
3850 assert_ne!(
3851 walked_on, handler_thread,
3852 "the walk ran on the async worker — blocking syscalls must go \
3853 through spawn_blocking (M3)"
3854 );
3855 let _ = std::fs::remove_dir_all(&dir);
3856 }
3857
3858 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3864
3865 async fn run_blocking_frees_the_worker() {
3866 let ticks = Arc::new(AtomicU64::new(0));
3867 let t = ticks.clone();
3868 let ticker = tokio::spawn(async move {
3869 let mut i = 0u64;
3870 loop {
3871 tokio::time::sleep(Duration::from_millis(10)).await;
3872 i += 1;
3873 t.store(i, Ordering::Relaxed);
3874 }
3875 });
3876 tokio::time::sleep(Duration::from_millis(30)).await;
3877 let before = ticks.load(Ordering::Relaxed);
3878 assert!(before > 0, "ticker must be running before the probe");
3879
3880 let probe = tokio::spawn(async move {
3885 run_blocking(std::path::Path::new("/nonexistent/f1-probe"), |_db| {
3886 std::thread::sleep(Duration::from_millis(250));
3887 Ok::<(), anyhow::Error>(())
3888 })
3889 .await
3890 .expect("probe op");
3891 });
3892 probe.await.expect("probe join");
3893
3894 let after = ticks.load(Ordering::Relaxed);
3895 ticker.abort();
3896 assert!(
3897 after > before,
3898 "blocking call parked the worker: before={before} after={after}"
3899 );
3900 }
3901}
3902
3903#[cfg(test)]
3904mod f5_wire {
3905 use super::*;
3906
3907 #[test]
3910 fn wire_bytes_are_serde_identical() {
3911 let st = AppState::new();
3912 let p = st.payloads.read();
3913 let w = st.wire.read();
3914 for (k, v) in [
3915 ("config", &p.config),
3916 ("config_providers", &p.config_providers),
3917 ("provider", &p.provider),
3918 ("console", &p.console),
3919 ("capabilities", &p.capabilities),
3920 ] {
3921 let cached = w.get(k).unwrap_or_else(|| panic!("missing wire key {k}"));
3922 let want = serde_json::to_vec(v).unwrap();
3923 assert_eq!(
3924 &cached[..],
3925 &want[..],
3926 "wire bytes for {k} must equal Value serialization"
3927 );
3928 }
3929 for (k, v) in [("agent", &p.agent), ("command", &p.command)] {
3930 let cached = w.get(k).unwrap_or_else(|| panic!("missing wire key {k}"));
3931 let want = serde_json::to_vec(v).unwrap();
3932 assert_eq!(&cached[..], &want[..]);
3933 }
3934 }
3935
3936 #[test]
3938 fn wire_off_disables_cache() {
3939 let empty = rebuild_wire(&Payloads::default(), true);
3940 assert!(empty.is_empty(), "off => nothing serialized");
3941 let st = AppState::new();
3942 st.wire_off
3943 .store(true, std::sync::atomic::Ordering::Relaxed);
3944 assert!(wire_json(&st, "config").is_none(), "off => Value path");
3945 st.wire_off
3946 .store(false, std::sync::atomic::Ordering::Relaxed);
3947 assert!(wire_json(&st, "config").is_some(), "on => Bytes path");
3948 }
3949}