1#![deny(unsafe_code)]
3
4use axum::extract::State;
5use axum::http::{HeaderValue, StatusCode};
6use axum::response::sse::{Event, Sse};
7use axum::response::{IntoResponse, Response};
8use axum::routing::get;
9use axum::{Json, Router};
10use futures_util::StreamExt;
11use futures_util::stream;
12use serde_json::{Value, json};
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Duration;
16
17pub const FREEZE_VERSION: &str = "1.18.31";
19
20pub fn deep_merge(base: &mut serde_json::Value, patch: serde_json::Value) {
25 match (base, patch) {
26 (serde_json::Value::Object(b), serde_json::Value::Object(p)) => {
27 for (k, v) in p {
28 match b.get_mut(&k) {
29 Some(bv) => deep_merge(bv, v),
30 None => {
31 b.insert(k, v);
32 }
33 }
34 }
35 }
36 (b, p) => *b = p,
37 }
38}
39
40pub fn config_write_path() -> std::path::PathBuf {
45 let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
46 if std::env::var("OCSERVE_CONFIG_WRITE")
47 .map(|v| v == "overlay")
48 .unwrap_or(false)
49 {
50 std::path::PathBuf::from(home).join(".config/ocserve/config.json")
51 } else {
52 std::path::PathBuf::from(home).join(".config/opencode/opencode.json")
53 }
54}
55
56pub type ConfigReloader =
61 std::sync::Arc<dyn Fn() -> anyhow::Result<(Payloads, LlmRegistry)> + Send + Sync>;
62
63#[derive(Default)]
64pub struct Payloads {
65 pub config: serde_json::Value,
66 pub agent: Vec<serde_json::Value>,
67 pub api_agent: Vec<serde_json::Value>,
68 pub command: Vec<serde_json::Value>,
69 pub config_providers: serde_json::Value,
70 pub provider: serde_json::Value,
71 pub console: serde_json::Value,
72 pub capabilities: serde_json::Value,
73 pub compaction: serde_json::Value,
75 pub catalog: std::sync::Arc<serde_json::Value>,
80}
81
82async fn run_blocking<T, F>(db: &std::path::Path, op: F) -> anyhow::Result<T>
89where
90 F: FnOnce(&std::path::Path) -> anyhow::Result<T> + Send + 'static,
91 T: Send + 'static,
92{
93 let db = db.to_path_buf();
94 tokio::task::spawn_blocking(move || op(&db))
95 .await
96 .map_err(|e| anyhow::anyhow!("blocking task join: {e}"))?
97}
98
99pub mod compress;
100pub mod doc;
101pub mod embedded_ui;
102pub mod pty;
103pub mod tui;
104pub mod ui;
105pub mod v2;
106pub mod vcs;
107
108pub struct ApiError {
110 pub status: StatusCode,
111 pub name: &'static str,
112 pub message: String,
113}
114
115impl ApiError {
116 pub fn not_found(msg: impl Into<String>) -> Self {
117 Self {
118 status: StatusCode::NOT_FOUND,
119 name: "NotFoundError",
120 message: msg.into(),
121 }
122 }
123}
124
125impl IntoResponse for ApiError {
126 fn into_response(self) -> Response {
127 let body = json!({"name": self.name, "data": {"message": self.message}});
128 (self.status, Json(body)).into_response()
129 }
130}
131
132pub mod sync;
135pub mod watch;
136
137pub enum HttpError {
138 Api(ApiError),
139 Tagged {
140 status: StatusCode,
141 tag: &'static str,
142 },
143 Query {
146 message: String,
147 },
148 Payload {
152 message: String,
153 },
154 Params {
158 message: String,
159 },
160 TaggedData {
163 status: StatusCode,
164 tag: &'static str,
165 fields: serde_json::Value,
166 },
167}
168
169impl From<ApiError> for HttpError {
170 fn from(e: ApiError) -> Self {
171 HttpError::Api(e)
172 }
173}
174
175impl IntoResponse for HttpError {
176 fn into_response(self) -> Response {
177 match self {
178 HttpError::Api(e) => e.into_response(),
179 HttpError::Tagged { status, tag } => {
180 let body = format!("{{\"_tag\":\"{tag}\"}}");
181 (
182 status,
183 [(axum::http::header::CONTENT_TYPE, "application/json")],
184 body,
185 )
186 .into_response()
187 }
188 HttpError::TaggedData {
189 status,
190 tag,
191 fields,
192 } => {
193 let mut obj = serde_json::Map::new();
194 obj.insert("_tag".into(), serde_json::Value::String(tag.into()));
195 if let serde_json::Value::Object(f) = fields {
196 obj.extend(f);
197 }
198 (
199 status,
200 [(axum::http::header::CONTENT_TYPE, "application/json")],
201 serde_json::Value::Object(obj).to_string(),
202 )
203 .into_response()
204 }
205 HttpError::Query { message } => {
206 let body = serde_json::json!({
207 "name": "BadRequest",
208 "data": {"message": message, "kind": "Query"}
209 })
210 .to_string();
211 (
212 StatusCode::BAD_REQUEST,
213 [(axum::http::header::CONTENT_TYPE, "application/json")],
214 body,
215 )
216 .into_response()
217 }
218 HttpError::Payload { message } => {
219 let body = serde_json::json!({
220 "name": "BadRequest",
221 "data": {"message": message, "kind": "Payload"}
222 })
223 .to_string();
224 (
225 StatusCode::BAD_REQUEST,
226 [(axum::http::header::CONTENT_TYPE, "application/json")],
227 body,
228 )
229 .into_response()
230 }
231 HttpError::Params { message } => {
232 let body = serde_json::json!({
233 "name": "BadRequest",
234 "data": {"message": message, "kind": "Params"}
235 })
236 .to_string();
237 (
238 StatusCode::BAD_REQUEST,
239 [(axum::http::header::CONTENT_TYPE, "application/json")],
240 body,
241 )
242 .into_response()
243 }
244 }
245 }
246}
247
248pub type PromptTask = (u64, tokio::task::JoinHandle<()>);
250
251pub struct AppState {
253 pub payloads: parking_lot::RwLock<Payloads>,
256 pub wire: parking_lot::RwLock<HashMap<&'static str, WireEntry>>,
263 pub wire_off: std::sync::atomic::AtomicBool,
264 pub reloader: parking_lot::RwLock<Option<ConfigReloader>>,
268 pub paths: serde_json::Value,
270 pub auth: Option<(String, String)>,
272 pub requests: std::sync::atomic::AtomicU64,
274 pub bus: ocserve_core::EventBus,
277 pub db: std::path::PathBuf,
278 pub blobs: std::sync::Arc<ocserve_store::BlobStore>,
279 pub writer: std::sync::Arc<ocserve_store::Writer>,
280 pub llm: parking_lot::RwLock<LlmRegistry>,
284 pub watch: parking_lot::RwLock<watch::WatchState>,
286 pub reconcile_lock: tokio::sync::Mutex<()>,
289 pub max_rounds: usize,
292 pub cost_ceiling: f64,
293 pub gate: std::sync::Arc<ocserve_core::PermissionGate>,
295 pub mcp: std::sync::OnceLock<std::sync::Arc<ocserve_mcp::McpHub>>,
297 pub plugins: std::sync::OnceLock<std::sync::Arc<tokio::sync::Mutex<ocserve_plugin::Sidecar>>>,
299 pub prompt_locks: std::sync::Arc<
303 parking_lot::Mutex<
304 std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
305 >,
306 >,
307 pub prompt_tasks:
311 std::sync::Arc<parking_lot::Mutex<std::collections::HashMap<String, PromptTask>>>,
312 pub prompt_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
314 pub question_gate: std::sync::Arc<ocserve_core::question::QuestionGate>,
316 pub tui: crate::tui::TuiControl,
318 pub ui: crate::ui::UiProxy,
320 pub pty: std::sync::Arc<ocserve_pty::PtyManager>,
322 pub cors_extra: Vec<String>,
324}
325
326#[derive(Default)]
328pub struct LlmRegistry {
329 pub endpoints: std::collections::HashMap<String, (String, String)>,
331 pub pricing: std::collections::HashMap<(String, String), (f64, f64, f64)>,
333 pub limits: std::collections::HashMap<(String, String), serde_json::Value>,
335 pub default_model: (String, String),
337 pub systems: std::collections::HashMap<String, String>,
339 pub default_agent: String,
341}
342
343pub struct Wires {
345 pub db: std::path::PathBuf,
346 pub blobs: std::sync::Arc<ocserve_store::BlobStore>,
347 pub writer: std::sync::Arc<ocserve_store::Writer>,
348 pub llm: LlmRegistry,
349}
350
351#[derive(Clone)]
357pub struct WireEntry {
358 pub identity: bytes::Bytes,
359 pub br: bytes::Bytes,
360 pub gzip: bytes::Bytes,
361 pub etag: String,
362}
363
364pub(crate) fn rebuild_wire(
368 p: &Payloads,
369 off: bool,
370 location: &Value,
371) -> HashMap<&'static str, WireEntry> {
372 let mut m = HashMap::new();
373 if off {
374 return m;
375 }
376 fn one<T: serde::Serialize>(m: &mut HashMap<&'static str, WireEntry>, k: &'static str, v: &T) {
378 if let Ok(b) = serde_json::to_vec(v) {
379 let identity = bytes::Bytes::from(b);
380 let etag = crate::compress::etag_for(&identity);
381 let br = bytes::Bytes::from(crate::compress::compress(
382 &identity,
383 crate::compress::Encoding::Brotli,
384 ));
385 let gzip = bytes::Bytes::from(crate::compress::compress(
386 &identity,
387 crate::compress::Encoding::Gzip,
388 ));
389 m.insert(
390 k,
391 WireEntry {
392 identity,
393 br,
394 gzip,
395 etag,
396 },
397 );
398 }
399 }
400 {
402 let mut gc = p.config.clone();
403 if let Some(obj) = gc.as_object_mut() {
404 for k in ["agent", "command", "mode", "username"] {
405 obj.remove(k);
406 }
407 }
408 one(&mut m, "global_config", &gc);
409 }
410 one(&mut m, "config", &p.config);
411 one(&mut m, "agent", &p.agent);
412 one(&mut m, "command", &p.command);
413 one(&mut m, "config_providers", &p.config_providers);
414 one(&mut m, "provider", &p.provider);
415 one(&mut m, "console", &p.console);
416 one(&mut m, "capabilities", &p.capabilities);
417 one(&mut m, "api_model", &crate::v2::models_body(p, location));
421 one(
422 &mut m,
423 "api_provider",
424 &crate::v2::providers_body(p, location),
425 );
426 one(
427 &mut m,
428 "api_integration",
429 &crate::v2::integrations_body(p, location),
430 );
431 one(&mut m, "api_agent", &api_agent_body(p, location));
434 one(&mut m, "api_command", &api_command_body(p, location));
435 m
436}
437
438fn wire_json_h(
443 st: &AppState,
444 key: &'static str,
445 accept_encoding: Option<&str>,
446 if_none_match: Option<&str>,
447) -> Option<axum::response::Response> {
448 if st.wire_off.load(std::sync::atomic::Ordering::Relaxed) {
449 return None;
450 }
451 let e = st.wire.read().get(key).cloned()?;
452 let encoding = crate::compress::negotiate(accept_encoding);
453 if crate::compress::if_none_match_matches(if_none_match, &e.etag) {
455 return Some(
456 axum::response::Response::builder()
457 .status(axum::http::StatusCode::NOT_MODIFIED)
458 .header(axum::http::header::ETAG, &e.etag)
459 .header(axum::http::header::VARY, "Accept-Encoding")
460 .body(axum::body::Body::empty())
461 .expect("static 304"),
462 );
463 }
464 let mut b = axum::response::Response::builder()
465 .header(axum::http::header::CONTENT_TYPE, "application/json")
466 .header(axum::http::header::ETAG, &e.etag)
467 .header(axum::http::header::VARY, "Accept-Encoding");
468 let body = match encoding {
469 Some(crate::compress::Encoding::Brotli) => {
470 b = b.header(axum::http::header::CONTENT_ENCODING, "br");
471 e.br.clone()
472 }
473 Some(crate::compress::Encoding::Gzip) => {
474 b = b.header(axum::http::header::CONTENT_ENCODING, "gzip");
475 e.gzip.clone()
476 }
477 None => e.identity.clone(),
478 };
479 Some(
480 b.body(axum::body::Body::from(body))
481 .expect("static response"),
482 )
483}
484
485#[cfg(test)]
487fn wire_json(st: &AppState, key: &'static str) -> Option<axum::response::Response> {
488 wire_json_h(st, key, None, None)
489}
490
491impl AppState {
492 pub fn new() -> Arc<Self> {
493 Self::with_auth(None)
494 }
495
496 pub fn with_auth(auth: Option<(String, String)>) -> Arc<Self> {
497 Self::with_payloads(auth, Payloads::default())
498 }
499
500 pub fn with_payloads(auth: Option<(String, String)>, p: Payloads) -> Arc<Self> {
503 static NEW_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
506 let dir = std::env::temp_dir().join(format!(
507 "ocserve-http-{}-{}-{}",
508 std::process::id(),
509 std::time::SystemTime::now()
510 .duration_since(std::time::UNIX_EPOCH)
511 .map(|d| d.as_nanos())
512 .unwrap_or(0),
513 NEW_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
514 ));
515 std::fs::create_dir_all(&dir).expect("temp data dir");
516 let db = ocserve_store::writer::db_path(&dir);
517 let writer =
518 std::sync::Arc::new(ocserve_store::Writer::spawn(db.clone()).expect("temp writer"));
519 let blobs = std::sync::Arc::new(
520 ocserve_store::BlobStore::new(dir.join("blobs")).expect("temp blobs"),
521 );
522 Self::with_wiring(
523 auth,
524 p,
525 Wires {
526 db,
527 blobs,
528 writer,
529 llm: LlmRegistry::default(),
530 },
531 )
532 }
533
534 pub fn with_wiring(auth: Option<(String, String)>, p: Payloads, w: Wires) -> Arc<Self> {
536 let home = std::env::var("HOME").unwrap_or_default();
537 let worktree = std::env::current_dir()
538 .map(|p| p.display().to_string())
539 .unwrap_or_else(|_| "/".into());
540 let wire_off = std::env::var("OCSERVE_WIRE_CACHE")
541 .map(|v| v == "0")
542 .unwrap_or(false);
543 let location = json!({
545 "directory": worktree,
546 "project": {"id": "global", "directory": "/"},
547 });
548 let wire_map = rebuild_wire(&p, wire_off, &location);
549 let bus = ocserve_core::EventBus::new();
550 let pty = {
551 let mgr = ocserve_pty::PtyManager::new(worktree.clone());
552 let bus_for_pty = bus.clone();
553 let dir_for_pty = worktree.clone();
554 mgr.set_event_sink(std::sync::Arc::new(move |event_type, properties| {
555 bus_for_pty.publish(ocserve_core::event::frame(
556 &dir_for_pty,
557 event_type,
558 properties,
559 ));
560 }));
561 mgr
562 };
563 Arc::new(Self {
564 payloads: parking_lot::RwLock::new(p),
565 wire: parking_lot::RwLock::new(wire_map),
566 wire_off: std::sync::atomic::AtomicBool::new(wire_off),
567 reloader: parking_lot::RwLock::new(None),
568 paths: json!({
570 "home": home,
571 "state": format!("{home}/.local/state/opencode"),
572 "config": format!("{home}/.config/opencode"),
573 "worktree": worktree,
574 "directory": worktree,
575 }),
576 auth,
577 requests: std::sync::atomic::AtomicU64::new(0),
578 bus,
579 gate: ocserve_core::PermissionGate::new(),
580 mcp: std::sync::OnceLock::new(),
581 plugins: std::sync::OnceLock::new(),
582 prompt_locks: std::sync::Arc::new(parking_lot::Mutex::new(
583 std::collections::HashMap::new(),
584 )),
585 prompt_tasks: std::sync::Arc::new(parking_lot::Mutex::new(
586 std::collections::HashMap::new(),
587 )),
588 prompt_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)),
589 question_gate: ocserve_core::question::QuestionGate::new(),
590 tui: crate::tui::TuiControl::default(),
591 ui: crate::ui::UiProxy::from_env(),
592 pty,
593 cors_extra: read_cors_env(),
594 db: w.db,
595 blobs: w.blobs,
596 writer: w.writer,
597 llm: parking_lot::RwLock::new(w.llm),
598 watch: parking_lot::RwLock::new(watch::WatchState::default()),
599 reconcile_lock: tokio::sync::Mutex::new(()),
600 max_rounds: std::env::var("OCSERVE_PROMPT_MAX_ROUNDS")
601 .ok()
602 .and_then(|v| v.parse().ok())
603 .unwrap_or(0),
604 cost_ceiling: std::env::var("OCSERVE_PROMPT_MAX_COST_USD")
605 .ok()
606 .and_then(|v| v.parse().ok())
607 .unwrap_or(0.0),
608 })
609 }
610}
611
612async fn health() -> impl IntoResponse {
613 Json(json!({"healthy": true, "version": FREEZE_VERSION}))
614}
615
616async fn get_doc() -> impl IntoResponse {
618 (
619 [(axum::http::header::CONTENT_TYPE, "application/json")],
620 crate::doc::SPEC_JSON,
621 )
622}
623
624async fn get_config(
625 State(st): State<Arc<AppState>>,
626 headers: axum::http::HeaderMap,
627) -> axum::response::Response {
628 let ae = headers
629 .get(axum::http::header::ACCEPT_ENCODING)
630 .and_then(|v| v.to_str().ok());
631 let inm = headers
632 .get(axum::http::header::IF_NONE_MATCH)
633 .and_then(|v| v.to_str().ok());
634 wire_json_h(&st, "config", ae, inm).unwrap_or_else(|| {
635 let v = st.payloads.read().config.clone();
636 let body = serde_json::to_vec(&v).unwrap_or_default();
637 let etag = crate::compress::etag_for(&body);
638 crate::compress::encode_response(
639 axum::http::StatusCode::OK,
640 "application/json",
641 bytes::Bytes::from(body),
642 Some(&etag),
643 ae,
644 inm,
645 &[],
646 )
647 })
648}
649
650async fn get_agent(
651 State(st): State<Arc<AppState>>,
652 headers: axum::http::HeaderMap,
653) -> axum::response::Response {
654 let ae = headers
655 .get(axum::http::header::ACCEPT_ENCODING)
656 .and_then(|v| v.to_str().ok());
657 let inm = headers
658 .get(axum::http::header::IF_NONE_MATCH)
659 .and_then(|v| v.to_str().ok());
660 wire_json_h(&st, "agent", ae, inm).unwrap_or_else(|| {
661 let v = st.payloads.read().agent.clone();
662 let body = serde_json::to_vec(&v).unwrap_or_default();
663 let etag = crate::compress::etag_for(&body);
664 crate::compress::encode_response(
665 axum::http::StatusCode::OK,
666 "application/json",
667 bytes::Bytes::from(body),
668 Some(&etag),
669 ae,
670 inm,
671 &[],
672 )
673 })
674}
675
676async fn get_command(
677 State(st): State<Arc<AppState>>,
678 headers: axum::http::HeaderMap,
679) -> axum::response::Response {
680 let ae = headers
681 .get(axum::http::header::ACCEPT_ENCODING)
682 .and_then(|v| v.to_str().ok());
683 let inm = headers
684 .get(axum::http::header::IF_NONE_MATCH)
685 .and_then(|v| v.to_str().ok());
686 wire_json_h(&st, "command", ae, inm).unwrap_or_else(|| {
687 let v = st.payloads.read().command.clone();
688 let body = serde_json::to_vec(&v).unwrap_or_default();
689 let etag = crate::compress::etag_for(&body);
690 crate::compress::encode_response(
691 axum::http::StatusCode::OK,
692 "application/json",
693 bytes::Bytes::from(body),
694 Some(&etag),
695 ae,
696 inm,
697 &[],
698 )
699 })
700}
701
702async fn get_config_providers(
703 State(st): State<Arc<AppState>>,
704 headers: axum::http::HeaderMap,
705) -> axum::response::Response {
706 let ae = headers
707 .get(axum::http::header::ACCEPT_ENCODING)
708 .and_then(|v| v.to_str().ok());
709 let inm = headers
710 .get(axum::http::header::IF_NONE_MATCH)
711 .and_then(|v| v.to_str().ok());
712 wire_json_h(&st, "config_providers", ae, inm).unwrap_or_else(|| {
713 let v = st.payloads.read().config_providers.clone();
714 let body = serde_json::to_vec(&v).unwrap_or_default();
715 let etag = crate::compress::etag_for(&body);
716 crate::compress::encode_response(
717 axum::http::StatusCode::OK,
718 "application/json",
719 bytes::Bytes::from(body),
720 Some(&etag),
721 ae,
722 inm,
723 &[],
724 )
725 })
726}
727
728async fn get_provider(
729 State(st): State<Arc<AppState>>,
730 headers: axum::http::HeaderMap,
731) -> axum::response::Response {
732 let ae = headers
733 .get(axum::http::header::ACCEPT_ENCODING)
734 .and_then(|v| v.to_str().ok());
735 let inm = headers
736 .get(axum::http::header::IF_NONE_MATCH)
737 .and_then(|v| v.to_str().ok());
738 wire_json_h(&st, "provider", ae, inm).unwrap_or_else(|| {
739 let v = st.payloads.read().provider.clone();
740 let body = serde_json::to_vec(&v).unwrap_or_default();
741 let etag = crate::compress::etag_for(&body);
742 crate::compress::encode_response(
743 axum::http::StatusCode::OK,
744 "application/json",
745 bytes::Bytes::from(body),
746 Some(&etag),
747 ae,
748 inm,
749 &[],
750 )
751 })
752}
753
754async fn get_console(
755 State(st): State<Arc<AppState>>,
756 headers: axum::http::HeaderMap,
757) -> axum::response::Response {
758 let ae = headers
759 .get(axum::http::header::ACCEPT_ENCODING)
760 .and_then(|v| v.to_str().ok());
761 let inm = headers
762 .get(axum::http::header::IF_NONE_MATCH)
763 .and_then(|v| v.to_str().ok());
764 wire_json_h(&st, "console", ae, inm).unwrap_or_else(|| {
765 let v = st.payloads.read().console.clone();
766 let body = serde_json::to_vec(&v).unwrap_or_default();
767 let etag = crate::compress::etag_for(&body);
768 crate::compress::encode_response(
769 axum::http::StatusCode::OK,
770 "application/json",
771 bytes::Bytes::from(body),
772 Some(&etag),
773 ae,
774 inm,
775 &[],
776 )
777 })
778}
779
780async fn get_capabilities(
781 State(st): State<Arc<AppState>>,
782 headers: axum::http::HeaderMap,
783) -> axum::response::Response {
784 let ae = headers
785 .get(axum::http::header::ACCEPT_ENCODING)
786 .and_then(|v| v.to_str().ok());
787 let inm = headers
788 .get(axum::http::header::IF_NONE_MATCH)
789 .and_then(|v| v.to_str().ok());
790 wire_json_h(&st, "capabilities", ae, inm).unwrap_or_else(|| {
791 let v = st.payloads.read().capabilities.clone();
792 let body = serde_json::to_vec(&v).unwrap_or_default();
793 let etag = crate::compress::etag_for(&body);
794 crate::compress::encode_response(
795 axum::http::StatusCode::OK,
796 "application/json",
797 bytes::Bytes::from(body),
798 Some(&etag),
799 ae,
800 inm,
801 &[],
802 )
803 })
804}
805
806pub(crate) fn api_location(st: &AppState) -> Value {
808 let dir = st.paths["directory"].as_str().unwrap_or("/");
809 json!({
810 "directory": dir,
811 "project": {"id": "global", "directory": "/"},
812 })
813}
814
815async fn get_api_agent(
818 State(st): State<Arc<AppState>>,
819 headers: axum::http::HeaderMap,
820) -> axum::response::Response {
821 let ae = headers
822 .get(axum::http::header::ACCEPT_ENCODING)
823 .and_then(|v| v.to_str().ok());
824 let inm = headers
825 .get(axum::http::header::IF_NONE_MATCH)
826 .and_then(|v| v.to_str().ok());
827 crate::wire_json_h(&st, "api_agent", ae, inm).unwrap_or_else(|| {
828 let payloads = st.payloads.read();
829 Json(api_agent_body(&payloads, &api_location(&st))).into_response()
830 })
831}
832
833pub(crate) fn api_agent_body(payloads: &Payloads, location: &Value) -> Value {
835 json!({"location": location, "data": api_agent_data(payloads)})
836}
837
838fn api_agent_data(payloads: &Payloads) -> Vec<Value> {
839 let data: Vec<Value> = payloads
840 .api_agent
841 .iter()
842 .map(|a| {
843 let perms: Vec<Value> = a["permission"]
844 .as_array()
845 .map(|rules| {
846 rules
847 .iter()
848 .map(|r| {
849 json!({
850 "action": r["permission"],
851 "resource": r["pattern"],
852 "effect": r["action"],
853 })
854 })
855 .collect()
856 })
857 .unwrap_or_default();
858 let mut out = json!({
859 "id": a["name"],
860 "hidden": a.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false),
861 "mode": a["mode"],
862 "permissions": perms,
863 "request": {"headers": {}, "body": {}},
864 });
865 if let Some(d) = a.get("description") {
866 out["description"] = d.clone();
867 }
868 if let Some(p) = a.get("prompt") {
870 out["system"] = p.clone();
871 } else if a["name"] == "build" {
872 out["system"] = json!(BUILD_SYSTEM_BLURB);
873 }
874 out
875 })
876 .collect();
877 data
878}
879
880const BUILD_SYSTEM_BLURB: &str = "You are an AI coding agent. Help the user accomplish software \
882engineering tasks by inspecting the workspace, making targeted changes, and using tools according \
883to the configured permissions.";
884
885async fn get_api_command(
887 State(st): State<Arc<AppState>>,
888 headers: axum::http::HeaderMap,
889) -> axum::response::Response {
890 let ae = headers
891 .get(axum::http::header::ACCEPT_ENCODING)
892 .and_then(|v| v.to_str().ok());
893 let inm = headers
894 .get(axum::http::header::IF_NONE_MATCH)
895 .and_then(|v| v.to_str().ok());
896 crate::wire_json_h(&st, "api_command", ae, inm).unwrap_or_else(|| {
897 let payloads = st.payloads.read();
898 Json(api_command_body(&payloads, &api_location(&st))).into_response()
899 })
900}
901
902pub(crate) fn api_command_body(payloads: &Payloads, location: &Value) -> Value {
904 let data: Vec<Value> = payloads
905 .command
906 .iter()
907 .map(|c| {
908 json!({
909 "name": c["name"],
910 "description": c.get("description").and_then(|v| v.as_str()).unwrap_or(""),
911 "template": c.get("template").and_then(|v| v.as_str()).unwrap_or(""),
912 })
913 })
914 .collect();
915 json!({"location": location, "data": data})
916}
917
918async fn get_experimental_tool(
925 State(st): State<Arc<AppState>>,
926 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
927) -> Result<Json<Value>, ApiError> {
928 let has = |k: &str| q.get(k).map(|s| !s.is_empty()).unwrap_or(false);
929 if !has("provider") || !has("model") {
930 return Err(ApiError {
931 status: StatusCode::BAD_REQUEST,
932 name: "BadRequest",
933 message: "Invalid query: provider, model required".into(),
934 });
935 }
936 let mut schemas = ocserve_tools::schemas();
937 if let Some(hub) = st.mcp.get() {
938 let mcp_tools =
939 tokio::time::timeout(std::time::Duration::from_secs(10), hub.tool_schemas())
940 .await
941 .unwrap_or_else(|_| {
942 tracing::warn!("mcp tool schema fetch timed out (/experimental/tool)");
943 Vec::new()
944 });
945 schemas.extend(mcp_tools);
946 }
947 let items: Vec<Value> = schemas
948 .iter()
949 .filter_map(|t| {
950 let f = t.get("function")?;
951 Some(json!({
952 "id": f.get("name")?,
953 "description": f.get("description")?,
954 "parameters": f.get("parameters")?,
955 }))
956 })
957 .collect();
958 Ok(Json(Value::Array(items)))
959}
960
961async fn get_experimental_tool_ids() -> Json<Value> {
963 let ids: Vec<Value> = ocserve_tools::schemas()
964 .iter()
965 .filter_map(|t| t.pointer("/function/name").and_then(|n| n.as_str()))
966 .map(|n| json!(n))
967 .collect();
968 Json(Value::Array(ids))
969}
970
971async fn experimental_resource() -> Json<Value> {
972 Json(json!({}))
973}
974async fn experimental_workspace() -> Json<Value> {
975 Json(json!([]))
976}
977
978async fn experimental_workspace_status() -> Json<Value> {
980 Json(json!([]))
981}
982async fn formatter_list() -> Json<Value> {
983 Json(json!([]))
984}
985async fn lsp_list() -> Json<Value> {
986 Json(json!([]))
987}
988async fn project_directories() -> Json<Value> {
989 Json(json!([]))
990}
991
992async fn get_mcp(State(st): State<Arc<AppState>>) -> Json<Value> {
994 let hub = st
995 .mcp
996 .get()
997 .cloned()
998 .unwrap_or_else(|| std::sync::Arc::new(ocserve_mcp::McpHub::default()));
999 Json(hub.statuses())
1000}
1001
1002async fn post_mcp(
1008 State(st): State<Arc<AppState>>,
1009 Json(body): Json<Value>,
1010) -> Result<Json<Value>, HttpError> {
1011 let name = body
1012 .get("name")
1013 .and_then(|v| v.as_str())
1014 .filter(|s| !s.is_empty())
1015 .ok_or_else(|| mcp_payload_err("name"))?;
1016 let config = body
1017 .get("config")
1018 .filter(|c| c.is_object())
1019 .ok_or_else(|| mcp_payload_err("config"))?;
1020 let hub = st.mcp.get().cloned().ok_or_else(|| {
1021 HttpError::Api(ApiError {
1022 status: StatusCode::SERVICE_UNAVAILABLE,
1023 name: "McpUnavailable",
1024 message: "MCP hub not initialized".into(),
1025 })
1026 })?;
1027 let mut one = serde_json::Map::new();
1030 one.insert(name.to_string(), config.clone());
1031 let cfg = ocserve_mcp::parse_config(&Value::Object(one))
1032 .into_iter()
1033 .next()
1034 .ok_or_else(|| mcp_payload_err("config"))?;
1035 hub.upsert_cfg(cfg);
1036 if let Err(e) = hub.connect(name).await {
1039 tracing::info!("mcp add: connect {name} failed (recorded): {e:#}");
1040 }
1041 Ok(Json(hub.statuses()))
1042}
1043
1044fn mcp_payload_err(field: &str) -> HttpError {
1046 HttpError::Payload {
1047 message: format!("Missing key\n at [\"{field}\"]"),
1048 }
1049}
1050
1051pub(crate) fn decode_v1<T>(r: Result<T, ocserve_core::wire::WireError>) -> Result<T, HttpError> {
1056 r.map_err(|e| HttpError::Payload { message: e.message })
1057}
1058
1059pub(crate) fn decode_v2<T>(r: Result<T, ocserve_core::wire::WireError>) -> Result<T, HttpError> {
1063 r.map_err(|e| HttpError::TaggedData {
1064 status: StatusCode::BAD_REQUEST,
1065 tag: "InvalidRequestError",
1066 fields: json!({"message": e.message, "kind": "Payload"}),
1067 })
1068}
1069
1070async fn get_experimental_sessions(
1074 State(st): State<Arc<AppState>>,
1075 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1076) -> Json<Value> {
1077 let mut v = run_blocking(&st.db, ocserve_store::load_sessions_wire)
1078 .await
1079 .unwrap_or_else(|e| {
1080 tracing::error!("session list read failed: {e:#}");
1081 Vec::new()
1082 });
1083 if let Some(search) = q.get("search").filter(|s| !s.is_empty()) {
1084 let needle = search.to_lowercase();
1085 v.retain(|s| {
1086 s["title"]
1087 .as_str()
1088 .unwrap_or_default()
1089 .to_lowercase()
1090 .contains(&needle)
1091 });
1092 }
1093 if let Some(limit) = q.get("limit").and_then(|l| l.parse::<usize>().ok()) {
1094 v.truncate(limit);
1095 }
1096 for row in &mut v {
1098 row["project"] = json!({"id": "global", "worktree": "/"});
1099 }
1100 Json(Value::Array(v))
1101}
1102
1103async fn post_permission_reply(
1110 State(st): State<Arc<AppState>>,
1111 axum::extract::Path(id): axum::extract::Path<String>,
1112 Json(body): Json<Value>,
1113) -> Result<Json<Value>, HttpError> {
1114 let reply = validate_permission_reply(&body)?;
1115 if !st.gate.reply(&id, reply) {
1116 return Err(HttpError::TaggedData {
1117 status: StatusCode::NOT_FOUND,
1118 tag: "PermissionNotFoundError",
1119 fields: json!({"requestID": id, "message": format!("Permission request not found: {id}")}),
1120 });
1121 }
1122 Ok(Json(json!({})))
1123}
1124
1125pub(crate) fn validate_permission_reply(body: &Value) -> Result<&'static str, HttpError> {
1128 validate_permission_reply_key(body, "reply")
1129}
1130
1131pub(crate) fn validate_permission_reply_key(
1133 body: &Value,
1134 key: &str,
1135) -> Result<&'static str, HttpError> {
1136 let Some(v) = body.get(key) else {
1137 return Err(HttpError::Payload {
1138 message: format!("Missing key\n at [\"{key}\"]"),
1139 });
1140 };
1141 match v.as_str() {
1142 Some("once") => Ok("once"),
1143 Some("always") => Ok("always"),
1144 Some("reject") => Ok("reject"),
1145 _ => Err(HttpError::Payload {
1148 message: format!(
1149 "Expected \"once\" | \"always\" | \"reject\", got {}\n at [\"{key}\"]",
1150 v
1151 ),
1152 }),
1153 }
1154}
1155
1156async fn get_permissions(State(st): State<Arc<AppState>>) -> Json<Value> {
1158 Json(serde_json::Value::Array(st.gate.list()))
1159}
1160
1161async fn vcs_info(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1163 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1164 let info = run_blocking(&st.db, move |_db| Ok::<_, anyhow::Error>(vcs::info(&dir)))
1165 .await
1166 .unwrap_or_else(|_| json!({"branch": null, "default_branch": null}));
1167 Json(info)
1168}
1169
1170async fn vcs_status(State(st): State<Arc<AppState>>) -> Response {
1172 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1173 match run_blocking(&st.db, move |_db| Ok::<_, anyhow::Error>(vcs::status(&dir))).await {
1174 Ok(v) => axum::Json(v).into_response(),
1175 Err(_) => axum::Json(Value::Array(vec![])).into_response(),
1176 }
1177}
1178
1179async fn vcs_diff(
1182 State(st): State<Arc<AppState>>,
1183 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1184 headers: axum::http::HeaderMap,
1185) -> Response {
1186 let mode = q.get("mode").map(String::as_str).unwrap_or("");
1187 if mode != "git" && mode != "branch" {
1188 return HttpError::Query {
1189 message: format!("Expected \"git\" | \"branch\", got \"{mode}\"\n at [\"mode\"]"),
1190 }
1191 .into_response();
1192 }
1193 let context: Option<u32> = q.get("context").and_then(|v| v.parse().ok());
1194 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1195 let mode_owned = mode.to_string();
1196 let v = run_blocking(&st.db, move |_db| {
1197 Ok::<_, anyhow::Error>(vcs::diff(&dir, &mode_owned, context))
1198 })
1199 .await
1200 .unwrap_or_else(|_| Value::Array(vec![]));
1201 let ae = headers
1202 .get(axum::http::header::ACCEPT_ENCODING)
1203 .and_then(|v| v.to_str().ok());
1204 let inm = headers
1205 .get(axum::http::header::IF_NONE_MATCH)
1206 .and_then(|v| v.to_str().ok());
1207 let body = serde_json::to_vec(&v).unwrap_or_default();
1208 let etag = crate::compress::etag_for(&body);
1209 crate::compress::encode_response(
1210 StatusCode::OK,
1211 "application/json",
1212 bytes::Bytes::from(body),
1213 Some(&etag),
1214 ae,
1215 inm,
1216 &[],
1217 )
1218}
1219
1220async fn vcs_diff_raw(State(st): State<Arc<AppState>>) -> Response {
1224 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1225 let (text, truncated) = run_blocking(&st.db, move |_db| {
1226 Ok::<_, anyhow::Error>(vcs::diff_raw_bounded(&dir))
1227 })
1228 .await
1229 .unwrap_or_else(|_| (String::new(), false));
1230 Response::builder()
1231 .header(
1232 axum::http::header::CONTENT_TYPE,
1233 "text/plain; charset=utf-8",
1234 )
1235 .header(axum::http::header::VARY, "Accept-Encoding")
1236 .header("x-ocserve-truncated", if truncated { "1" } else { "0" })
1237 .body(axum::body::Body::from(text))
1238 .expect("static response")
1239}
1240
1241async fn file_status() -> impl IntoResponse {
1246 axum::Json(Value::Array(vec![]))
1247}
1248
1249async fn find_symbol() -> impl IntoResponse {
1252 axum::Json(Value::Array(vec![]))
1253}
1254
1255async fn global_config_get(
1259 State(st): State<Arc<AppState>>,
1260 headers: axum::http::HeaderMap,
1261) -> Response {
1262 let ae = headers
1263 .get(axum::http::header::ACCEPT_ENCODING)
1264 .and_then(|v| v.to_str().ok());
1265 let inm = headers
1266 .get(axum::http::header::IF_NONE_MATCH)
1267 .and_then(|v| v.to_str().ok());
1268 wire_json_h(&st, "global_config", ae, inm).unwrap_or_else(|| {
1271 let mut v = st.payloads.read().config.clone();
1272 if let Some(obj) = v.as_object_mut() {
1273 for k in ["agent", "command", "mode", "username"] {
1274 obj.remove(k);
1275 }
1276 }
1277 let body = serde_json::to_vec(&v).unwrap_or_default();
1278 let etag = crate::compress::etag_for(&body);
1279 crate::compress::encode_response(
1280 StatusCode::OK,
1281 "application/json",
1282 bytes::Bytes::from(body),
1283 Some(&etag),
1284 ae,
1285 inm,
1286 &[],
1287 )
1288 })
1289}
1290
1291pub const PAGE_MEMO_ENTRY_CAP: usize = 4 * 1024 * 1024;
1305pub const PAGE_MEMO_MAX_ENTRIES: usize = 2048;
1312pub const PAGE_MEMO_MAX_BYTES: usize = 64 * 1024 * 1024;
1313
1314struct PageEntry {
1315 body: bytes::Bytes,
1316 next: Option<String>,
1317}
1318
1319struct PageMemoState {
1320 epoch: u64,
1321 entries: std::collections::VecDeque<(String, PageEntry)>,
1322 bytes: usize,
1323}
1324
1325impl PageMemoState {
1326 fn new() -> Self {
1327 Self {
1328 epoch: 0,
1329 entries: std::collections::VecDeque::new(),
1330 bytes: 0,
1331 }
1332 }
1333 fn get(&mut self, key: &str, epoch: u64) -> Option<PageEntry> {
1334 if self.epoch != epoch {
1335 self.entries.clear();
1336 self.bytes = 0;
1337 self.epoch = epoch;
1338 }
1339 self.entries
1340 .iter()
1341 .find(|(k, _)| k == key)
1342 .map(|(_, e)| PageEntry {
1343 body: e.body.clone(),
1344 next: e.next.clone(),
1345 })
1346 }
1347 fn put(&mut self, key: String, entry: PageEntry, epoch: u64) {
1348 if entry.body.len() > PAGE_MEMO_ENTRY_CAP.min(PAGE_MEMO_MAX_BYTES) {
1349 return;
1350 }
1351 if self.epoch != epoch {
1352 self.entries.clear();
1353 self.bytes = 0;
1354 self.epoch = epoch;
1355 }
1356 if let Some(pos) = self.entries.iter().position(|(k, _)| k == &key) {
1358 let (_, old) = self.entries.remove(pos).expect("position found");
1359 self.bytes -= old.body.len();
1360 }
1361 let budget = std::env::var("OCSERVE_PAGE_MEMO_MB")
1369 .ok()
1370 .and_then(|v| v.parse::<usize>().ok())
1371 .map(|mb| mb * 1024 * 1024)
1372 .unwrap_or(PAGE_MEMO_MAX_BYTES)
1373 .min(PAGE_MEMO_MAX_BYTES);
1374 self.bytes += entry.body.len();
1375 self.entries.push_back((key, entry));
1376 while self.entries.len() > PAGE_MEMO_MAX_ENTRIES
1377 || (self.bytes > budget && self.entries.len() > 1)
1378 {
1379 if let Some((_, old)) = self.entries.pop_front() {
1380 self.bytes -= old.body.len();
1381 } else {
1382 break;
1383 }
1384 }
1385 }
1386 fn len(&self) -> usize {
1387 self.entries.len()
1388 }
1389}
1390
1391static PAGE_MEMO: std::sync::OnceLock<parking_lot::Mutex<PageMemoState>> =
1392 std::sync::OnceLock::new();
1393static PAGE_MEMO_HITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1394static PAGE_MEMO_MISSES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1395
1396fn page_memo() -> &'static parking_lot::Mutex<PageMemoState> {
1397 PAGE_MEMO.get_or_init(|| parking_lot::Mutex::new(PageMemoState::new()))
1398}
1399
1400pub fn page_memo_enabled() -> bool {
1401 std::env::var("OCSERVE_PAGE_MEMO")
1402 .map(|v| v != "0")
1403 .unwrap_or(true)
1404}
1405
1406pub fn page_memo_stats() -> (u64, u64, usize) {
1408 (
1409 PAGE_MEMO_HITS.load(std::sync::atomic::Ordering::Relaxed),
1410 PAGE_MEMO_MISSES.load(std::sync::atomic::Ordering::Relaxed),
1411 page_memo().lock().len(),
1412 )
1413}
1414
1415fn page_response(
1420 headers: &axum::http::HeaderMap,
1421 uri: &axum::http::Uri,
1422 page_n: &Option<u64>,
1423 page_next: &Option<String>,
1424 body: axum::body::Body,
1425) -> Result<axum::response::Response, ApiError> {
1426 let mut builder = axum::response::Response::builder()
1427 .header(axum::http::header::CONTENT_TYPE, "application/json");
1428 if let (Some(n), Some(cur)) = (page_n, page_next) {
1429 let scheme = headers
1430 .get("x-forwarded-proto")
1431 .and_then(|v| v.to_str().ok())
1432 .unwrap_or("http");
1433 let host = headers
1434 .get("host")
1435 .and_then(|v| v.to_str().ok())
1436 .unwrap_or("localhost");
1437 let link = format!(
1438 "<{scheme}://{host}{}?limit={n}&before={cur}>; rel=\"next\"",
1439 uri.path()
1440 );
1441 builder = builder
1442 .header("x-next-cursor", cur.as_str())
1443 .header("link", link)
1444 .header("access-control-expose-headers", "Link, X-Next-Cursor");
1445 }
1446 builder.body(body).map_err(|e| ApiError {
1447 status: StatusCode::INTERNAL_SERVER_ERROR,
1448 name: "InternalError",
1449 message: format!("response build: {e}"),
1450 })
1451}
1452
1453async fn get_sessions(
1454 State(st): State<Arc<AppState>>,
1455 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1456 headers: axum::http::HeaderMap,
1457) -> axum::response::Response {
1458 let query = ocserve_store::SessionListQuery {
1464 limit: q
1465 .get("limit")
1466 .and_then(|v| v.parse::<usize>().ok())
1467 .filter(|n| *n > 0),
1468 roots: q.get("roots").map(|v| v == "true").unwrap_or(false),
1469 search: q.get("search").filter(|s| !s.is_empty()).cloned(),
1470 start: q.get("start").and_then(|v| v.parse::<i64>().ok()),
1471 directory: q.get("directory").filter(|s| !s.is_empty()).cloned(),
1472 scope_project: q.get("scope").map(|v| v == "project").unwrap_or(false),
1473 };
1474 let ae = headers
1475 .get(axum::http::header::ACCEPT_ENCODING)
1476 .and_then(|v| v.to_str().ok());
1477 let inm = headers
1478 .get(axum::http::header::IF_NONE_MATCH)
1479 .and_then(|v| v.to_str().ok());
1480 let qc = query.clone();
1481 match run_blocking(&st.db, move |db| {
1482 ocserve_store::load_sessions_wire_bytes_filtered(db, &qc)
1483 })
1484 .await
1485 {
1486 Ok(b) => {
1487 let etag = crate::compress::etag_for(&b);
1488 crate::compress::encode_response(
1489 axum::http::StatusCode::OK,
1490 "application/json",
1491 (*b).clone(),
1492 Some(&etag),
1493 ae,
1494 inm,
1495 &[],
1496 )
1497 }
1498 Err(e) => {
1499 tracing::error!("session list read failed: {e:#}");
1500 Json(Vec::<serde_json::Value>::new()).into_response()
1501 }
1502 }
1503}
1504
1505async fn get_session(
1506 State(st): State<Arc<AppState>>,
1507 axum::extract::Path(id): axum::extract::Path<String>,
1508) -> Result<impl IntoResponse, ApiError> {
1509 let wire_id = id.clone();
1510 run_blocking(&st.db, move |db| {
1511 ocserve_store::load_session_wire(db, &wire_id)
1512 })
1513 .await
1514 .map_err(|e| ApiError {
1515 status: StatusCode::INTERNAL_SERVER_ERROR,
1516 name: "InternalError",
1517 message: format!("{e:#}"),
1518 })?
1519 .map(Json)
1520 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
1521}
1522
1523async fn session_status(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1528 let mut out = serde_json::Map::new();
1529 let mut busy = |sid: &str| {
1530 out.insert(sid.to_string(), serde_json::json!({"type": "busy"}));
1531 };
1532 for (sid, arc) in st.prompt_locks.lock().iter() {
1533 if arc.try_lock().is_err() {
1534 busy(sid);
1535 }
1536 }
1537 for (sid, (_gen, handle)) in st.prompt_tasks.lock().iter() {
1538 if !handle.is_finished() {
1539 busy(sid);
1540 }
1541 }
1542 Json(serde_json::Value::Object(out))
1543}
1544
1545async fn get_path(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1547 Json(st.paths.clone())
1548}
1549
1550async fn get_projects(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1558 let now_ms = std::time::SystemTime::now()
1559 .duration_since(std::time::UNIX_EPOCH)
1560 .map(|d| d.as_millis() as i64)
1561 .unwrap_or(0);
1562 let mut dirs = ocserve_store::distinct_session_directories(&st.db).unwrap_or_default();
1563 let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
1565 if !dirs.iter().any(|d| d == &worktree) {
1566 dirs.insert(0, worktree);
1567 }
1568 let project = |id: &str, wt: &str| {
1569 json!({
1570 "id": id,
1571 "worktree": wt,
1572 "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
1573 "sandboxes": [],
1574 })
1575 };
1576 let mut out = vec![project("global", "/")];
1577 for d in dirs {
1578 out.push(project(&d, &d));
1579 }
1580 Json(Value::Array(out))
1581}
1582
1583async fn get_project_current(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1585 let now_ms = std::time::SystemTime::now()
1586 .duration_since(std::time::UNIX_EPOCH)
1587 .map(|d| d.as_millis() as i64)
1588 .unwrap_or(0);
1589 let worktree = st.paths["worktree"].as_str().unwrap_or("/");
1590 Json(json!({
1591 "id": "global",
1592 "worktree": worktree,
1593 "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
1594 "sandboxes": [],
1595 }))
1596}
1597
1598async fn metrics(State(_st): State<Arc<AppState>>) -> impl IntoResponse {
1600 ocserve_metrics::sample_rss();
1603 ocserve_metrics::gauge(
1612 "ocserve_splice_rows_total",
1613 ocserve_store::splice_rows() as i64,
1614 );
1615 ocserve_metrics::gauge(
1616 "ocserve_splice_fallbacks_total",
1617 ocserve_store::splice_fallbacks() as i64,
1618 );
1619 let (search_hits, search_misses, list_hits, list_misses, _pad, search_len, _epoch) =
1620 ocserve_store::memo_stats();
1621 ocserve_metrics::gauge("ocserve_memo_search_hits", search_hits as i64);
1622 ocserve_metrics::gauge("ocserve_memo_search_misses", search_misses as i64);
1623 ocserve_metrics::gauge("ocserve_memo_list_hits", list_hits as i64);
1624 ocserve_metrics::gauge("ocserve_memo_list_misses", list_misses as i64);
1625 ocserve_metrics::gauge("ocserve_memo_search_entries", search_len as i64);
1626 let body = ocserve_metrics::render();
1627 (
1628 [(
1629 axum::http::header::CONTENT_TYPE,
1630 "text/plain; version=0.0.4",
1631 )],
1632 body,
1633 )
1634}
1635
1636fn slug_for(id: &str) -> String {
1640 const A: &[&str] = &[
1641 "stellar", "amber", "quiet", "cobalt", "rapid", "lunar", "vivid", "crisp", "amber", "ivory",
1642 ];
1643 const B: &[&str] = &[
1644 "orchid", "falcon", "meadow", "canyon", "ember", "drift", "quartz", "harbor", "summit",
1645 "ripple",
1646 ];
1647 let h = id
1648 .bytes()
1649 .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
1650 format!(
1651 "{}-{}",
1652 A[(h % A.len() as u64) as usize],
1653 B[((h >> 16) % B.len() as u64) as usize]
1654 )
1655}
1656
1657fn now_ms() -> i64 {
1658 std::time::SystemTime::now()
1659 .duration_since(std::time::UNIX_EPOCH)
1660 .map(|d| d.as_millis() as i64)
1661 .unwrap_or(0)
1662}
1663
1664fn iso8601_z(ms: i64) -> String {
1667 let ms = ms.rem_euclid(86_400_000_000_000);
1668 let (days, rem) = (ms.div_euclid(86_400_000), ms.rem_euclid(86_400_000));
1669 let (h, rem) = (rem.div_euclid(3_600_000), rem.rem_euclid(3_600_000));
1670 let (mi, rem) = (rem.div_euclid(60_000), rem.rem_euclid(60_000));
1671 let (sec, milli) = (rem.div_euclid(1_000), rem.rem_euclid(1_000));
1672 let z = days + 719_468;
1674 let era = z.div_euclid(146_097);
1675 let doe = z.rem_euclid(146_097);
1676 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1677 let y = yoe + era * 400;
1678 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1679 let mp = (5 * doy + 2) / 153;
1680 let d = doy - (153 * mp + 2) / 5 + 1;
1681 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1682 let y = if m <= 2 { y + 1 } else { y };
1683 format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}.{milli:03}Z")
1684}
1685
1686pub fn js_to_date_string(ms: i64) -> String {
1690 const WDAY: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
1691 const MON: [&str; 12] = [
1692 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1693 ];
1694 let days = ms.div_euclid(86_400_000);
1695 let z = days + 719_468;
1696 let era = z.div_euclid(146_097);
1697 let doe = z.rem_euclid(146_097);
1698 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1699 let y = yoe + era * 400;
1700 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1701 let mp = (5 * doy + 2) / 153;
1702 let d = doy - (153 * mp + 2) / 5 + 1;
1703 let m = if mp < 10 { mp + 3 } else { mp - 9 } as usize;
1704 let y = if m <= 2 { y + 1 } else { y };
1705 let wd = (days + 4).rem_euclid(7) as usize;
1707 format!("{} {} {:02} {}", WDAY[wd], MON[m - 1], d, y)
1708}
1709
1710fn default_session_title(now_ms: i64) -> String {
1711 format!("New session - {}", iso8601_z(now_ms))
1712}
1713
1714pub fn forked_title(title: &str) -> String {
1719 const PAT: &str = " (fork #";
1720 if let Some(idx) = title.rfind(PAT)
1721 && idx > 0
1722 && let Some(num) = title[idx + PAT.len()..].strip_suffix(')')
1723 && !num.is_empty()
1724 && num.bytes().all(|b| b.is_ascii_digit())
1725 && let Ok(n) = num.parse::<u64>()
1726 {
1727 return format!("{} (fork #{})", &title[..idx], n + 1);
1728 }
1729 format!("{title} (fork #1)")
1730}
1731
1732pub fn start_plugin_event_pump(st: &Arc<AppState>) {
1736 let mut rx = st.bus.subscribe();
1741 let st = st.clone();
1742 tokio::spawn(async move {
1743 loop {
1744 match rx.recv().await {
1745 Ok(frame_str) => {
1746 let Some(plug) = st.plugins.get() else {
1747 continue; };
1749 let Ok(v) = serde_json::from_str::<Value>(&frame_str) else {
1750 continue;
1751 };
1752 let Some(payload) = v.get("payload").cloned() else {
1753 continue;
1754 };
1755 let mut guard = plug.lock().await;
1756 if let Err(e) = guard.emit_event(payload).await {
1757 tracing::debug!("plugin event pump: {e:#}");
1758 }
1759 }
1760 Err(ocserve_core::event::RecvError::Lagged(n)) => {
1761 tracing::warn!("plugin event pump lagged {n} frames (skipped)");
1762 }
1763 Err(ocserve_core::event::RecvError::Closed) => break,
1764 }
1765 }
1766 });
1767}
1768
1769async fn post_session(
1772 State(st): State<Arc<AppState>>,
1773 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1774 Json(body): Json<Value>,
1775) -> Result<impl IntoResponse, ApiError> {
1776 let resolved =
1777 resolve_create_dir_path(&st, &body, q.get("directory").map(String::as_str)).await;
1778 Ok(Json(create_session_record(&st, &body, resolved)?))
1779}
1780
1781async fn resolve_create_dir_path(
1784 st: &Arc<AppState>,
1785 body: &Value,
1786 query_dir: Option<&str>,
1787) -> (String, String) {
1788 let requested = resolve_create_dir(st, body, query_dir);
1789 let st = st.clone();
1790 tokio::task::spawn_blocking(move || freeze_dir_path(&requested))
1791 .await
1792 .unwrap_or_else(|_| {
1793 let d = resolve_create_dir(&st, body, query_dir);
1794 (d.clone(), d.trim_start_matches('/').to_string())
1795 })
1796}
1797
1798fn resolve_create_dir(st: &Arc<AppState>, body: &Value, query_dir: Option<&str>) -> String {
1804 query_dir
1805 .filter(|s| !s.is_empty())
1806 .map(String::from)
1807 .or_else(|| {
1808 body.pointer("/location/directory")
1809 .and_then(|d| d.as_str())
1810 .filter(|s| !s.is_empty())
1811 .map(String::from)
1812 })
1813 .unwrap_or_else(|| st.paths["worktree"].as_str().unwrap_or("/").to_string())
1814}
1815
1816fn freeze_dir_path(dir: &str) -> (String, String) {
1824 let dir = dir.trim_end_matches('/');
1825 if dir.is_empty() {
1829 return ("/".to_string(), String::new());
1830 }
1831 match git_toplevel(dir) {
1832 Some(root) => {
1833 let path = dir
1834 .strip_prefix(&root)
1835 .unwrap_or("")
1836 .trim_start_matches('/')
1837 .to_string();
1838 (root, path)
1839 }
1840 None => {
1841 let path = dir.trim_start_matches('/').to_string();
1842 (dir.to_string(), path)
1843 }
1844 }
1845}
1846
1847fn git_toplevel(dir: &str) -> Option<String> {
1850 let out = std::process::Command::new("git")
1851 .arg("-C")
1852 .arg(dir)
1853 .args(["rev-parse", "--show-toplevel"])
1854 .output()
1855 .ok()?;
1856 if !out.status.success() {
1857 return None;
1858 }
1859 let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
1860 (!root.is_empty()).then_some(root)
1861}
1862
1863pub(crate) fn create_session_record(
1868 st: &Arc<AppState>,
1869 body: &Value,
1870 (worktree, rel_path): (String, String),
1871) -> Result<Value, ApiError> {
1872 let id = ocserve_core::ids::ses_id();
1873 let now = now_ms();
1874 let title = body
1875 .get("title")
1876 .and_then(|t| t.as_str())
1877 .unwrap_or("")
1878 .to_string();
1879 let title = if title.trim().is_empty() {
1882 default_session_title(now)
1883 } else {
1884 title
1885 };
1886 let agent = body
1887 .get("agent")
1888 .and_then(|v| v.as_str())
1889 .filter(|s| !s.is_empty())
1890 .unwrap_or("build");
1891 let model = body.get("model").cloned().unwrap_or(Value::Null);
1892 let mut info = json!({
1893 "id": id,
1894 "projectID": "global",
1895 "directory": worktree,
1896 "path": rel_path,
1897 "slug": slug_for(&id),
1898 "title": title,
1899 "version": FREEZE_VERSION,
1900 "time": {"created": now, "updated": now},
1901 "cost": 0,
1902 "tokens": {"input": 0, "output": 0, "reasoning": 0,
1903 "cache": {"read": 0, "write": 0}},
1904 });
1905 if body.get("agent").is_some() {
1906 info["agent"] = json!(agent);
1907 }
1908 if model.is_object() {
1909 info["model"] = model;
1910 }
1911 ocserve_store::insert_session(&st.writer, &info).map_err(|e| ApiError {
1912 status: StatusCode::INTERNAL_SERVER_ERROR,
1913 name: "InternalError",
1914 message: format!("{e:#}"),
1915 })?;
1916 st.bus.publish(ocserve_core::event::frame(
1917 &worktree,
1918 "session.created",
1919 json!({"sessionID": id, "info": info}),
1920 ));
1921 Ok(info)
1922}
1923
1924async fn post_fork(
1932 State(st): State<Arc<AppState>>,
1933 axum::extract::Path(id): axum::extract::Path<String>,
1934 body: axum::body::Bytes,
1935) -> Result<Json<Value>, HttpError> {
1936 let upto: Option<String> = {
1937 let text = String::from_utf8_lossy(&body);
1938 if text.trim().is_empty() {
1939 None } else {
1941 let v: Value = serde_json::from_str(&text).map_err(|_| tagged_bad_request())?;
1942 if !v.is_object() {
1943 return Err(tagged_bad_request()); }
1945 match v.get("messageID") {
1946 None => None,
1947 Some(Value::String(s)) => Some(s.clone()),
1948 Some(_) => return Err(tagged_bad_request()), }
1950 }
1951 };
1952 let src_id = id.clone();
1953 let src = run_blocking(&st.db, move |db| {
1954 ocserve_store::load_session_wire(db, &src_id)
1955 })
1956 .await
1957 .map_err(|e| ApiError {
1958 status: StatusCode::INTERNAL_SERVER_ERROR,
1959 name: "InternalError",
1960 message: format!("{e:#}"),
1961 })?
1962 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))?;
1963
1964 let new_id = ocserve_core::ids::ses_id();
1965 let now = now_ms();
1966 let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
1967 let info = json!({
1971 "id": new_id,
1972 "projectID": "global",
1973 "directory": worktree,
1974 "path": worktree.trim_start_matches('/'),
1975 "slug": slug_for(&new_id),
1976 "title": forked_title(src["title"].as_str().unwrap_or("")),
1977 "version": FREEZE_VERSION,
1978 "time": {"created": now, "updated": now},
1979 "cost": 0,
1980 "tokens": {"input": 0, "output": 0, "reasoning": 0,
1981 "cache": {"read": 0, "write": 0}},
1982 });
1983 let writer = st.writer.clone();
1986 let blobs = st.blobs.clone();
1987 let db = st.db.clone();
1988 let fork_info = info.clone();
1989 let fork_id = id.clone();
1990 let fork_upto = upto.clone();
1991 let stats = run_blocking(&db, move |db| {
1992 let env = ocserve_store::fork::ForkEnv {
1993 writer: &writer,
1994 blobs: Some(&*blobs),
1995 db,
1996 };
1997 ocserve_store::fork_session(
1998 &env,
1999 &fork_info,
2000 &fork_id,
2001 fork_upto.as_deref(),
2002 ocserve_core::ids::msg_id,
2003 ocserve_core::ids::prt_id,
2004 )
2005 })
2006 .await
2007 .map_err(|e| ApiError {
2008 status: StatusCode::INTERNAL_SERVER_ERROR,
2009 name: "InternalError",
2010 message: format!("{e:#}"),
2011 })?;
2012 ocserve_metrics::counter("ocserve_session_fork_total", 1);
2013 tracing::info!(
2014 fork = %new_id,
2015 source = %id,
2016 upto = ?upto,
2017 messages = stats.messages,
2018 parts = stats.parts,
2019 "session forked"
2020 );
2021 st.bus.publish(ocserve_core::event::frame(
2026 st.paths["directory"].as_str().unwrap_or("/"),
2027 "session.created",
2028 json!({"sessionID": new_id, "info": info}),
2029 ));
2030 Ok(Json(info))
2031}
2032
2033async fn get_messages(
2035 State(st): State<Arc<AppState>>,
2036 axum::extract::Path(id): axum::extract::Path<String>,
2037 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2038 uri: axum::http::Uri,
2039 headers: axum::http::HeaderMap,
2040) -> Result<impl IntoResponse, HttpError> {
2041 let limit: Option<u64> = match q.get("limit") {
2045 None => None,
2046 Some(raw) if raw.is_empty() => None, Some(raw) => Some(parse_limit(raw).map_err(|message| HttpError::Query { message })?),
2048 };
2049 let before: Option<(String, i64)> = match (q.get("before").map(String::as_str), limit) {
2050 (Some(_), None) => return Err(tagged_bad_request()),
2051 (Some(b), Some(_)) => {
2052 Some(ocserve_store::decode_cursor(b).map_err(|_| tagged_bad_request())?)
2053 }
2054 (None, _) => None,
2055 };
2056 let exists_id = id.clone();
2057 if !run_blocking(&st.db, move |db| {
2058 ocserve_store::session_exists(db, &exists_id)
2059 })
2060 .await
2061 .map_err(|e| ApiError {
2062 status: StatusCode::INTERNAL_SERVER_ERROR,
2063 name: "InternalError",
2064 message: format!("{e:#}"),
2065 })? {
2066 return Err(ApiError::not_found(format!("Session not found: {id}")).into());
2067 }
2068 let mut page_n: Option<u64> = None;
2071 let mut page_next: Option<String> = None;
2072 let walk: ocserve_store::MessageWalk = match limit.filter(|n| *n > 0) {
2073 Some(n) => {
2074 let page_id = id.clone();
2075 let before_owned = before.clone();
2076 let (rows, _more, next) = run_blocking(&st.db, move |db| {
2077 ocserve_store::page_messages(
2078 db,
2079 &page_id,
2080 n,
2081 before_owned.as_ref().map(|(b, t)| (b.as_str(), *t)),
2082 )
2083 })
2084 .await
2085 .map_err(|e| ApiError {
2086 status: StatusCode::INTERNAL_SERVER_ERROR,
2087 name: "InternalError",
2088 message: format!("{e:#}"),
2089 })?;
2090 page_n = Some(n);
2091 page_next = next;
2092 ocserve_store::MessageWalk::Window(rows)
2093 }
2094 None => ocserve_store::MessageWalk::Seq { limit: None },
2095 };
2096 if page_n.is_some() && page_memo_enabled() {
2100 let memo_key = format!("{}\u{1}{id}\u{1}{limit:?}\u{1}{before:?}", st.db.display());
2101 let e0 = ocserve_store::write_epoch();
2102 {
2103 let mut m = page_memo().lock();
2104 if let Some(e) = m.get(&memo_key, e0) {
2105 drop(m);
2106 PAGE_MEMO_HITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2107 return page_response(
2108 &headers,
2109 &uri,
2110 &page_n,
2111 &page_next,
2112 axum::body::Body::from(e.body),
2113 )
2114 .map_err(Into::into);
2115 }
2116 }
2117 PAGE_MEMO_MISSES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2118 let walk_asm = walk.clone();
2122 let db_asm = st.db.clone();
2123 let sid_asm = id.clone();
2124 let asm: anyhow::Result<std::result::Result<Vec<u8>, ()>> =
2125 run_blocking(&db_asm, move |db| {
2126 let mut buf: Vec<u8> = Vec::new();
2127 let mut total = 0usize;
2128 let mut first = true;
2129 let r = ocserve_store::for_each_message_json(db, &sid_asm, walk_asm, |chunk| {
2130 let framed = if first {
2131 first = false;
2132 format!("[{chunk}")
2133 } else {
2134 format!(",{chunk}")
2135 };
2136 if total + framed.len() > PAGE_MEMO_ENTRY_CAP {
2137 return Err(anyhow::anyhow!("page memo entry cap"));
2138 }
2139 total += framed.len();
2140 buf.extend_from_slice(framed.as_bytes());
2141 Ok(())
2142 });
2143 match r {
2144 Ok(()) => {
2145 if first {
2148 buf.extend_from_slice(b"[]");
2149 } else {
2150 buf.push(b']');
2151 }
2152 Ok(Ok(buf))
2153 }
2154 Err(e) if e.to_string().contains("entry cap") => Ok(Err(())),
2155 Err(e) => Err(e),
2156 }
2157 })
2158 .await;
2159 if let Ok(Ok(buf)) = asm {
2160 let bytes = bytes::Bytes::from(buf);
2161 let entry = PageEntry {
2162 body: bytes.clone(),
2163 next: page_next.clone(),
2164 };
2165 page_memo().lock().put(memo_key, entry, e0);
2166 return page_response(
2167 &headers,
2168 &uri,
2169 &page_n,
2170 &page_next,
2171 axum::body::Body::from(bytes),
2172 )
2173 .map_err(Into::into);
2174 }
2175 }
2177 let (tx, rx) = tokio::sync::mpsc::channel::<String>(4);
2182 let db = st.db.clone();
2183 let sid = id.clone();
2184 tokio::task::spawn_blocking(move || {
2190 let mut first = true;
2191 let r = ocserve_store::for_each_message_json(&db, &sid, walk, |chunk| {
2192 let framed = if first {
2193 first = false;
2194 format!("[{chunk}")
2195 } else {
2196 format!(",{chunk}")
2197 };
2198 tx.blocking_send(framed)
2199 .map_err(|_| anyhow::anyhow!("client disconnected"))
2200 });
2201 if let Err(e) = r {
2202 tracing::error!("message stream aborted session={sid}: {e:#}");
2203 }
2204 let _ = tx.blocking_send(if first {
2206 "[]".to_string()
2207 } else {
2208 "]".to_string()
2209 });
2210 });
2211 let body_stream = futures_util::stream::unfold(rx, |mut rx| async move {
2212 rx.recv().await.map(|chunk| {
2213 (
2214 Ok::<axum::body::Bytes, std::convert::Infallible>(axum::body::Bytes::from(chunk)),
2215 rx,
2216 )
2217 })
2218 });
2219 page_response(
2220 &headers,
2221 &uri,
2222 &page_n,
2223 &page_next,
2224 axum::body::Body::from_stream(body_stream),
2225 )
2226 .map_err(Into::into)
2227}
2228
2229async fn patch_config(
2237 State(st): State<Arc<AppState>>,
2238 Json(payload): Json<Value>,
2239) -> Result<Json<Value>, ApiError> {
2240 let path = config_write_path();
2241 let mut base: Value = if path.exists() {
2242 let raw = std::fs::read_to_string(&path).map_err(|e| ApiError {
2243 status: StatusCode::INTERNAL_SERVER_ERROR,
2244 name: "InternalError",
2245 message: format!("read {}: {e}", path.display()),
2246 })?;
2247 serde_json::from_str(&raw).map_err(|e| ApiError {
2248 status: StatusCode::INTERNAL_SERVER_ERROR,
2249 name: "InternalError",
2250 message: format!("parse {}: {e}", path.display()),
2251 })?
2252 } else {
2253 json!({})
2254 };
2255 deep_merge(&mut base, payload.clone());
2256 let dir = path.parent().ok_or_else(|| ApiError {
2257 status: StatusCode::INTERNAL_SERVER_ERROR,
2258 name: "InternalError",
2259 message: "config path has no parent".into(),
2260 })?;
2261 std::fs::create_dir_all(dir).map_err(|e| ApiError {
2262 status: StatusCode::INTERNAL_SERVER_ERROR,
2263 name: "InternalError",
2264 message: format!("mkdir {}: {e}", dir.display()),
2265 })?;
2266 let tmp = dir.join(format!(".opencode.json.tmp-{}", std::process::id()));
2267 let data = serde_json::to_vec_pretty(&base).map_err(|e| ApiError {
2268 status: StatusCode::INTERNAL_SERVER_ERROR,
2269 name: "InternalError",
2270 message: format!("serialize: {e}"),
2271 })?;
2272 std::fs::write(&tmp, &data).map_err(|e| ApiError {
2273 status: StatusCode::INTERNAL_SERVER_ERROR,
2274 name: "InternalError",
2275 message: format!("write {}: {e}", tmp.display()),
2276 })?;
2277 if path.exists() {
2278 let _ = std::fs::copy(&path, dir.join(".opencode.json.bak"));
2279 }
2280 std::fs::rename(&tmp, &path).map_err(|e| ApiError {
2281 status: StatusCode::INTERNAL_SERVER_ERROR,
2282 name: "InternalError",
2283 message: format!("rename {}: {e}", path.display()),
2284 })?;
2285 if let Err(e) = watch::reconcile(&st).await {
2289 tracing::warn!("config reload failed (restart to apply): {e:#}");
2290 }
2291 tracing::info!("config patched: {}", path.display());
2292 Ok(Json(payload))
2293}
2294
2295async fn post_summarize(
2302 State(st): State<Arc<AppState>>,
2303 axum::extract::Path(sid): axum::extract::Path<String>,
2304 Json(payload): Json<Value>,
2305) -> Result<Json<Value>, ApiError> {
2306 let exists_id = sid.clone();
2307 if !run_blocking(&st.db, move |db| {
2308 ocserve_store::session_exists(db, &exists_id)
2309 })
2310 .await
2311 .map_err(|e| ApiError {
2312 status: StatusCode::INTERNAL_SERVER_ERROR,
2313 name: "InternalError",
2314 message: format!("{e:#}"),
2315 })? {
2316 return Err(ApiError::not_found(format!("Session not found: {sid}")));
2317 }
2318 let provider = payload
2319 .get("providerID")
2320 .and_then(|v| v.as_str())
2321 .filter(|s| !s.is_empty());
2322 let model_id = payload
2323 .get("modelID")
2324 .and_then(|v| v.as_str())
2325 .filter(|s| !s.is_empty());
2326 let (Some(provider), Some(model_id)) = (provider, model_id) else {
2327 return Err(ApiError {
2328 status: StatusCode::BAD_REQUEST,
2329 name: "BadRequest",
2330 message: "providerID and modelID are required".into(),
2331 });
2332 };
2333
2334 let release = lock_session(&st, &sid).await?;
2335 let Ok(_guard) = release.arc().try_lock() else {
2336 return Err(session_busy(&sid));
2337 };
2338
2339 let history = run_blocking(&st.db, {
2347 let sid = sid.clone();
2348 move |db| ocserve_store::load_messages(db, &sid, None) })
2350 .await
2351 .map_err(|e| ApiError {
2352 status: StatusCode::INTERNAL_SERVER_ERROR,
2353 name: "InternalError",
2354 message: format!("{e:#}"),
2355 })?;
2356 let agent = history
2357 .iter()
2358 .rev()
2359 .find(|(info, _)| info["role"] == "user")
2360 .and_then(|(info, _)| info["agent"].as_str())
2361 .unwrap_or("build")
2362 .to_string();
2363 let prompt_payload = ocserve_core::wire::PromptRequest {
2364 model: Some(ocserve_core::wire::ModelRef {
2365 provider_id: provider.to_string(),
2366 model_id: model_id.to_string(),
2367 }),
2368 agent: Some(agent.clone()),
2369 ..Default::default()
2370 };
2371 let ctx = build_prompt_context(&st, &prompt_payload, &sid)?;
2372 let writer = st.writer.clone();
2373 ocserve_core::compaction::persist_anchor(&ctx, &writer, &sid, &agent, false, false)
2374 .await
2375 .map_err(|e| ApiError {
2376 status: StatusCode::INTERNAL_SERVER_ERROR,
2377 name: "InternalError",
2378 message: format!("{e:#}"),
2379 })?;
2380 ocserve_core::prompt::run_prompt_with(
2381 &ctx,
2382 &writer,
2383 &sid,
2384 &prompt_payload,
2385 ocserve_core::prompt::RunOpts {
2386 persist_user: false,
2391 skip_history: false,
2392 prelude: None,
2393 tools_enabled: false,
2394 ..Default::default()
2395 },
2396 )
2397 .await
2398 .map_err(prompt_err)?;
2399 st.bus.publish(ocserve_core::event::frame(
2401 st.paths["directory"].as_str().unwrap_or("/"),
2402 "session.compacted",
2403 json!({"sessionID": sid}),
2404 ));
2405 Ok(Json(json!(true)))
2406}
2407
2408pub(crate) async fn run_compact(
2411 st: &Arc<AppState>,
2412 ctx: &ocserve_core::prompt::PromptContext,
2413 writer: &ocserve_store::Writer,
2414 sid: &str,
2415) -> anyhow::Result<()> {
2416 let agent = ctx.agent.clone();
2417 let prompt_payload = ocserve_core::wire::PromptRequest {
2418 model: Some(ocserve_core::wire::ModelRef {
2419 provider_id: ctx.provider_id.clone(),
2420 model_id: ctx.model_id.clone(),
2421 }),
2422 agent: Some(agent.clone()),
2423 ..Default::default()
2424 };
2425 ocserve_core::compaction::persist_anchor(ctx, writer, sid, &agent, false, false).await?;
2426 ocserve_core::prompt::run_prompt_with(
2427 ctx,
2428 writer,
2429 sid,
2430 &prompt_payload,
2431 ocserve_core::prompt::RunOpts {
2432 persist_user: false,
2433 skip_history: false,
2434 prelude: None,
2435 tools_enabled: false,
2436 ..Default::default()
2437 },
2438 )
2439 .await?;
2440 st.bus.publish(ocserve_core::event::frame(
2441 st.paths["directory"].as_str().unwrap_or("/"),
2442 "session.compacted",
2443 json!({"sessionID": sid}),
2444 ));
2445 Ok(())
2446}
2447
2448fn mcp_not_found(name: &str) -> HttpError {
2454 HttpError::TaggedData {
2455 status: StatusCode::NOT_FOUND,
2456 tag: "McpServerNotFoundError",
2457 fields: json!({"name": name, "message": format!("MCP server not found: {name}")}),
2458 }
2459}
2460
2461async fn mcp_action(
2462 State(st): State<Arc<AppState>>,
2463 axum::extract::Path((name, action)): axum::extract::Path<(String, String)>,
2464) -> Result<Json<Value>, HttpError> {
2465 let hub = st.mcp.get().cloned().ok_or_else(|| HttpError::TaggedData {
2466 status: StatusCode::SERVICE_UNAVAILABLE,
2467 tag: "McpUnavailable",
2468 fields: json!({"message": "MCP hub not initialized"}),
2469 })?;
2470 let res = match action.as_str() {
2471 "connect" => hub.connect(&name).await,
2472 "disconnect" => hub.disconnect(&name).await,
2473 _ => Err(anyhow::anyhow!("unknown MCP action: {action}")),
2474 };
2475 res.map_err(|e| {
2476 if e.to_string().contains("not found") {
2477 mcp_not_found(&name)
2478 } else {
2479 HttpError::Api(ApiError {
2480 status: StatusCode::INTERNAL_SERVER_ERROR,
2481 name: "InternalError",
2482 message: format!("{e:#}"),
2483 })
2484 }
2485 })?;
2486 Ok(Json(json!(true)))
2487}
2488
2489async fn mcp_auth_remove(
2490 State(st): State<Arc<AppState>>,
2491 axum::extract::Path(name): axum::extract::Path<String>,
2492) -> Result<Json<Value>, HttpError> {
2493 let known = st
2495 .mcp
2496 .get()
2497 .map(|h| {
2498 let m = h.statuses();
2499 m.as_object()
2500 .map(|o| o.contains_key(&name))
2501 .unwrap_or(false)
2502 || h.known(&name)
2503 })
2504 .unwrap_or(false);
2505 if !known {
2506 return Err(mcp_not_found(&name));
2507 }
2508 Ok(Json(json!({"success": true})))
2511}
2512
2513fn auth_overlay_path(st: &AppState) -> std::path::PathBuf {
2516 st.db
2517 .parent()
2518 .unwrap_or(std::path::Path::new("."))
2519 .join("auth-overlay.json")
2520}
2521
2522fn write_json_atomic(path: &std::path::Path, value: &Value) -> Result<(), ApiError> {
2523 let dir = path.parent().ok_or_else(|| ApiError {
2524 status: StatusCode::INTERNAL_SERVER_ERROR,
2525 name: "InternalError",
2526 message: "path has no parent".into(),
2527 })?;
2528 std::fs::create_dir_all(dir).map_err(|e| ApiError {
2529 status: StatusCode::INTERNAL_SERVER_ERROR,
2530 name: "InternalError",
2531 message: format!("mkdir: {e}"),
2532 })?;
2533 let tmp = dir.join(format!(".tmp-{}-auth", std::process::id()));
2534 let data = serde_json::to_vec_pretty(value).map_err(|e| ApiError {
2535 status: StatusCode::INTERNAL_SERVER_ERROR,
2536 name: "InternalError",
2537 message: format!("serialize: {e}"),
2538 })?;
2539 std::fs::write(&tmp, data).map_err(|e| ApiError {
2540 status: StatusCode::INTERNAL_SERVER_ERROR,
2541 name: "InternalError",
2542 message: format!("write: {e}"),
2543 })?;
2544 std::fs::rename(&tmp, path).map_err(|e| ApiError {
2545 status: StatusCode::INTERNAL_SERVER_ERROR,
2546 name: "InternalError",
2547 message: format!("rename: {e}"),
2548 })?;
2549 Ok(())
2550}
2551
2552async fn auth_put(
2553 State(st): State<Arc<AppState>>,
2554 axum::extract::Path(pid): axum::extract::Path<String>,
2555 Json(body): Json<Value>,
2556) -> Result<Json<Value>, ApiError> {
2557 if !body.is_object() {
2558 return Err(ApiError {
2559 status: StatusCode::BAD_REQUEST,
2560 name: "BadRequest",
2561 message: "auth payload must be an object".into(),
2562 });
2563 }
2564 let path = auth_overlay_path(&st);
2565 let mut overlay: Value = if path.exists() {
2566 std::fs::read_to_string(&path)
2567 .ok()
2568 .and_then(|r| serde_json::from_str(&r).ok())
2569 .unwrap_or_else(|| json!({}))
2570 } else {
2571 json!({})
2572 };
2573 overlay[&pid] = body; write_json_atomic(&path, &overlay)?;
2575 if let Err(e) = watch::reconcile(&st).await {
2576 tracing::warn!("payload reload failed: {e:#}");
2577 }
2578 tracing::info!("auth overlay set provider={pid}");
2579 Ok(Json(json!(true)))
2580}
2581
2582async fn auth_delete(
2583 State(st): State<Arc<AppState>>,
2584 axum::extract::Path(pid): axum::extract::Path<String>,
2585) -> Result<Json<Value>, ApiError> {
2586 let path = auth_overlay_path(&st);
2587 if path.exists() {
2588 let mut overlay: Value = std::fs::read_to_string(&path)
2589 .ok()
2590 .and_then(|r| serde_json::from_str(&r).ok())
2591 .unwrap_or_else(|| json!({}));
2592 if let Some(obj) = overlay.as_object_mut() {
2593 obj.remove(&pid);
2594 }
2595 write_json_atomic(&path, &overlay)?;
2596 if let Err(e) = watch::reconcile(&st).await {
2597 tracing::warn!("payload reload failed: {e:#}");
2598 }
2599 }
2600 tracing::info!("auth overlay remove provider={pid}");
2601 Ok(Json(json!(true)))
2602}
2603
2604async fn provider_auth_methods(State(st): State<Arc<AppState>>) -> Json<Value> {
2609 let payloads = st.payloads.read();
2610 let mut out = serde_json::Map::new();
2611 if let Some(list) = payloads
2614 .config_providers
2615 .get("providers")
2616 .and_then(|p| p.as_array())
2617 {
2618 for e in list {
2619 if let Some(pid) = e.get("id").and_then(|v| v.as_str()) {
2620 out.insert(
2621 pid.to_string(),
2622 json!([{"type": "api", "label": "API Key"}]),
2623 );
2624 }
2625 }
2626 }
2627 Json(serde_json::Value::Object(out))
2628}
2629
2630async fn global_dispose(State(st): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
2635 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
2637 let props = json!({"directory": dir});
2638 ocserve_store::append_event(&st.writer, None, "server.instance.disposed", &props).map_err(
2639 |e| ApiError {
2640 status: StatusCode::INTERNAL_SERVER_ERROR,
2641 name: "InternalError",
2642 message: format!("{e:#}"),
2643 },
2644 )?;
2645 st.bus.publish(ocserve_core::event::frame(
2646 &dir,
2647 "server.instance.disposed",
2648 props,
2649 ));
2650 if let Err(e) = watch::reconcile(&st).await {
2651 tracing::warn!("payload reload failed: {e:#}");
2652 }
2653 tracing::info!("global dispose: emitted + payloads reloaded");
2654 Ok(Json(json!(true)))
2655}
2656
2657async fn post_revert(
2663 State(st): State<Arc<AppState>>,
2664 axum::extract::Path(id): axum::extract::Path<String>,
2665 Json(payload): Json<Value>,
2666) -> Result<axum::response::Response, ApiError> {
2667 let exists_id = id.clone();
2668 if !run_blocking(&st.db, move |db| {
2669 ocserve_store::session_exists(db, &exists_id)
2670 })
2671 .await
2672 .map_err(internal_err)?
2673 {
2674 return Err(ApiError::not_found(format!("Session not found: {id}")));
2675 }
2676 let message_id = payload
2677 .get("messageID")
2678 .and_then(|v| v.as_str())
2679 .unwrap_or("")
2680 .to_string();
2681 if message_id.is_empty() {
2682 return Ok((
2685 StatusCode::BAD_REQUEST,
2686 axum::Json(json!({
2687 "name": "BadRequest",
2688 "data": {"message": "Missing key\n at [\"messageID\"]", "kind": "Payload"}
2689 })),
2690 )
2691 .into_response());
2692 }
2693 let part_id = payload
2694 .get("partID")
2695 .and_then(|v| v.as_str())
2696 .filter(|p| !p.is_empty())
2697 .map(String::from);
2698
2699 let release = lock_session(&st, &id).await?;
2701 let Ok(_guard) = release.arc().try_lock() else {
2702 return Err(session_busy(&id));
2703 };
2704
2705 let marker = revert_apply(&st, &id, &message_id, part_id.as_deref()).await?;
2706 let _ = marker;
2707 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
2708 let _ = dir;
2709 let sid = id.clone();
2710 run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
2711 .await
2712 .map_err(internal_err)?
2713 .map(|j| axum::Json(j).into_response())
2714 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
2715}
2716
2717pub(crate) fn internal_err(e: anyhow::Error) -> ApiError {
2718 ApiError {
2719 status: StatusCode::INTERNAL_SERVER_ERROR,
2720 name: "InternalError",
2721 message: format!("{e:#}"),
2722 }
2723}
2724
2725pub(crate) async fn revert_apply(
2728 st: &Arc<AppState>,
2729 id: &str,
2730 message_id: &str,
2731 part_id: Option<&str>,
2732) -> Result<ocserve_core::revert::Marker, ApiError> {
2733 let db = st.db.clone();
2734 let sid = id.to_string();
2735 let mid = message_id.to_string();
2736 let pid = part_id.map(String::from);
2737 let messages = run_blocking(&db, move |db| {
2739 ocserve_store::load_messages(db, &sid, None) })
2741 .await
2742 .map_err(internal_err)?;
2743 let target = ocserve_core::revert::resolve_target(&messages, &mid, pid.as_deref());
2744 let dir = session_directory_http(st, id).await;
2745 let snapshots = snapshots_for(st, &dir);
2746
2747 let existing = run_blocking(&st.db, {
2749 let sid = id.to_string();
2750 move |db| ocserve_store::load_session_wire(db, &sid)
2751 })
2752 .await
2753 .map_err(internal_err)?
2754 .and_then(|s| {
2755 s.get("revert")
2756 .and_then(|r| r.get("snapshot"))
2757 .and_then(|v| v.as_str())
2758 .map(String::from)
2759 });
2760
2761 let mut marker = ocserve_core::revert::Marker {
2762 message_id: target
2763 .as_ref()
2764 .map(|t| t.0.clone())
2765 .unwrap_or_else(|| mid.clone()),
2766 part_id: target.as_ref().and_then(|t| t.1.clone()),
2767 snapshot: existing.clone(),
2768 diff: None,
2769 };
2770 if let Some(snap) = snapshots.clone() {
2771 let prev = marker.snapshot.clone();
2772 let (snapshot, _patches) = run_blocking(&st.db, {
2773 let msgs = messages.clone();
2774 let snap = snap.clone();
2775 let prev = prev.clone();
2776 move |_db| {
2777 let snapshot = match &prev {
2778 Some(s) => s.clone(),
2779 None => snap.track().unwrap_or_default(),
2780 };
2781 if let Some(p) = &prev {
2782 snap.restore(p);
2783 }
2784 let patches = ocserve_core::revert::collect_patches(&msgs);
2785 snap.revert(&patches);
2786 Ok::<_, anyhow::Error>((snapshot, patches))
2787 }
2788 })
2789 .await
2790 .map_err(internal_err)?;
2791 if !snapshot.is_empty() {
2792 marker.snapshot = Some(snapshot.clone());
2793 let diff = run_blocking(&st.db, {
2794 let snap = snap.clone();
2795 let s = snapshot.clone();
2796 move |_db| Ok::<_, anyhow::Error>(snap.diff(&s))
2797 })
2798 .await
2799 .map_err(internal_err)?;
2800 if !diff.is_empty() {
2801 marker.diff = Some(diff);
2802 }
2803 }
2804 }
2805
2806 let range: Vec<(Value, Vec<Value>)> = match &target {
2808 Some((tmid, _)) => messages
2809 .iter()
2810 .skip_while(|(info, _)| info["id"].as_str() != Some(tmid.as_str()))
2811 .cloned()
2812 .collect(),
2813 None => Vec::new(),
2814 };
2815 let (diffs, summary) = match snapshots.clone() {
2816 Some(snap) => run_blocking(&st.db, move |_db| {
2817 Ok::<_, anyhow::Error>(ocserve_core::revert::compute_range(&snap, &range))
2818 })
2819 .await
2820 .map_err(internal_err)?,
2821 None => (
2822 Vec::new(),
2823 json!({"additions": 0, "deletions": 0, "files": 0}),
2824 ),
2825 };
2826
2827 let adds = summary["additions"].as_i64().unwrap_or(0);
2829 let dels = summary["deletions"].as_i64().unwrap_or(0);
2830 let files = summary["files"].as_i64().unwrap_or(0);
2831 ocserve_store::set_session_summary(&st.writer, id, adds, dels, files).map_err(internal_err)?;
2832 if let Some((tmid, _)) = &target
2833 && let Some((info, _)) = messages
2834 .iter()
2835 .find(|(i, _)| i["id"].as_str() == Some(tmid.as_str()))
2836 {
2837 let mut info = info.clone();
2838 info["summary"] = json!({"diffs": diffs});
2839 ocserve_store::update_message_info(&st.writer, id, tmid, &info).map_err(internal_err)?;
2840 }
2841 ocserve_store::set_session_revert(&st.writer, id, Some(&marker.to_value()))
2842 .map_err(internal_err)?;
2843
2844 let diff_val = Value::Array(diffs.clone());
2846 emit_durable_http(
2847 st,
2848 id,
2849 "session.diff",
2850 json!({"sessionID": id, "diff": diff_val}),
2851 )?;
2852 let info = session_wire_or_partial_http(st, id, json!({"revert": marker.to_value()})).await;
2853 emit_durable_http(
2854 st,
2855 id,
2856 "session.updated",
2857 json!({"sessionID": id, "info": info}),
2858 )?;
2859 Ok(marker)
2860}
2861
2862async fn post_unrevert(
2864 State(st): State<Arc<AppState>>,
2865 axum::extract::Path(id): axum::extract::Path<String>,
2866) -> Result<Json<Value>, ApiError> {
2867 let exists_id = id.clone();
2868 if !run_blocking(&st.db, move |db| {
2869 ocserve_store::session_exists(db, &exists_id)
2870 })
2871 .await
2872 .map_err(internal_err)?
2873 {
2874 return Err(ApiError::not_found(format!("Session not found: {id}")));
2875 }
2876 let release = lock_session(&st, &id).await?;
2877 let Ok(_guard) = release.arc().try_lock() else {
2878 return Err(session_busy(&id));
2879 };
2880 let existing = run_blocking(&st.db, {
2882 let sid = id.to_string();
2883 move |db| ocserve_store::load_session_wire(db, &sid)
2884 })
2885 .await
2886 .map_err(internal_err)?
2887 .and_then(|s| {
2888 s.get("revert")
2889 .and_then(|r| r.get("snapshot"))
2890 .and_then(|v| v.as_str())
2891 .map(String::from)
2892 });
2893 let dir = session_directory_http(&st, &id).await;
2894 if let Some(snap) = snapshots_for(&st, &dir)
2895 && let Some(s) = existing
2896 {
2897 let _ = run_blocking(&st.db, move |_db| {
2898 snap.restore(&s);
2899 Ok::<_, anyhow::Error>(())
2900 })
2901 .await;
2902 }
2903 ocserve_store::set_session_revert(&st.writer, &id, None).map_err(internal_err)?;
2904 let info = session_wire_or_partial_http(&st, &id, json!({"revert": Value::Null})).await;
2905 emit_durable_http(
2906 &st,
2907 &id,
2908 "session.updated",
2909 json!({"sessionID": id, "info": info}),
2910 )?;
2911 let sid = id.clone();
2912 run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
2913 .await
2914 .map_err(internal_err)?
2915 .map(Json)
2916 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
2917}
2918
2919pub(crate) async fn session_directory_http(st: &Arc<AppState>, id: &str) -> String {
2921 let sid = id.to_string();
2922 run_blocking(&st.db, move |db| {
2923 Ok::<_, anyhow::Error>(ocserve_store::session_directory(db, &sid).ok().flatten())
2924 })
2925 .await
2926 .ok()
2927 .flatten()
2928 .unwrap_or_else(|| st.paths["directory"].as_str().unwrap_or("/").to_string())
2929}
2930
2931async fn instance_dispose(State(st): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
2935 let _disposed = global_dispose(State(st)).await?;
2936 Ok(Json(json!(true)))
2937}
2938
2939async fn post_init(
2944 State(st): State<Arc<AppState>>,
2945 axum::extract::Path(id): axum::extract::Path<String>,
2946 Json(payload): Json<Value>,
2947) -> Result<axum::response::Response, HttpError> {
2948 let provider = payload.get("providerID").and_then(|v| v.as_str());
2949 let model = payload.get("modelID").and_then(|v| v.as_str());
2950 let (Some(provider), Some(model)) = (provider, model) else {
2951 let missing = if provider.is_none() {
2952 "providerID"
2953 } else {
2954 "modelID"
2955 };
2956 return Ok((
2957 StatusCode::BAD_REQUEST,
2958 axum::Json(json!({
2959 "name": "BadRequest",
2960 "data": {
2961 "message": format!("Missing key\n at [\"{missing}\"]"),
2962 "kind": "Payload"
2963 }
2964 })),
2965 )
2966 .into_response());
2967 };
2968 let exists_id = id.clone();
2969 if !run_blocking(&st.db, move |db| {
2970 ocserve_store::session_exists(db, &exists_id)
2971 })
2972 .await
2973 .map_err(|e| ApiError {
2974 status: StatusCode::INTERNAL_SERVER_ERROR,
2975 name: "InternalError",
2976 message: format!("{e:#}"),
2977 })? {
2978 return Err(ApiError::not_found(format!("Session not found: {id}")).into());
2979 }
2980 let cmd_payload = json!({
2982 "command": "init",
2983 "arguments": "",
2984 "model": format!("{provider}/{model}"),
2985 "messageID": payload.get("messageID").cloned().unwrap_or(Value::Null),
2986 });
2987 post_command(State(st), axum::extract::Path(id), Json(cmd_payload))
2988 .await
2989 .map(|j| j.into_response())
2990}
2991
2992async fn post_provider_oauth(
2996 axum::extract::Path(_id): axum::extract::Path<String>,
2997) -> axum::response::Response {
2998 (
2999 StatusCode::BAD_REQUEST,
3000 axum::Json(json!({"name": "BadRequest", "data": {}})),
3001 )
3002 .into_response()
3003}
3004
3005async fn post_provider_oauth_callback(
3007 axum::extract::Path(_id): axum::extract::Path<String>,
3008) -> axum::response::Response {
3009 (
3010 StatusCode::BAD_REQUEST,
3011 axum::Json(json!({"name": "BadRequest", "data": {}})),
3012 )
3013 .into_response()
3014}
3015
3016async fn post_mcp_auth_authenticate(
3019 State(st): State<Arc<AppState>>,
3020 axum::extract::Path(name): axum::extract::Path<String>,
3021) -> Result<axum::response::Response, HttpError> {
3022 mcp_auth_gate(&st, &name)?;
3023 Err(HttpError::TaggedData {
3024 status: StatusCode::BAD_REQUEST,
3025 tag: "McpUnsupportedOAuthError",
3026 fields: json!({"error": format!("MCP server {name} does not support OAuth")}),
3027 })
3028}
3029
3030async fn post_mcp_auth_callback(
3032 State(st): State<Arc<AppState>>,
3033 axum::extract::Path(name): axum::extract::Path<String>,
3034) -> Result<axum::response::Response, HttpError> {
3035 mcp_auth_gate(&st, &name)?;
3036 Err(HttpError::TaggedData {
3037 status: StatusCode::BAD_REQUEST,
3038 tag: "McpUnsupportedOAuthError",
3039 fields: json!({"error": format!("MCP server {name} does not support OAuth")}),
3040 })
3041}
3042
3043fn mcp_auth_gate(st: &Arc<AppState>, name: &str) -> Result<(), HttpError> {
3045 let known = st
3046 .mcp
3047 .get()
3048 .map(|h| h.statuses().get(name).is_some())
3049 .unwrap_or(false);
3050 if known {
3051 return Ok(());
3052 }
3053 Err(HttpError::TaggedData {
3054 status: StatusCode::NOT_FOUND,
3055 tag: "McpServerNotFoundError",
3056 fields: json!({
3057 "name": name,
3058 "message": format!("MCP server not found: {name}"),
3059 }),
3060 })
3061}
3062
3063async fn get_message_by_id(
3067 State(st): State<Arc<AppState>>,
3068 axum::extract::Path((id, mid)): axum::extract::Path<(String, String)>,
3069) -> Result<axum::response::Response, ApiError> {
3070 let db = st.db.clone();
3071 let sid = id.clone();
3072 let mid_c = mid.clone();
3073 let found = run_blocking(&db, move |db| {
3074 ocserve_store::message_by_id(db, &sid, &mid_c)
3075 })
3076 .await
3077 .map_err(|e| ApiError {
3078 status: StatusCode::INTERNAL_SERVER_ERROR,
3079 name: "InternalError",
3080 message: format!("{e:#}"),
3081 })?;
3082 match found {
3083 Some(v) => Ok(axum::Json(v).into_response()),
3084 None => Err(ApiError::not_found(format!("Message not found: {mid}"))),
3085 }
3086}
3087
3088async fn post_session_permission_respond(
3092 State(st): State<Arc<AppState>>,
3093 axum::extract::Path((_id, pid)): axum::extract::Path<(String, String)>,
3094 Json(body): Json<Value>,
3095) -> Result<axum::response::Response, HttpError> {
3096 let reply = validate_permission_reply_key(&body, "response")?;
3097 if !st.gate.reply(&pid, reply) {
3098 return Err(HttpError::TaggedData {
3099 status: StatusCode::NOT_FOUND,
3100 tag: "PermissionNotFoundError",
3101 fields: json!({
3102 "requestID": pid,
3103 "message": format!("Permission request not found: {pid}"),
3104 }),
3105 });
3106 }
3107 Ok(axum::Json(json!(true)).into_response())
3108}
3109
3110async fn post_log(Json(body): Json<Value>) -> Result<Json<Value>, HttpError> {
3113 let level = body.get("level").and_then(|v| v.as_str()).unwrap_or("info");
3114 let service = body
3115 .get("service")
3116 .and_then(|v| v.as_str())
3117 .unwrap_or("app");
3118 let message = body.get("message").and_then(|v| v.as_str()).unwrap_or("");
3119 match level {
3120 "debug" => tracing::debug!(target: "ocserve::client", service, "{message}"),
3121 "warn" => tracing::warn!(target: "ocserve::client", service, "{message}"),
3122 "error" => tracing::error!(target: "ocserve::client", service, "{message}"),
3123 _ => tracing::info!(target: "ocserve::client", service, "{message}"),
3124 }
3125 Ok(Json(json!(true)))
3126}
3127
3128async fn search_messages(
3133 State(st): State<Arc<AppState>>,
3134 Json(body): Json<Value>,
3135) -> Result<Json<Value>, ApiError> {
3136 let query = body
3137 .get("query")
3138 .and_then(|v| v.as_str())
3139 .unwrap_or("")
3140 .trim()
3141 .to_string();
3142 if query.is_empty() || query.chars().count() > 512 {
3143 return Err(ApiError {
3144 status: StatusCode::BAD_REQUEST,
3145 name: "BadRequest",
3146 message: "query must be a non-empty string of at most 512 characters".into(),
3147 });
3148 }
3149 let scope = body
3150 .get("sessionID")
3151 .and_then(|v| v.as_str())
3152 .map(str::to_string);
3153 let limit = body
3154 .get("limit")
3155 .and_then(|v| v.as_u64())
3156 .unwrap_or(50)
3157 .clamp(1, 200) as u32;
3158 let offset = body.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
3159 let needle = query.clone();
3160 let (hits, truncated) = run_blocking(&st.db, move |db| {
3161 ocserve_store::search_parts(db, &needle, scope.as_deref(), limit, offset)
3162 })
3163 .await
3164 .map_err(|e| ApiError {
3165 status: StatusCode::INTERNAL_SERVER_ERROR,
3166 name: "InternalError",
3167 message: format!("{e:#}"),
3168 })?;
3169 let out = json!({
3170 "hits": hits
3171 .iter()
3172 .map(|h| json!({
3173 "sessionID": h.session_id,
3174 "messageID": h.message_id,
3175 "partID": h.part_id,
3176 "role": h.role,
3177 "time": h.time,
3178 "snippet": ocserve_store::snippet(&h.text, &query),
3179 }))
3180 .collect::<Vec<_>>(),
3181 "truncated": truncated,
3182 });
3183 Ok(Json(out))
3184}
3185
3186fn resolve_model(
3191 st: &Arc<AppState>,
3192 model: Option<&ocserve_core::wire::ModelRef>,
3193 session_id: &str,
3194) -> (String, String) {
3195 if let Some(m) = model {
3196 return (m.provider_id.clone(), m.model_id.clone());
3197 }
3198 if let Ok(conn) = ocserve_store::pragma::open_reader(&st.db) {
3199 let stored: Option<String> = conn
3200 .query_row(
3201 "SELECT model FROM session WHERE id = ?1",
3202 [session_id],
3203 |r| r.get(0),
3204 )
3205 .ok()
3206 .flatten();
3207 if let Some(raw) = stored
3208 && let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
3209 {
3210 let p = v.get("providerID").and_then(|x| x.as_str()).unwrap_or("");
3211 let m = v.get("id").and_then(|x| x.as_str()).unwrap_or("");
3212 if !p.is_empty() && !m.is_empty() {
3213 return (p.to_string(), m.to_string());
3214 }
3215 }
3216 }
3217 st.llm.read().default_model.clone()
3218}
3219
3220pub(crate) fn agent_rules(st: &Arc<AppState>, agent: &str) -> Vec<ocserve_tools::Rule> {
3223 st.payloads
3224 .read()
3225 .agent
3226 .iter()
3227 .find(|a| a["name"].as_str() == Some(agent))
3228 .and_then(|a| a["permission"].as_array())
3229 .map(|arr| {
3230 arr.iter()
3231 .filter_map(|r| {
3232 Some(ocserve_tools::Rule {
3233 permission: r["permission"].as_str()?.to_string(),
3234 pattern: r["pattern"].as_str()?.to_string(),
3235 action: r["action"].as_str()?.to_string(),
3236 })
3237 })
3238 .collect()
3239 })
3240 .unwrap_or_default()
3241}
3242
3243fn skill_entries(st: &Arc<AppState>) -> Vec<(String, String, String, String)> {
3247 let payloads = st.payloads.read();
3248 payloads
3249 .command
3250 .iter()
3251 .filter(|c| c.get("source").and_then(|v| v.as_str()) == Some("skill"))
3252 .filter_map(|c| {
3253 let name = c.get("name").and_then(|v| v.as_str())?.to_string();
3254 let description = c
3255 .get("description")
3256 .and_then(|v| v.as_str())
3257 .unwrap_or("")
3258 .to_string();
3259 let location = c
3260 .get("location")
3261 .and_then(|v| v.as_str())
3262 .filter(|s| !s.is_empty())
3263 .unwrap_or("<built-in>")
3264 .to_string();
3265 let content = c
3266 .get("template")
3267 .and_then(|v| v.as_str())
3268 .unwrap_or("")
3269 .to_string();
3270 Some((name, description, location, content))
3271 })
3272 .collect()
3273}
3274
3275pub(crate) fn build_prompt_context(
3278 st: &Arc<AppState>,
3279 req: &ocserve_core::wire::PromptRequest,
3280 session_id: &str,
3281) -> Result<ocserve_core::prompt::PromptContext, ApiError> {
3282 let agent = req
3283 .agent
3284 .as_deref()
3285 .filter(|a| !a.is_empty())
3286 .map(String::from)
3287 .unwrap_or_else(|| st.llm.read().default_agent.clone());
3288 let system = st
3289 .llm
3290 .read()
3291 .systems
3292 .get(&agent)
3293 .cloned()
3294 .unwrap_or_default();
3295 let (pid, mid) = resolve_model(st, req.model.as_ref(), session_id);
3296 let (base_url, api_key) =
3297 st.llm
3298 .read()
3299 .endpoints
3300 .get(&pid)
3301 .cloned()
3302 .ok_or_else(|| ApiError {
3303 status: StatusCode::BAD_REQUEST,
3304 name: "BadRequest",
3305 message: format!("no endpoint configured for provider {pid}"),
3306 })?;
3307 let pricing = st
3308 .llm
3309 .read()
3310 .pricing
3311 .get(&(pid.clone(), mid.clone()))
3312 .copied();
3313 let rules = agent_rules(st, &agent);
3315 let model_limit = st
3316 .llm
3317 .read()
3318 .limits
3319 .get(&(pid.clone(), mid.clone()))
3320 .cloned()
3321 .unwrap_or(serde_json::Value::Null);
3322 let compaction = {
3323 let payloads = st.payloads.read();
3324 ocserve_core::compact::CompactionCfg::from_value(Some(&payloads.compaction))
3325 };
3326 let compaction_system = st
3327 .llm
3328 .read()
3329 .systems
3330 .get("compaction")
3331 .cloned()
3332 .unwrap_or_else(|| {
3333 ocserve_core::system_prompt::base_prompt(None, &mid, &pid)
3337 });
3338 let session_directory = ocserve_store::session_directory(&st.db, session_id)
3339 .ok()
3340 .flatten()
3341 .unwrap_or_else(|| st.paths["directory"].as_str().unwrap_or("/").to_string());
3342 let base = ocserve_core::system_prompt::base_prompt(
3347 if system.is_empty() {
3348 None
3349 } else {
3350 Some(&system)
3351 },
3352 &mid,
3353 &pid,
3354 );
3355 let worktree = vcs::toplevel(std::path::Path::new(&session_directory))
3356 .unwrap_or_else(|| session_directory.clone());
3357 let is_git =
3358 worktree != session_directory || std::path::Path::new(&worktree).join(".git").exists();
3359 let env_block = ocserve_core::system_prompt::environment_block(
3360 &mid,
3361 &pid,
3362 &session_directory,
3363 &worktree,
3364 is_git,
3365 std::env::consts::OS,
3366 &js_to_date_string(now_ms()),
3367 );
3368 let skills = skill_entries(st);
3369 let skill_rows: Vec<(String, String, String)> = skills
3370 .iter()
3371 .map(|(n, d, l, _)| (n.clone(), d.clone(), l.clone()))
3372 .collect();
3373 let skills_blk = ocserve_core::system_prompt::skills_block(&skill_rows);
3374 let system = ocserve_core::system_prompt::compose(&base, &[Some(env_block), skills_blk], None);
3375 let snapshots = snapshots_for(st, &session_directory);
3376 let ctx = ocserve_core::prompt::PromptContext {
3377 db: st.db.clone(),
3378 blobs: st.blobs.clone(),
3379 bus: st.bus.clone(),
3380 directory: session_directory,
3385 agent: agent.clone(),
3386 system,
3387 endpoint: ocserve_core::prompt::LlmEndpoint {
3388 base_url,
3389 api_key,
3390 pricing,
3391 },
3392 model_id: mid,
3393 provider_id: pid,
3394 rules,
3395 gate: st.gate.clone(),
3396 mcp: st.mcp.get().cloned(),
3397 plugins: st.plugins.get().cloned(),
3398 questions: st.question_gate.clone(),
3399 skills,
3400 model_limit,
3401 max_rounds: st.max_rounds,
3402 cost_ceiling: st.cost_ceiling,
3403 compaction,
3404 compaction_system,
3405 snapshots,
3408 };
3409 Ok(ctx)
3410}
3411
3412fn snapshots_for(st: &AppState, directory: &str) -> Option<ocserve_core::snapshot::Snapshots> {
3416 let cfg_off = st
3417 .payloads
3418 .read()
3419 .config
3420 .get("snapshot")
3421 .map(|v| v == &serde_json::Value::Bool(false))
3422 .unwrap_or(false);
3423 let env_off = std::env::var("OCSERVE_SNAPSHOT")
3424 .map(|v| v == "0")
3425 .unwrap_or(false);
3426 if cfg_off || env_off {
3427 return None;
3428 }
3429 let data_dir = st.db.parent().unwrap_or_else(|| std::path::Path::new("."));
3430 Some(ocserve_core::snapshot::Snapshots::new(
3431 data_dir.to_path_buf(),
3432 directory,
3433 true,
3434 ))
3435}
3436
3437fn fmt_js(v: f64) -> String {
3442 if v.is_finite() && v.abs() >= 1e21 {
3443 let raw = format!("{v:e}"); if let Some(pos) = raw.find('e') {
3445 return format!("{}e+{}", &raw[..pos], &raw[pos + 1..]);
3446 }
3447 raw
3448 } else {
3449 format!("{v}")
3450 }
3451}
3452
3453fn parse_limit(raw: &str) -> Result<u64, String> {
3461 const INT_MSG: &str = "Expected an integer, got {}\n at [\"limit\"]";
3462 match raw.parse::<f64>() {
3463 Err(_) => Err(INT_MSG.replace("{}", "NaN")),
3464 Ok(v) if v.is_nan() => Err(INT_MSG.replace("{}", "NaN")),
3465 Ok(v) if v.is_infinite() => {
3466 Err(INT_MSG.replace("{}", if v > 0.0 { "Infinity" } else { "-Infinity" }))
3467 }
3468 Ok(v) if v.trunc() != v => Err(INT_MSG.replace("{}", &fmt_js(v))),
3469 Ok(v) if v.abs() > 9007199254740991.0 => Err(INT_MSG.replace("{}", &fmt_js(v))),
3470 Ok(v) if v < 0.0 => Err(format!(
3471 "Expected a value greater than or equal to 0, got {}\n at [\"limit\"]",
3472 fmt_js(v)
3473 )),
3474 Ok(v) => Ok(v as u64),
3475 }
3476}
3477
3478fn tagged_bad_request() -> HttpError {
3479 HttpError::Tagged {
3480 status: StatusCode::BAD_REQUEST,
3481 tag: "BadRequest",
3482 }
3483}
3484
3485struct LockRelease {
3492 locks: std::sync::Arc<
3493 parking_lot::Mutex<
3494 std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
3495 >,
3496 >,
3497 sid: String,
3498 lock: std::sync::Arc<tokio::sync::Mutex<()>>,
3499}
3500
3501impl LockRelease {
3502 fn arc(&self) -> &std::sync::Arc<tokio::sync::Mutex<()>> {
3503 &self.lock
3504 }
3505}
3506
3507impl Drop for LockRelease {
3508 fn drop(&mut self) {
3509 let mut map = self.locks.lock();
3514 if let Some(arc) = map.get(&self.sid)
3515 && std::sync::Arc::strong_count(arc) <= 2
3516 {
3517 map.remove(&self.sid);
3518 }
3519 }
3520}
3521
3522pub(crate) async fn lock_session(st: &Arc<AppState>, sid: &str) -> Result<LockRelease, ApiError> {
3528 let lock = {
3529 let mut map = st.prompt_locks.lock();
3530 if map.len() >= 64 && !map.contains_key(sid) {
3531 return Err(ApiError {
3532 status: StatusCode::SERVICE_UNAVAILABLE,
3533 name: "ServiceUnavailableError",
3534 message: "too many concurrent prompts".into(),
3535 });
3536 }
3537 map.entry(sid.to_string())
3538 .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
3539 .clone()
3540 };
3541 Ok(LockRelease {
3542 locks: std::sync::Arc::clone(&st.prompt_locks),
3543 sid: sid.to_string(),
3544 lock,
3545 })
3546}
3547
3548pub(crate) fn prompt_err(e: anyhow::Error) -> ApiError {
3549 let msg = format!("{e:#}");
3550 if msg.starts_with("Session not found") {
3551 ApiError::not_found(msg)
3552 } else {
3553 ApiError {
3554 status: StatusCode::INTERNAL_SERVER_ERROR,
3555 name: "InternalError",
3556 message: msg,
3557 }
3558 }
3559}
3560
3561async fn post_message(
3562 State(st): State<Arc<AppState>>,
3563 axum::extract::Path(id): axum::extract::Path<String>,
3564 Json(payload): Json<Value>,
3565) -> Result<impl IntoResponse, HttpError> {
3566 let req = decode_v1(ocserve_core::wire::PromptRequest::decode(&payload))?;
3569 let ctx = build_prompt_context(&st, &req, &id)?;
3570 let _release = lock_session(&st, &id).await?;
3571 let _guard = _release.arc().lock().await;
3572 let writer = st.writer.clone();
3573 let result = ocserve_core::prompt::run_prompt(&ctx, &writer, &id, &req)
3574 .await
3575 .map_err(prompt_err)?;
3576 drop(_guard);
3577 let (info, parts) = result;
3578 Ok(Json(json!({"info": info, "parts": parts})))
3579}
3580
3581async fn post_prompt_async(
3587 State(st): State<Arc<AppState>>,
3588 axum::extract::Path(id): axum::extract::Path<String>,
3589 Json(payload): Json<Value>,
3590) -> Result<StatusCode, HttpError> {
3591 let req = decode_v1(ocserve_core::wire::PromptRequest::decode(&payload))?;
3594 let exists_id = id.clone();
3595 if !run_blocking(&st.db, move |db| {
3596 ocserve_store::session_exists(db, &exists_id)
3597 })
3598 .await
3599 .map_err(|e| ApiError {
3600 status: StatusCode::INTERNAL_SERVER_ERROR,
3601 name: "InternalError",
3602 message: format!("{e:#}"),
3603 })? {
3604 return Err(ApiError::not_found(format!("Session not found: {id}")).into());
3605 }
3606 let ctx = build_prompt_context(&st, &req, &id)?;
3607 let release = lock_session(&st, &id).await?;
3611 let writer = st.writer.clone();
3612 let sid = id.clone();
3613 let generation = st
3614 .prompt_gen
3615 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3616 let tasks = st.prompt_tasks.clone();
3617 let tasks_insert = st.prompt_tasks.clone();
3618 let handle = tokio::spawn(async move {
3619 let _release = release;
3620 let guard = _release.arc().lock().await;
3621 match ocserve_core::prompt::run_prompt(&ctx, &writer, &sid, &req).await {
3622 Ok(_) => {}
3623 Err(e) => tracing::error!("prompt_async failed session={sid}: {e:#}"),
3624 }
3625 drop(guard);
3626 let mut t = tasks.lock();
3627 if let Some((g, _)) = t.get(&sid)
3628 && *g == generation
3629 {
3630 t.remove(&sid);
3631 }
3632 });
3633 tasks_insert.lock().insert(id.clone(), (generation, handle));
3634 Ok(StatusCode::NO_CONTENT)
3635}
3636
3637async fn get_children(
3640 State(st): State<Arc<AppState>>,
3641 axum::extract::Path(id): axum::extract::Path<String>,
3642) -> Result<Json<Value>, ApiError> {
3643 let exists_id = id.clone();
3644 if !run_blocking(&st.db, move |db| {
3645 ocserve_store::session_exists(db, &exists_id)
3646 })
3647 .await
3648 .map_err(|e| ApiError {
3649 status: StatusCode::INTERNAL_SERVER_ERROR,
3650 name: "InternalError",
3651 message: format!("{e:#}"),
3652 })? {
3653 return Err(ApiError::not_found(format!("Session not found: {id}")));
3654 }
3655 run_blocking(&st.db, move |db| ocserve_store::load_children(db, &id))
3656 .await
3657 .map(|v| Json(Value::Array(v)))
3658 .map_err(|e| ApiError {
3659 status: StatusCode::INTERNAL_SERVER_ERROR,
3660 name: "InternalError",
3661 message: format!("{e:#}"),
3662 })
3663}
3664
3665async fn get_todos(
3666 State(st): State<Arc<AppState>>,
3667 axum::extract::Path(id): axum::extract::Path<String>,
3668) -> Result<Json<Value>, ApiError> {
3669 let exists_id = id.clone();
3671 if !run_blocking(&st.db, move |db| {
3672 ocserve_store::session_exists(db, &exists_id)
3673 })
3674 .await
3675 .map_err(|e| ApiError {
3676 status: StatusCode::INTERNAL_SERVER_ERROR,
3677 name: "InternalError",
3678 message: format!("{e:#}"),
3679 })? {
3680 return Err(ApiError::not_found(format!("Session not found: {id}")));
3681 }
3682 run_blocking(&st.db, move |db| ocserve_store::load_todos(db, &id))
3683 .await
3684 .map(|v| Json(Value::Array(v)))
3685 .map_err(|e| ApiError {
3686 status: StatusCode::INTERNAL_SERVER_ERROR,
3687 name: "InternalError",
3688 message: format!("{e:#}"),
3689 })
3690}
3691
3692async fn patch_session(
3696 State(st): State<Arc<AppState>>,
3697 axum::extract::Path(id): axum::extract::Path<String>,
3698 Json(payload): Json<Value>,
3699) -> Result<Json<Value>, ApiError> {
3700 let exists_id = id.clone();
3701 if !run_blocking(&st.db, move |db| {
3702 ocserve_store::session_exists(db, &exists_id)
3703 })
3704 .await
3705 .map_err(|e| ApiError {
3706 status: StatusCode::INTERNAL_SERVER_ERROR,
3707 name: "InternalError",
3708 message: format!("{e:#}"),
3709 })? {
3710 return Err(ApiError::not_found(format!("Session not found: {id}")));
3711 }
3712 let mut dirty = false;
3713 if let Some(title) = payload.get("title").and_then(|t| t.as_str()) {
3714 ocserve_store::update_session_title(&st.writer, &id, title).map_err(|e| ApiError {
3715 status: StatusCode::INTERNAL_SERVER_ERROR,
3716 name: "InternalError",
3717 message: format!("{e:#}"),
3718 })?;
3719 dirty = true;
3720 }
3721 if payload.get("model").is_some() {
3726 let raw = payload.get("model").cloned().unwrap_or(Value::Null);
3727 let model_json: Value = match raw {
3728 Value::Null => Value::Null, Value::Object(ref m) => {
3730 let ok = m
3731 .get("id")
3732 .and_then(|v| v.as_str())
3733 .is_some_and(|v| !v.is_empty())
3734 && m.get("providerID")
3735 .and_then(|v| v.as_str())
3736 .is_some_and(|v| !v.is_empty());
3737 if !ok {
3738 return Err(ApiError {
3739 status: StatusCode::BAD_REQUEST,
3740 name: "BadRequest",
3741 message: "model must be {id, providerID, variant?} or null".into(),
3742 });
3743 }
3744 Value::String(
3745 serde_json::to_string(&json!({
3746 "id": m["id"],
3747 "providerID": m["providerID"],
3748 "variant": m.get("variant").cloned().unwrap_or(json!("default")),
3749 }))
3750 .map_err(|e| ApiError {
3751 status: StatusCode::INTERNAL_SERVER_ERROR,
3752 name: "InternalError",
3753 message: format!("{e:#}"),
3754 })?,
3755 )
3756 }
3757 _ => {
3758 return Err(ApiError {
3759 status: StatusCode::BAD_REQUEST,
3760 name: "BadRequest",
3761 message: "model must be {id, providerID, variant?} or null".into(),
3762 });
3763 }
3764 };
3765 st.writer
3766 .write(vec![ocserve_store::WriteOp::Sql {
3767 sql: "UPDATE session SET model = ?2 WHERE id = ?1".into(),
3768 params: vec![id.clone().into(), model_json],
3769 }])
3770 .map_err(|e| ApiError {
3771 status: StatusCode::INTERNAL_SERVER_ERROR,
3772 name: "InternalError",
3773 message: format!("{e:#}"),
3774 })?;
3775 dirty = true;
3776 }
3777 if payload.get("agent").is_some() {
3778 let agent = match payload.get("agent") {
3779 Some(Value::Null) => String::new(),
3780 Some(Value::String(a)) if !a.is_empty() => a.clone(),
3781 _ => {
3782 return Err(ApiError {
3783 status: StatusCode::BAD_REQUEST,
3784 name: "BadRequest",
3785 message: "agent must be a non-empty string or null".into(),
3786 });
3787 }
3788 };
3789 st.writer
3790 .write(vec![ocserve_store::WriteOp::Sql {
3791 sql: "UPDATE session SET agent = ?2 WHERE id = ?1".into(),
3792 params: vec![id.clone().into(), agent.into()],
3793 }])
3794 .map_err(|e| ApiError {
3795 status: StatusCode::INTERNAL_SERVER_ERROR,
3796 name: "InternalError",
3797 message: format!("{e:#}"),
3798 })?;
3799 dirty = true;
3800 }
3801 let info_id = id.clone();
3802 let info = run_blocking(&st.db, move |db| {
3803 ocserve_store::load_session_wire(db, &info_id)
3804 })
3805 .await
3806 .map_err(|e| ApiError {
3807 status: StatusCode::INTERNAL_SERVER_ERROR,
3808 name: "InternalError",
3809 message: format!("{e:#}"),
3810 })?
3811 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))?;
3812 if dirty {
3813 st.bus.publish(ocserve_core::event::frame(
3817 st.paths["directory"].as_str().unwrap_or("/"),
3818 "session.updated",
3819 json!({"sessionID": id, "info": info}),
3820 ));
3821 }
3822 Ok(Json(info))
3823}
3824
3825async fn delete_session_route(
3828 State(st): State<Arc<AppState>>,
3829 axum::extract::Path(id): axum::extract::Path<String>,
3830) -> Result<Json<Value>, ApiError> {
3831 if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
3832 handle.abort();
3833 }
3834 let found = ocserve_store::delete_session(&st.writer, &st.db, &id).map_err(|e| ApiError {
3835 status: StatusCode::INTERNAL_SERVER_ERROR,
3836 name: "InternalError",
3837 message: format!("{e:#}"),
3838 })?;
3839 if !found {
3840 return Err(ApiError::not_found(format!("Session not found: {id}")));
3841 }
3842 st.gate.forget_session(&id);
3845 st.bus.publish(ocserve_core::event::frame(
3848 st.paths["directory"].as_str().unwrap_or("/"),
3849 "session.deleted",
3850 json!({"sessionID": id}),
3851 ));
3852 Ok(Json(json!(true)))
3853}
3854
3855async fn post_abort(
3857 State(st): State<Arc<AppState>>,
3858 axum::extract::Path(id): axum::extract::Path<String>,
3859) -> Result<Json<Value>, ApiError> {
3860 if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
3861 handle.abort();
3862 tracing::info!("abort: cancelled prompt task for {id}");
3863 }
3864 Ok(Json(json!(true)))
3865}
3866
3867async fn delete_message_route(
3868 State(st): State<Arc<AppState>>,
3869 axum::extract::Path((sid, mid)): axum::extract::Path<(String, String)>,
3870) -> Result<Json<Value>, ApiError> {
3871 let found =
3872 ocserve_store::delete_message(&st.writer, &st.db, &sid, &mid).map_err(|e| ApiError {
3873 status: StatusCode::INTERNAL_SERVER_ERROR,
3874 name: "InternalError",
3875 message: format!("{e:#}"),
3876 })?;
3877 if !found {
3878 return Err(ApiError::not_found(format!("Message not found: {mid}")));
3879 }
3880 st.bus.publish(ocserve_core::event::frame(
3883 st.paths["directory"].as_str().unwrap_or("/"),
3884 "message.removed",
3885 json!({"sessionID": sid, "messageID": mid}),
3886 ));
3887 Ok(Json(json!(true)))
3888}
3889
3890async fn delete_part_route(
3891 State(st): State<Arc<AppState>>,
3892 axum::extract::Path((_sid, mid, pid)): axum::extract::Path<(String, String, String)>,
3893) -> Result<Json<Value>, ApiError> {
3894 ocserve_store::delete_part(&st.writer, &st.db, &mid, &pid)
3895 .map_err(|e| ApiError {
3896 status: StatusCode::INTERNAL_SERVER_ERROR,
3897 name: "InternalError",
3898 message: format!("{e:#}"),
3899 })?
3900 .then_some(Json(json!(true)))
3901 .ok_or_else(|| ApiError::not_found(format!("Part not found: {pid}")))
3902}
3903
3904async fn patch_part_route(
3907 State(st): State<Arc<AppState>>,
3908 axum::extract::Path((sid, mid, pid)): axum::extract::Path<(String, String, String)>,
3909 Json(part): Json<Value>,
3910) -> Result<Json<Value>, ApiError> {
3911 let body_sid = part.get("sessionID").and_then(|v| v.as_str()).unwrap_or("");
3912 let body_mid = part.get("messageID").and_then(|v| v.as_str()).unwrap_or("");
3913 let body_pid = part.get("id").and_then(|v| v.as_str()).unwrap_or("");
3914 if body_sid != sid || body_mid != mid || body_pid != pid {
3915 return Err(ApiError {
3916 status: StatusCode::BAD_REQUEST,
3917 name: "BadRequest",
3918 message: "part id/sessionID/messageID must match the path".into(),
3919 });
3920 }
3921 ocserve_store::update_part(&st.writer, &st.blobs, &sid, &mid, &pid, &part)
3922 .map_err(|e| ApiError {
3923 status: StatusCode::INTERNAL_SERVER_ERROR,
3924 name: "InternalError",
3925 message: format!("{e:#}"),
3926 })?
3927 .then_some(Json(part))
3928 .ok_or_else(|| ApiError::not_found(format!("Part not found: {pid}")))
3929}
3930
3931fn scope_path(base: &std::path::Path, raw: &str) -> Result<std::path::PathBuf, ApiError> {
3934 let joined = if raw.is_empty() || raw == "." {
3935 base.to_path_buf()
3936 } else {
3937 let p = std::path::Path::new(raw);
3938 if p.is_absolute() {
3939 p.to_path_buf()
3940 } else {
3941 base.join(p)
3942 }
3943 };
3944 let canon_base = std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
3945 let norm = lexically_normalize(&joined);
3952 if !norm.starts_with(&canon_base) && !norm.starts_with(base) {
3953 return Err(ApiError {
3954 status: StatusCode::BAD_REQUEST,
3955 name: "BadRequest",
3956 message: format!("path escapes the session directory: {raw}"),
3957 });
3958 }
3959 let canon = std::fs::canonicalize(&joined).unwrap_or(norm);
3960 if !canon.starts_with(&canon_base) {
3961 return Err(ApiError {
3962 status: StatusCode::BAD_REQUEST,
3963 name: "BadRequest",
3964 message: format!("path escapes the session directory: {raw}"),
3965 });
3966 }
3967 Ok(canon)
3968}
3969
3970fn lexically_normalize(p: &std::path::Path) -> std::path::PathBuf {
3972 use std::path::Component;
3973 let mut out = std::path::PathBuf::new();
3974 for c in p.components() {
3975 match c {
3976 Component::ParentDir => {
3977 out.pop();
3978 }
3979 Component::CurDir => {}
3980 other => out.push(other.as_os_str()),
3981 }
3982 }
3983 out
3984}
3985
3986async fn list_directory(
3988 State(st): State<Arc<AppState>>,
3989 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
3990) -> Result<Json<Value>, ApiError> {
3991 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
3992 let raw = q.get("path").map(String::as_str).unwrap_or(".");
3993 let dir = scope_path(base, raw)?;
3994 let dir_for_worker = dir.clone();
3998 let raw_owned = raw.to_string();
3999 let rd = tokio::task::spawn_blocking(move || std::fs::read_dir(&dir_for_worker))
4000 .await
4001 .map_err(|e| ApiError {
4002 status: StatusCode::INTERNAL_SERVER_ERROR,
4003 name: "InternalServerError",
4004 message: format!("list worker: {e}"),
4005 })?
4006 .map_err(|e| ApiError {
4007 status: StatusCode::BAD_REQUEST,
4008 name: "BadRequest",
4009 message: format!("list {}: {e}", dir.display()),
4010 })?;
4011 let mut nodes: Vec<Value> = Vec::new();
4012 for entry in rd.flatten() {
4013 let name = entry.file_name().to_string_lossy().to_string();
4014 let path = entry.path();
4015 let meta = entry.metadata().ok();
4016 let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
4017 let rel = if raw_owned.is_empty() || raw_owned == "." {
4018 name.clone()
4019 } else {
4020 format!("{}/{}", raw_owned.trim_end_matches('/'), name)
4021 };
4022 let ignored =
4023 name.starts_with('.') || name == "node_modules" || name == "target" || name == ".git";
4024 nodes.push(json!({
4030 "name": name,
4031 "path": rel,
4032 "type": if is_dir { "directory" } else { "file" },
4033 "absolute": path.to_string_lossy(),
4034 "ignored": ignored,
4035 }));
4036 }
4037 nodes.sort_by(|a, b| {
4039 let da = a["type"] == "directory";
4040 let db = b["type"] == "directory";
4041 db.cmp(&da)
4042 .then_with(|| a["name"].as_str().cmp(&b["name"].as_str()))
4043 });
4044 nodes.truncate(5000);
4045 Ok(Json(Value::Array(nodes)))
4046}
4047
4048async fn read_file_content(
4050 State(st): State<Arc<AppState>>,
4051 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
4052) -> Result<Json<Value>, HttpError> {
4053 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
4054 let raw = q
4055 .get("path")
4056 .map(String::as_str)
4057 .ok_or_else(|| HttpError::Query {
4058 message: "Missing key\n at [\"path\"]".into(),
4059 })?;
4060 let path = scope_path(base, raw).map_err(HttpError::Api)?;
4061 let read_path = path.clone();
4066 let bytes = match tokio::task::spawn_blocking(move || std::fs::read(&read_path)).await {
4067 Ok(Ok(b)) => b,
4068 _ => {
4069 return Ok(Json(json!({"type": "text", "content": ""})));
4070 }
4071 };
4072 let truncated = bytes.len() > 1024 * 1024;
4076 let bytes = if truncated {
4077 let mut cut = 1024 * 1024;
4078 while cut > 0 && (bytes[cut] & 0xC0) == 0x80 {
4079 cut -= 1;
4080 }
4081 &bytes[..cut]
4082 } else {
4083 &bytes[..]
4084 };
4085 let (kind, content) = match std::str::from_utf8(bytes) {
4086 Ok(text) => ("text", text.to_string()),
4087 Err(_) => ("binary", String::from_utf8_lossy(bytes).into_owned()),
4088 };
4089 Ok(Json(json!({
4090 "type": kind,
4091 "content": content,
4092 "encoding": "utf-8",
4093 })))
4094}
4095
4096async fn find_files(
4099 State(st): State<Arc<AppState>>,
4100 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
4101) -> Result<Json<Value>, HttpError> {
4102 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
4103 let query = q
4106 .get("query")
4107 .ok_or_else(|| HttpError::Query {
4108 message: "Missing key\n at [\"query\"]".into(),
4109 })?
4110 .to_lowercase();
4111 if query.is_empty() {
4112 return Ok(Json(json!([])));
4113 }
4114 let want_type = q.get("type").map(String::as_str).unwrap_or("").to_string();
4115 let limit: usize = q
4117 .get("limit")
4118 .and_then(|l| l.parse().ok())
4119 .unwrap_or(10)
4120 .clamp(1, 200);
4121 let glob_mode = query.contains('*') || query.contains('?');
4122 let out = tokio::task::spawn_blocking(move || {
4129 walk_files(&base, &query, &want_type, limit, glob_mode)
4130 })
4131 .await
4132 .map_err(|e| {
4133 HttpError::Api(ApiError {
4134 status: StatusCode::INTERNAL_SERVER_ERROR,
4135 name: "InternalServerError",
4136 message: format!("find_files worker: {e}"),
4137 })
4138 })?
4139 .map_err(|e| {
4140 HttpError::Api(ApiError {
4141 status: StatusCode::BAD_REQUEST,
4142 name: "BadRequest",
4143 message: format!("find_files: {e:#}"),
4144 })
4145 })?;
4146 Ok(Json(Value::Array(
4147 out.into_iter().map(Value::String).collect(),
4148 )))
4149}
4150
4151static WALK_THREAD: std::sync::Mutex<Option<std::thread::ThreadId>> = std::sync::Mutex::new(None);
4158
4159pub(crate) fn walk_files(
4163 base: &std::path::Path,
4164 query: &str,
4165 want_type: &str,
4166 limit: usize,
4167 glob_mode: bool,
4168) -> anyhow::Result<Vec<String>> {
4169 if let Ok(mut g) = WALK_THREAD.lock() {
4176 *g = Some(std::thread::current().id());
4177 }
4178 let mut out: Vec<String> = Vec::new();
4179 let mut stack = vec![(base.to_path_buf(), String::new())];
4180 let mut visited = 0usize;
4181 while let Some((dir, rel)) = stack.pop() {
4182 visited += 1;
4183 if visited > 20_000 || out.len() >= limit * 4 {
4184 break; }
4186 let Ok(rd) = std::fs::read_dir(&dir) else {
4187 continue;
4188 };
4189 for entry in rd.flatten() {
4190 let name = entry.file_name().to_string_lossy().to_string();
4191 if name == ".git" || name == "node_modules" {
4192 continue;
4193 }
4194 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
4195 let child_rel = if rel.is_empty() {
4196 name.clone()
4197 } else {
4198 format!("{rel}/{name}")
4199 };
4200 let matched = if glob_mode {
4201 ocserve_tools::wildcard_match(&name, query)
4202 } else {
4203 child_rel.to_lowercase().contains(query)
4204 };
4205 if matched
4206 && (want_type.is_empty()
4207 || (want_type == "dir" && is_dir)
4208 || (want_type == "file" && !is_dir))
4209 {
4210 out.push(child_rel.clone());
4211 }
4212 if is_dir {
4213 stack.push((entry.path(), child_rel));
4214 }
4215 }
4216 }
4217 out.sort_by(|a, b| {
4219 let pa = a.to_lowercase().starts_with(query);
4220 let pb = b.to_lowercase().starts_with(query);
4221 pb.cmp(&pa).then_with(|| a.cmp(b))
4222 });
4223 out.truncate(limit);
4224 Ok(out)
4225}
4226
4227async fn find_text(
4232 State(st): State<Arc<AppState>>,
4233 axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
4234) -> Result<Json<Value>, HttpError> {
4235 let pattern = q.get("pattern").ok_or_else(|| HttpError::Query {
4238 message: "Missing key\n at [\"pattern\"]".into(),
4239 })?;
4240 if pattern.is_empty() {
4241 return Ok(Json(json!([])));
4242 }
4243 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
4244 let pattern = pattern.to_string();
4245 const EXCLUDES: &[&str] = &[
4250 ".git",
4251 "node_modules",
4252 "target",
4253 ".venv",
4254 "venv",
4255 "__pycache__",
4256 "dist",
4257 "build",
4258 ".terraform",
4259 ".terragrunt-cache",
4260 ];
4261 let base2 = base.clone();
4262 let out = tokio::task::spawn_blocking(move || {
4269 let run = |extra: &[&str]| -> std::io::Result<std::process::Output> {
4270 let mut c = std::process::Command::new("grep");
4271 c.args(["-rnE", "--binary-files=without-match", "-m", "50"]);
4272 c.args(extra);
4273 for d in EXCLUDES {
4274 c.arg(format!("--exclude-dir={d}"));
4275 }
4276 c.arg("--").arg(&pattern).arg(&base2);
4277 c.env("LC_ALL", "C").output()
4278 };
4279 let a = run(&["-n"])?;
4281 let b = run(&["-on", "-b"])?;
4282 Ok::<_, std::io::Error>((a, b))
4283 })
4284 .await
4285 .map_err(|e| {
4286 HttpError::Api(ApiError {
4287 status: StatusCode::INTERNAL_SERVER_ERROR,
4288 name: "InternalError",
4289 message: format!("find worker: {e}"),
4290 })
4291 })?;
4292 let (a_out, b_out) = match out {
4293 Ok(o) => o,
4294 Err(e) => {
4295 return Err(HttpError::Api(ApiError {
4296 status: StatusCode::INTERNAL_SERVER_ERROR,
4297 name: "InternalError",
4298 message: format!("grep spawn: {e}"),
4299 }));
4300 }
4301 };
4302 if a_out.status.code().unwrap_or(0) > 1 {
4303 return Err(HttpError::Api(ApiError {
4304 status: StatusCode::BAD_REQUEST,
4305 name: "BadRequest",
4306 message: "invalid pattern".into(),
4307 }));
4308 }
4309 let mut subs: std::collections::HashMap<(String, i64), Vec<(usize, String)>> =
4311 std::collections::HashMap::new();
4312 for line in String::from_utf8_lossy(&b_out.stdout).lines() {
4313 if let Some((head, text)) = split3(line) {
4315 let (path, ln, byte) = head;
4316 subs.entry((path, ln)).or_default().push((byte, text));
4317 }
4318 }
4319 let mut matches: Vec<Value> = Vec::new();
4320 for line in String::from_utf8_lossy(&a_out.stdout).lines().take(200) {
4321 let (p1, rest) = match line.split_once(':') {
4323 Some(x) => x,
4324 None => continue,
4325 };
4326 let (ln, text) = match rest.split_once(':') {
4327 Some(x) => x,
4328 None => continue,
4329 };
4330 let ln: i64 = ln.parse().unwrap_or(0);
4331 let line_start = text.as_ptr() as usize; let _ = line_start;
4333 let rel = std::path::Path::new(p1)
4334 .strip_prefix(&base)
4335 .map(|r| r.to_string_lossy().to_string())
4336 .unwrap_or_else(|_| p1.to_string());
4337 let line_subs = subs.get(&(p1.to_string(), ln)).cloned().unwrap_or_default();
4342 let mut submatch_json = Vec::new();
4343 let mut absolute_offset: i64 = 0;
4344 for (i, (byteoff, mtext)) in line_subs.iter().enumerate() {
4345 let from = submatch_json
4348 .last()
4349 .and_then(|v: &Value| v["end"].as_u64())
4350 .map(|e| e as usize + 1)
4351 .unwrap_or(0);
4352 let start = text[from.min(text.len())..]
4353 .find(mtext.as_str())
4354 .map(|p| p + from.min(text.len()))
4355 .unwrap_or(0);
4356 let end = start + mtext.len();
4357 if i == 0 {
4358 absolute_offset = (*byteoff as i64) - (start as i64);
4359 }
4360 submatch_json.push(json!({
4361 "match": {"text": mtext},
4362 "start": start,
4363 "end": end,
4364 }));
4365 }
4366 matches.push(json!({
4367 "path": {"text": rel},
4368 "lines": {"text": format!("{text}\n")},
4369 "line_number": ln,
4370 "absolute_offset": absolute_offset,
4371 "submatches": submatch_json,
4372 }));
4373 }
4374 Ok(Json(Value::Array(matches)))
4375}
4376
4377fn split3(line: &str) -> Option<((String, i64, usize), String)> {
4379 let (path, rest) = line.split_once(':')?;
4380 let (ln, rest) = rest.split_once(':')?;
4381 let (byte, text) = rest.split_once(':')?;
4382 Some((
4383 (path.to_string(), ln.parse().ok()?, byte.parse().ok()?),
4384 text.to_string(),
4385 ))
4386}
4387
4388async fn get_questions(State(st): State<Arc<AppState>>) -> Json<Value> {
4391 Json(Value::Array(st.question_gate.list()))
4392}
4393
4394pub(crate) fn validate_request_prefix(id: &str, prefix: &str) -> Result<(), HttpError> {
4397 if id.starts_with(prefix) {
4398 Ok(())
4399 } else {
4400 Err(HttpError::Params {
4401 message: format!(
4402 "Expected a string starting with \"{prefix}\", got \"{id}\"\n at [\"requestID\"]"
4403 ),
4404 })
4405 }
4406}
4407
4408pub(crate) fn question_not_found(id: &str) -> HttpError {
4410 HttpError::TaggedData {
4411 status: StatusCode::NOT_FOUND,
4412 tag: "QuestionNotFoundError",
4413 fields: json!({"requestID": id, "message": format!("Question request not found: {id}")}),
4414 }
4415}
4416
4417async fn post_question_reply(
4419 State(st): State<Arc<AppState>>,
4420 axum::extract::Path(id): axum::extract::Path<String>,
4421 Json(body): Json<Value>,
4422) -> Result<Json<Value>, HttpError> {
4423 validate_request_prefix(&id, "que")?;
4424 let raw = body
4425 .get("answers")
4426 .and_then(|a| a.as_array())
4427 .ok_or_else(|| {
4428 HttpError::Api(ApiError {
4429 status: StatusCode::BAD_REQUEST,
4430 name: "BadRequest",
4431 message: "body must be {answers: [[String]]}".into(),
4432 })
4433 })?;
4434 let mut answers: Vec<Vec<String>> = Vec::with_capacity(raw.len());
4435 for a in raw {
4436 let row = a.as_array().ok_or_else(|| {
4437 HttpError::Api(ApiError {
4438 status: StatusCode::BAD_REQUEST,
4439 name: "BadRequest",
4440 message: "each answer must be an array of labels".into(),
4441 })
4442 })?;
4443 answers.push(
4444 row.iter()
4445 .map(|l| l.as_str().unwrap_or_default().to_string())
4446 .collect(),
4447 );
4448 }
4449 if st.question_gate.reply(&id, answers) {
4450 Ok(Json(json!(true)))
4451 } else {
4452 Err(question_not_found(&id))
4453 }
4454}
4455
4456async fn post_question_reject(
4458 State(st): State<Arc<AppState>>,
4459 axum::extract::Path(id): axum::extract::Path<String>,
4460) -> Result<Json<Value>, HttpError> {
4461 validate_request_prefix(&id, "que")?;
4462 if st.question_gate.reject(&id) {
4463 Ok(Json(json!(true)))
4464 } else {
4465 Err(question_not_found(&id))
4466 }
4467}
4468
4469pub fn split_command_args(arguments: &str) -> Vec<String> {
4474 let bytes = arguments.as_bytes();
4475 let mut out = Vec::new();
4476 let mut i = 0;
4477 while i < bytes.len() {
4478 let c = bytes[i];
4479 if c.is_ascii_whitespace() {
4480 i += 1;
4481 continue;
4482 }
4483 let (tok, next) = if c == b'"' || c == b'\'' {
4484 let quote = c;
4485 let mut j = i + 1;
4486 while j < bytes.len() && bytes[j] != quote {
4487 j += 1;
4488 }
4489 let end = (j + 1).min(bytes.len());
4490 (
4491 std::str::from_utf8(&bytes[i..end])
4492 .unwrap_or("")
4493 .to_string(),
4494 end,
4495 )
4496 } else {
4497 let mut j = i;
4498 while j < bytes.len() && !bytes[j].is_ascii_whitespace() {
4499 j += 1;
4500 }
4501 (
4502 std::str::from_utf8(&bytes[i..j]).unwrap_or("").to_string(),
4503 j,
4504 )
4505 };
4506 let mut t = tok.as_str();
4508 if t.starts_with(['"', '\'']) {
4509 t = &t[1..];
4510 }
4511 if t.ends_with(['"', '\'']) && !t.is_empty() {
4512 t = &t[..t.len() - 1];
4513 }
4514 out.push(t.to_string());
4515 i = next;
4516 }
4517 out
4518}
4519
4520pub fn expand_command(template: &str, arguments: &str) -> String {
4525 let args = split_command_args(arguments);
4526 let mut last = 0usize;
4528 let b = template.as_bytes();
4529 let mut i = 0;
4530 while i + 1 < b.len() {
4531 if b[i] == b'$' && b[i + 1].is_ascii_digit() {
4532 let mut j = i + 1;
4533 while j < b.len() && b[j].is_ascii_digit() {
4534 j += 1;
4535 }
4536 if let Some(n) = std::str::from_utf8(&b[i + 1..j])
4537 .ok()
4538 .and_then(|d| d.parse::<usize>().ok())
4539 {
4540 last = last.max(n);
4541 }
4542 i = j;
4543 } else {
4544 i += 1;
4545 }
4546 }
4547 let mut with_args = String::new();
4549 let mut i = 0;
4550 let mut had_placeholder = false;
4551 while i < b.len() {
4552 if b[i] == b'$' && i + 1 < b.len() && b[i + 1].is_ascii_digit() {
4553 let mut j = i + 1;
4554 while j < b.len() && b[j].is_ascii_digit() {
4555 j += 1;
4556 }
4557 let n: usize = std::str::from_utf8(&b[i + 1..j])
4558 .unwrap_or("0")
4559 .parse()
4560 .unwrap_or(0);
4561 had_placeholder = true;
4562 let arg_index = n.saturating_sub(1);
4563 let value = if arg_index >= args.len() {
4564 String::new()
4565 } else if n == last {
4566 args[arg_index..].join(" ")
4567 } else {
4568 args[arg_index].clone()
4569 };
4570 with_args.push_str(&value);
4571 i = j;
4572 } else {
4573 let ch_len = {
4575 let ch = template[i..].chars().next().unwrap_or(' ');
4576 ch.len_utf8()
4577 };
4578 with_args.push_str(&template[i..i + ch_len]);
4579 i += ch_len;
4580 }
4581 }
4582 let uses_arguments = template.contains("$ARGUMENTS");
4583 let mut expanded = with_args.replace("$ARGUMENTS", arguments);
4584 if !had_placeholder && !uses_arguments && !arguments.trim().is_empty() {
4585 expanded = format!("{expanded}\n\n{arguments}");
4586 }
4587 expanded.trim().to_string()
4588}
4589
4590fn emit_session_error_event(st: &Arc<AppState>, sid: &str, message: &str) {
4593 let props = json!({
4594 "sessionID": sid,
4595 "error": {"name": "UnknownError", "data": {"message": message}},
4596 });
4597 if let Err(e) = ocserve_store::append_event(&st.writer, Some(sid), "session.error", &props) {
4598 tracing::warn!("session.error persist failed: {e:#}");
4599 }
4600 let seq = ocserve_store::next_event_seq(&st.db, sid).unwrap_or(0);
4601 let dir = st.paths["directory"].as_str().unwrap_or("/");
4602 st.bus.publish(ocserve_core::event::frame(
4603 dir,
4604 "session.error",
4605 props.clone(),
4606 ));
4607 st.bus.publish(ocserve_core::event::sync_frame(
4608 dir,
4609 "session.error",
4610 props,
4611 seq as u64,
4612 sid,
4613 ));
4614}
4615
4616pub(crate) fn session_busy(id: &str) -> ApiError {
4619 ApiError {
4620 status: StatusCode::CONFLICT,
4621 name: "SessionBusyError",
4622 message: format!("Session is busy: {id}"),
4623 }
4624}
4625
4626async fn post_command(
4630 State(st): State<Arc<AppState>>,
4631 axum::extract::Path(id): axum::extract::Path<String>,
4632 Json(payload): Json<Value>,
4633) -> Result<Json<Value>, HttpError> {
4634 let cmd = decode_v1(ocserve_core::wire::CommandRequest::decode(&payload))?;
4637 let exists_id = id.clone();
4638 if !run_blocking(&st.db, move |db| {
4639 ocserve_store::session_exists(db, &exists_id)
4640 })
4641 .await
4642 .map_err(|e| ApiError {
4643 status: StatusCode::INTERNAL_SERVER_ERROR,
4644 name: "InternalError",
4645 message: format!("{e:#}"),
4646 })? {
4647 return Err(ApiError::not_found(format!("Session not found: {id}")).into());
4648 }
4649 let name = cmd.command.as_str();
4650 let arguments = cmd.arguments.as_str();
4651 let (template, available_owned): (Option<String>, Vec<String>) = {
4654 let payloads = st.payloads.read();
4655 let t = payloads
4656 .command
4657 .iter()
4658 .find(|c| c["name"].as_str() == Some(name))
4659 .and_then(|c| c["template"].as_str())
4660 .map(str::to_string);
4661 let names = payloads
4662 .command
4663 .iter()
4664 .filter_map(|c| c["name"].as_str().map(str::to_string))
4665 .collect();
4666 (t, names)
4667 };
4668 let available: Vec<&str> = available_owned.iter().map(String::as_str).collect();
4669 let Some(template) = template.as_deref() else {
4670 let msg = format!(
4671 "Command not found: \"{name}\".{}",
4672 if available.is_empty() {
4673 String::new()
4674 } else {
4675 format!(" Available commands: {}", available.join(", "))
4676 }
4677 );
4678 emit_session_error_event(&st, &id, &msg);
4679 return Err(ApiError {
4680 status: StatusCode::BAD_REQUEST,
4681 name: "BadRequest",
4682 message: msg,
4683 }
4684 .into());
4685 };
4686 let expanded = expand_command(template, arguments);
4687 let mut cmd_parts = json!([{ "type": "text", "text": expanded }]);
4688 let parts_out = ocserve_core::prompt::hook_mutate(
4692 st.plugins.get(),
4693 "command.execute.before",
4694 json!({
4695 "command": name,
4696 "sessionID": id,
4697 "arguments": arguments,
4698 }),
4699 json!({"parts": cmd_parts.clone()}),
4700 )
4701 .await;
4702 if let Some(p) = parts_out.get("parts").and_then(|p| p.as_array()) {
4703 cmd_parts = Value::Array(p.clone());
4704 }
4705
4706 let req = ocserve_core::wire::PromptRequest {
4711 message_id: cmd.message_id.clone(),
4712 model: cmd
4713 .model
4714 .as_deref()
4715 .map(ocserve_core::wire::parse_model_str),
4716 agent: cmd.agent.clone(),
4717 no_reply: false,
4718 tools: None,
4719 format: None,
4720 system: None,
4721 variant: cmd.variant.clone(),
4722 parts: cmd_parts.as_array().cloned().unwrap_or_default(),
4723 };
4724 let ctx = build_prompt_context(&st, &req, &id)?;
4725 let _release = lock_session(&st, &id).await?;
4726 let Ok(_guard) = _release.arc().try_lock() else {
4727 return Err(session_busy(&id).into());
4728 };
4729 let writer = st.writer.clone();
4730 let (info, parts) = ocserve_core::prompt::run_prompt_with(
4731 &ctx,
4732 &writer,
4733 &id,
4734 &req,
4735 ocserve_core::prompt::RunOpts {
4736 auto_title: false,
4738 ..Default::default()
4739 },
4740 )
4741 .await
4742 .map_err(prompt_err)?;
4743 drop(_guard);
4744 Ok(Json(json!({"info": info, "parts": parts})))
4745}
4746
4747async fn post_shell(
4752 State(st): State<Arc<AppState>>,
4753 axum::extract::Path(id): axum::extract::Path<String>,
4754 Json(payload): Json<Value>,
4755) -> Result<Json<Value>, HttpError> {
4756 let sh = decode_v1(ocserve_core::wire::ShellRequest::decode(&payload))?;
4759 let exists_id = id.clone();
4760 if !run_blocking(&st.db, move |db| {
4761 ocserve_store::session_exists(db, &exists_id)
4762 })
4763 .await
4764 .map_err(|e| ApiError {
4765 status: StatusCode::INTERNAL_SERVER_ERROR,
4766 name: "InternalError",
4767 message: format!("{e:#}"),
4768 })? {
4769 return Err(ApiError::not_found(format!("Session not found: {id}")).into());
4770 }
4771 let command = sh.command.as_str();
4772 let agent = sh.agent.as_str();
4773 let (pid, mid) = resolve_model(&st, sh.model.as_ref(), &id);
4774
4775 let _release = lock_session(&st, &id).await?;
4776 let Ok(_guard) = _release.arc().try_lock() else {
4777 return Err(session_busy(&id).into());
4778 };
4779 let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
4780
4781 let status_frame = |kind: &str| {
4783 ocserve_core::event::frame(
4784 &dir,
4785 "session.status",
4786 json!({"sessionID": id, "status": {"type": kind}}),
4787 )
4788 };
4789 st.bus.publish(status_frame("busy"));
4790
4791 let started = now_ms_local();
4792 let user_msg_id = sh
4795 .message_id
4796 .clone()
4797 .unwrap_or_else(ocserve_core::ids::msg_id);
4798 let user_part = json!({
4799 "type": "text", "id": ocserve_core::ids::prt_id(),
4800 "sessionID": id, "messageID": user_msg_id,
4801 "text": "The following tool was executed by the user",
4802 "synthetic": true,
4803 });
4804 let user_info = json!({
4805 "id": user_msg_id,
4806 "sessionID": id,
4807 "role": "user",
4808 "agent": agent,
4809 "model": {"providerID": pid, "modelID": mid},
4810 "time": {"created": started},
4811 "summary": {"diffs": []},
4812 });
4813 let assistant_id = ocserve_core::ids::msg_id();
4814 let call_id = ocserve_core::ids::evt_id();
4815 let mut tool_part = json!({
4816 "type": "tool", "id": ocserve_core::ids::prt_id(),
4817 "sessionID": id, "messageID": assistant_id,
4818 "callID": call_id, "tool": "bash",
4819 "state": {
4820 "status": "running",
4821 "input": {"command": command},
4822 "time": {"start": started},
4823 },
4824 });
4825
4826 let writer = st.writer.clone();
4828 insert_message_http(
4829 &writer,
4830 &st,
4831 &id,
4832 &user_info,
4833 std::slice::from_ref(&user_part),
4834 )?;
4835 let assistant_info = json!({
4836 "id": assistant_id,
4837 "parentID": user_msg_id,
4838 "role": "assistant",
4839 "mode": agent,
4840 "agent": agent,
4841 "path": {"cwd": dir, "root": "/"},
4842 "cost": 0.0,
4843 "tokens": {"total": 0, "input": 0, "output": 0, "reasoning": 0,
4844 "cache": {"write": 0, "read": 0}},
4845 "modelID": mid,
4846 "providerID": pid,
4847 "time": {"created": started, "completed": started},
4848 "finish": "stop",
4849 "id": assistant_id,
4850 "sessionID": id,
4851 });
4852 insert_message_http(&writer, &st, &id, &assistant_info, &[])?;
4853 emit_durable_http(
4854 &st,
4855 &id,
4856 "message.updated",
4857 json!({"sessionID": id, "info": user_info.clone()}),
4858 )?;
4859 emit_durable_http(
4860 &st,
4861 &id,
4862 "message.updated",
4863 json!({"sessionID": id, "info": assistant_info.clone()}),
4864 )?;
4865 emit_durable_http(
4866 &st,
4867 &id,
4868 "message.part.updated",
4869 json!({"sessionID": id, "part": user_part}),
4870 )?;
4871 emit_durable_http(
4872 &st,
4873 &id,
4874 "message.part.updated",
4875 json!({"sessionID": id, "part": tool_part.clone()}),
4876 )?;
4877
4878 let exec = {
4882 let cmd = json!({"command": command});
4883 let d = dir.clone();
4884 tokio::task::spawn_blocking(move || {
4885 ocserve_tools::execute("bash", &cmd, std::path::Path::new(&d))
4886 })
4887 .await
4888 .map_err(|e| ApiError {
4889 status: StatusCode::INTERNAL_SERVER_ERROR,
4890 name: "InternalError",
4891 message: format!("tool join: {e}"),
4892 })?
4893 };
4894 let (status, output) = match exec {
4895 Ok(r) => ("completed", r.output),
4896 Err(e) => ("error", format!("Error: {e:#}")),
4897 };
4898 let completed = now_ms_local();
4899 tool_part["state"] = json!({
4902 "status": status,
4903 "input": {"command": command},
4904 "output": output,
4905 "title": "",
4906 "metadata": {"output": ""},
4907 "time": {"start": started, "end": completed},
4908 });
4909 tool_part["state"]["metadata"]["output"] = tool_part["state"]["output"].clone();
4910 insert_message_http(
4911 &writer,
4912 &st,
4913 &id,
4914 &assistant_info,
4915 std::slice::from_ref(&tool_part),
4916 )?;
4917 emit_durable_http(
4918 &st,
4919 &id,
4920 "message.part.updated",
4921 json!({"sessionID": id, "part": tool_part.clone()}),
4922 )?;
4923 st.bus.publish(status_frame("idle"));
4924
4925 Ok(Json(json!({"info": assistant_info, "parts": [tool_part]})))
4926}
4927
4928fn now_ms_local() -> i64 {
4929 std::time::SystemTime::now()
4930 .duration_since(std::time::UNIX_EPOCH)
4931 .map(|d| d.as_millis() as i64)
4932 .unwrap_or(0)
4933}
4934
4935fn insert_message_http(
4937 writer: &ocserve_store::Writer,
4938 st: &Arc<AppState>,
4939 sid: &str,
4940 info: &Value,
4941 parts: &[Value],
4942) -> Result<(), ApiError> {
4943 ocserve_store::insert_message(writer, Some(&*st.blobs), sid, info, parts).map_err(|e| {
4944 ApiError {
4945 status: StatusCode::INTERNAL_SERVER_ERROR,
4946 name: "InternalError",
4947 message: format!("{e:#}"),
4948 }
4949 })
4950}
4951
4952async fn session_wire_or_partial_http(st: &Arc<AppState>, sid: &str, overrides: Value) -> Value {
4958 let sid_owned = sid.to_string();
4959 run_blocking(&st.db, move |db| {
4960 ocserve_store::session_wire_with(db, &sid_owned, overrides)
4961 })
4962 .await
4963 .ok()
4964 .flatten()
4965 .unwrap_or_else(|| json!({"id": sid}))
4966}
4967pub(crate) fn emit_durable_http(
4968 st: &Arc<AppState>,
4969 sid: &str,
4970 event_type: &str,
4971 props: Value,
4972) -> Result<(), ApiError> {
4973 let props = ocserve_core::event::normalize_props(event_type, props);
4974 ocserve_store::append_event(&st.writer, Some(sid), event_type, &props).map_err(|e| {
4975 ApiError {
4976 status: StatusCode::INTERNAL_SERVER_ERROR,
4977 name: "InternalError",
4978 message: format!("{e:#}"),
4979 }
4980 })?;
4981 let seq = ocserve_store::next_event_seq(&st.db, sid).unwrap_or(0);
4982 let dir = st.paths["directory"].as_str().unwrap_or("/");
4983 st.bus
4984 .publish(ocserve_core::event::frame(dir, event_type, props.clone()));
4985 st.bus.publish(ocserve_core::event::sync_frame(
4986 dir, event_type, props, seq as u64, sid,
4987 ));
4988 Ok(())
4989}
4990
4991fn evt_id(kind: &str) -> String {
4995 use sha2::{Digest, Sha256};
4996 let nanos = std::time::SystemTime::now()
4997 .duration_since(std::time::UNIX_EPOCH)
4998 .map(|d| d.as_nanos())
4999 .unwrap_or(0);
5000 let mut h = Sha256::new();
5001 h.update(nanos.to_le_bytes());
5002 h.update(kind.as_bytes());
5003 let hex = hex::encode(h.finalize());
5004 format!("evt_{}", &hex[..26])
5005}
5006
5007pub(crate) async fn global_event(State(st): State<Arc<AppState>>) -> Response {
5015 event_stream(st, EventFormat::Global).await
5016}
5017
5018async fn bare_event(State(st): State<Arc<AppState>>) -> Response {
5022 event_stream(st, EventFormat::Bare).await
5023}
5024
5025async fn v2_event(State(st): State<Arc<AppState>>) -> Response {
5028 event_stream(st, EventFormat::V2).await
5029}
5030
5031#[derive(Clone, Copy)]
5032enum EventFormat {
5033 Global,
5035 Bare,
5037 V2,
5039}
5040
5041fn encode_event_frame(stored: &str, fmt: EventFormat) -> Option<String> {
5046 if matches!(fmt, EventFormat::Global) {
5047 return Some(stored.to_string());
5048 }
5049 let v: Value = serde_json::from_str(stored).ok()?;
5050 let payload = v.get("payload")?;
5051 if payload.get("type").and_then(Value::as_str) == Some("sync") {
5052 return None;
5053 }
5054 let id = payload.get("id").cloned().unwrap_or(Value::Null);
5055 let ty = payload.get("type").cloned().unwrap_or(Value::Null);
5056 let props = payload.get("properties").cloned().unwrap_or(json!({}));
5057 Some(match fmt {
5058 EventFormat::Bare => json!({"id": id, "type": ty, "properties": props}).to_string(),
5059 EventFormat::V2 => json!({"id": id, "type": ty, "data": props}).to_string(),
5060 EventFormat::Global => unreachable!(),
5061 })
5062}
5063
5064fn synthetic_frame(fmt: EventFormat, event_type: &str) -> String {
5069 let id = evt_id(if event_type == "server.connected" {
5070 "connected"
5071 } else {
5072 "heartbeat"
5073 });
5074 match fmt {
5075 EventFormat::Global => json!({"payload": {
5076 "id": id, "type": event_type, "properties": {}}})
5077 .to_string(),
5078 EventFormat::Bare => json!({
5079 "id": id, "type": event_type, "properties": {}})
5080 .to_string(),
5081 EventFormat::V2 => json!({
5082 "id": id, "type": event_type, "data": {}})
5083 .to_string(),
5084 }
5085}
5086
5087async fn event_stream(st: Arc<AppState>, fmt: EventFormat) -> Response {
5089 let connected = Event::default().data(synthetic_frame(fmt, "server.connected"));
5090 let rx = st.bus.subscribe();
5091 ocserve_metrics::gauge(
5092 "ocserve_sse_clients",
5093 st.bus.subscriber_count().max(1) as i64,
5094 );
5095 let bus_keepalive = st.bus.clone();
5098 struct SseClientGuard;
5100 impl Drop for SseClientGuard {
5101 fn drop(&mut self) {
5102 ocserve_metrics::gauge_delta("ocserve_sse_clients", -1);
5103 }
5104 }
5105 let sse_guard = SseClientGuard;
5106 let events = stream::once(async move { Ok::<_, std::convert::Infallible>(connected) }).chain(
5110 stream::unfold(
5111 (
5112 rx,
5113 tokio::time::Instant::now() + Duration::from_secs(10),
5114 bus_keepalive,
5115 sse_guard,
5116 fmt,
5117 ),
5118 |(mut rx, mut next_hb, bus, guard, fmt)| async move {
5119 loop {
5120 let dur = next_hb.saturating_duration_since(tokio::time::Instant::now());
5121 match tokio::time::timeout(dur.max(Duration::from_millis(1)), rx.recv()).await {
5122 Ok(Ok(frame)) => {
5123 ocserve_metrics::counter("ocserve_sse_events_total", 1);
5124 if let Some(out) = encode_event_frame(&frame, fmt) {
5128 return Some((
5129 Ok(Event::default().data(&out)),
5130 (rx, next_hb, bus, guard, fmt),
5131 ));
5132 }
5133 }
5134 Ok(Err(_lagged_or_closed)) => {
5135 ocserve_metrics::labeled_counter(
5136 "ocserve_event_ring_lag_total",
5137 "reason=\"lagged_or_closed\"",
5138 1,
5139 );
5140 return None;
5141 }
5142 Err(_elapsed) => {
5143 let hb =
5144 Event::default().data(synthetic_frame(fmt, "server.heartbeat"));
5145 next_hb += Duration::from_secs(10);
5146 return Some((Ok(hb), (rx, next_hb, bus, guard, fmt)));
5147 }
5148 }
5149 }
5150 },
5151 ),
5152 );
5153 let mut resp = Sse::new(events).into_response();
5156 let h = resp.headers_mut();
5158 for (k, v) in sse_expected_headers() {
5159 if let (Ok(name), Ok(val)) = (
5160 axum::http::HeaderName::try_from(k),
5161 HeaderValue::from_str(v),
5162 ) {
5163 h.insert(name, val);
5164 }
5165 }
5166 resp
5167}
5168
5169fn req_log() -> bool {
5176 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5177 *ON.get_or_init(|| {
5178 std::env::var("OCSERVE_REQ_LOG")
5179 .map(|v| v == "1")
5180 .unwrap_or(false)
5181 })
5182}
5183
5184async fn auth_gate(
5185 State(st): State<Arc<AppState>>,
5186 req: axum::extract::Request,
5187 next: axum::middleware::Next,
5188) -> Result<Response, ApiError> {
5189 st.requests
5190 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5191 ocserve_metrics::counter("ocserve_requests_total", 1);
5193 let metric_t0 = std::time::Instant::now();
5194 let metric_label = format!(
5195 "route=\"{}\",method=\"{}\"",
5196 ocserve_metrics::route_label(req.uri().path()),
5197 req.method()
5198 );
5199 let record = move |t0: std::time::Instant| {
5200 ocserve_metrics::observe(
5201 "ocserve_http_request_duration_seconds",
5202 &metric_label,
5203 t0.elapsed().as_micros() as u64,
5204 );
5205 };
5206 if req_log() {
5211 tracing::info!("req {} {}", req.method(), req.uri().path());
5212 }
5213 if let Some((user, pass)) = &st.auth {
5214 let authorized = req
5215 .headers()
5216 .get(axum::http::header::AUTHORIZATION)
5217 .and_then(|v| v.to_str().ok())
5218 .and_then(|v| v.strip_prefix("Basic "))
5219 .and_then(|b64| {
5220 use base64::Engine;
5221 base64::engine::general_purpose::STANDARD.decode(b64).ok()
5222 })
5223 .map(|raw| {
5224 let text = String::from_utf8_lossy(&raw);
5225 text == format!("{user}:{pass}")
5226 })
5227 .unwrap_or(false);
5228 if !authorized {
5229 record(metric_t0);
5230 return Err(ApiError {
5231 status: StatusCode::UNAUTHORIZED,
5232 name: "UnauthorizedError",
5233 message: "unauthorized".into(),
5234 });
5235 }
5236 }
5237 let resp = next.run(req).await;
5238 record(metric_t0);
5239 Ok(resp)
5240}
5241
5242pub fn router(state: Arc<AppState>) -> Router {
5244 Router::new()
5245 .route("/global/health", get(health))
5246 .route("/doc", get(get_doc))
5247 .route("/config", get(get_config))
5248 .route("/config/providers", get(get_config_providers))
5249 .route("/provider", get(get_provider))
5250 .route("/agent", get(get_agent))
5251 .route("/command", get(get_command))
5252 .route("/experimental/console", get(get_console))
5253 .route("/experimental/tool", get(get_experimental_tool))
5254 .route("/experimental/tool/ids", get(get_experimental_tool_ids))
5255 .route("/experimental/capabilities", get(get_capabilities))
5256 .route("/path", get(get_path))
5257 .route("/project", get(get_projects))
5258 .route("/project/current", get(get_project_current))
5259 .route("/session", get(get_sessions).post(post_session))
5260 .route("/session/{id}/fork", axum::routing::post(post_fork))
5261 .route("/session/status", get(session_status))
5262 .route("/session/search", axum::routing::post(search_messages))
5263 .route("/mcp/{name}/{action}", axum::routing::post(mcp_action))
5264 .route("/mcp/{name}/auth", axum::routing::delete(mcp_auth_remove))
5265 .route(
5266 "/auth/{providerID}",
5267 axum::routing::put(auth_put).delete(auth_delete),
5268 )
5269 .route("/provider/auth", axum::routing::get(provider_auth_methods))
5270 .route("/global/dispose", axum::routing::post(global_dispose))
5271 .route("/config", axum::routing::patch(patch_config))
5272 .route("/global/config", axum::routing::patch(patch_config))
5273 .route(
5274 "/session/{id}/summarize",
5275 axum::routing::post(post_summarize),
5276 )
5277 .route(
5278 "/session/{id}",
5279 get(get_session)
5280 .patch(patch_session)
5281 .delete(delete_session_route),
5282 )
5283 .route(
5284 "/session/{id}/message",
5285 get(get_messages).post(post_message),
5286 )
5287 .route("/global/event", get(global_event))
5288 .route("/event", get(bare_event))
5293 .route("/api/event", get(v2_event))
5294 .route("/metrics", get(metrics))
5295 .route("/experimental/resource", get(experimental_resource))
5298 .route(
5299 "/experimental/workspace",
5300 get(experimental_workspace).post(v2::workspace_create),
5301 )
5302 .route(
5305 "/experimental/workspace/status",
5306 get(experimental_workspace_status),
5307 )
5308 .route("/formatter", get(formatter_list))
5309 .route("/lsp", get(lsp_list))
5310 .route("/project/{id}/directories", get(project_directories))
5311 .route("/vcs", get(vcs_info))
5312 .route("/vcs/status", get(vcs_status))
5313 .route("/vcs/diff", get(vcs_diff))
5314 .route("/vcs/diff/raw", get(vcs_diff_raw))
5315 .route("/file/status", get(file_status))
5316 .route("/find/symbol", get(find_symbol))
5317 .route("/global/config", get(global_config_get))
5318 .route(
5319 "/session/{id}/prompt_async",
5320 axum::routing::post(post_prompt_async),
5321 )
5322 .route("/session/{id}/children", get(get_children))
5323 .route("/file", get(list_directory))
5324 .route("/file/content", get(read_file_content))
5325 .route("/find/file", get(find_files))
5326 .route("/find", get(find_text))
5327 .route("/question", get(get_questions))
5328 .route(
5329 "/question/{id}/reply",
5330 axum::routing::post(post_question_reply),
5331 )
5332 .route(
5333 "/question/{id}/reject",
5334 axum::routing::post(post_question_reject),
5335 )
5336 .route("/session/{id}/todo", get(get_todos))
5337 .route("/session/{id}/abort", axum::routing::post(post_abort))
5338 .route("/session/{id}/command", axum::routing::post(post_command))
5339 .route("/session/{id}/shell", axum::routing::post(post_shell))
5340 .route(
5341 "/session/{id}/message/{mid}",
5342 get(get_message_by_id).delete(delete_message_route),
5343 )
5344 .route(
5347 "/session/{id}/permissions/{pid}",
5348 axum::routing::post(post_session_permission_respond),
5349 )
5350 .route("/log", axum::routing::post(post_log))
5352 .route("/session/{id}/revert", axum::routing::post(post_revert))
5356 .route("/session/{id}/unrevert", axum::routing::post(post_unrevert))
5357 .route("/instance/dispose", axum::routing::post(instance_dispose))
5359 .route("/session/{id}/init", axum::routing::post(post_init))
5362 .route(
5366 "/provider/{id}/oauth/authorize",
5367 axum::routing::post(post_provider_oauth),
5368 )
5369 .route(
5370 "/provider/{id}/oauth/callback",
5371 axum::routing::post(post_provider_oauth_callback),
5372 )
5373 .route(
5377 "/mcp/{name}/auth/authenticate",
5378 axum::routing::post(post_mcp_auth_authenticate),
5379 )
5380 .route(
5381 "/mcp/{name}/auth/callback",
5382 axum::routing::post(post_mcp_auth_callback),
5383 )
5384 .route(
5385 "/session/{id}/message/{mid}/part/{pid}",
5386 axum::routing::delete(delete_part_route).patch(patch_part_route),
5387 )
5388 .route("/experimental/session", get(get_experimental_sessions))
5389 .route("/pty/shells", get(pty::get_shells))
5391 .route("/pty", get(pty::list).post(pty::create))
5392 .route(
5393 "/pty/{id}",
5394 get(pty::get).put(pty::update).delete(pty::remove),
5395 )
5396 .route(
5397 "/pty/{id}/connect-token",
5398 axum::routing::post(pty::connect_token),
5399 )
5400 .route("/pty/{id}/connect", get(pty::connect))
5401 .route(
5403 "/tui/append-prompt",
5404 axum::routing::post(tui::append_prompt),
5405 )
5406 .route("/tui/open-help", axum::routing::post(tui::open_help))
5407 .route(
5408 "/tui/open-sessions",
5409 axum::routing::post(tui::open_sessions),
5410 )
5411 .route("/tui/open-themes", axum::routing::post(tui::open_themes))
5412 .route("/tui/open-models", axum::routing::post(tui::open_models))
5413 .route(
5414 "/tui/submit-prompt",
5415 axum::routing::post(tui::submit_prompt),
5416 )
5417 .route("/tui/clear-prompt", axum::routing::post(tui::clear_prompt))
5418 .route(
5419 "/tui/execute-command",
5420 axum::routing::post(tui::execute_command),
5421 )
5422 .route("/tui/show-toast", axum::routing::post(tui::show_toast))
5423 .route("/tui/publish", axum::routing::post(tui::publish_event))
5424 .route(
5425 "/tui/select-session",
5426 axum::routing::post(tui::select_session),
5427 )
5428 .route("/tui/control/next", get(tui::control_next))
5429 .route(
5430 "/tui/control/response",
5431 axum::routing::post(tui::control_response),
5432 )
5433 .route("/mcp", get(get_mcp).post(post_mcp))
5434 .route("/permission", get(get_permissions))
5435 .route(
5436 "/permission/{id}/reply",
5437 axum::routing::post(post_permission_reply),
5438 )
5439 .route("/api/health", get(v2::health))
5442 .route("/api/location", get(v2::location))
5443 .route("/api/agent", get(get_api_agent))
5444 .route("/api/model", get(v2::list_models))
5445 .route("/api/provider", get(v2::list_providers))
5446 .route("/api/provider/{providerID}", get(v2::get_provider))
5447 .route("/api/skill", get(v2::list_skills))
5448 .route("/skill", get(v2::list_skills_raw))
5449 .route("/vcs/apply", axum::routing::post(v2::vcs_apply))
5450 .route("/api/command", get(get_api_command))
5451 .route("/api/integration", get(v2::list_integrations))
5452 .route("/api/integration/{integrationID}", get(v2::get_integration))
5453 .route("/api/reference", get(v2::list_references))
5454 .route("/api/fs/find", get(v2::fs_find))
5455 .route("/api/fs/list", get(v2::fs_list))
5456 .route("/api/fs/read/{*path}", get(v2::fs_read))
5457 .route(
5459 "/api/session",
5460 get(v2::list_sessions).post(v2::create_session),
5461 )
5462 .route("/api/session/active", get(v2::active_sessions))
5463 .route("/api/session/{sessionID}", get(v2::get_session))
5464 .route("/api/session/{sessionID}/message", get(v2::list_messages))
5465 .route(
5466 "/api/session/{sessionID}/message/{messageID}",
5467 get(v2::get_message),
5468 )
5469 .route("/api/session/{sessionID}/context", get(v2::session_context))
5470 .route("/api/session/{sessionID}/history", get(v2::session_history))
5471 .route(
5472 "/api/session/{sessionID}/agent",
5473 axum::routing::post(v2::switch_agent),
5474 )
5475 .route(
5476 "/api/session/{sessionID}/model",
5477 axum::routing::post(v2::switch_model),
5478 )
5479 .route(
5480 "/api/session/{sessionID}/prompt",
5481 axum::routing::post(v2::prompt),
5482 )
5483 .route(
5484 "/api/session/{sessionID}/interrupt",
5485 axum::routing::post(v2::interrupt),
5486 )
5487 .route(
5488 "/api/session/{sessionID}/compact",
5489 axum::routing::post(v2::compact),
5490 )
5491 .route(
5492 "/api/session/{sessionID}/wait",
5493 axum::routing::post(v2::wait),
5494 )
5495 .route(
5496 "/api/session/{sessionID}/revert/stage",
5497 axum::routing::post(v2::revert_stage),
5498 )
5499 .route(
5500 "/api/session/{sessionID}/revert/clear",
5501 axum::routing::post(v2::revert_clear),
5502 )
5503 .route(
5504 "/api/session/{sessionID}/revert/commit",
5505 axum::routing::post(v2::revert_commit),
5506 )
5507 .route(
5508 "/api/session/{sessionID}/permission/{requestID}/reply",
5509 axum::routing::post(v2::permission_reply),
5510 )
5511 .route(
5512 "/api/session/{sessionID}/question/{requestID}/reply",
5513 axum::routing::post(v2::question_reply),
5514 )
5515 .route(
5516 "/api/session/{sessionID}/question/{requestID}/reject",
5517 axum::routing::post(v2::question_reject),
5518 )
5519 .route("/api/permission/saved", get(v2::permission_saved))
5521 .route(
5522 "/api/permission/saved/{id}",
5523 axum::routing::delete(v2::permission_saved_delete),
5524 )
5525 .route("/api/permission/request", get(v2::permission_request))
5526 .route("/api/question/request", get(v2::question_request))
5527 .route(
5528 "/api/session/{sessionID}/question",
5529 get(v2::session_question),
5530 )
5531 .route(
5532 "/api/session/{sessionID}/permission/{requestID}",
5533 get(v2::session_permission_one),
5534 )
5535 .route(
5536 "/api/session/{sessionID}/permission",
5537 get(v2::session_permission).post(v2::permission_create),
5538 )
5539 .route("/api/pty", get(v2::pty_list).post(v2::pty_create))
5541 .route(
5542 "/api/pty/{ptyID}",
5543 get(v2::pty_get).put(v2::pty_update).delete(v2::pty_remove),
5544 )
5545 .route(
5546 "/api/pty/{ptyID}/connect-token",
5547 axum::routing::post(v2::pty_connect_token),
5548 )
5549 .route("/api/pty/{ptyID}/connect", get(pty::connect))
5550 .route("/session/{sessionID}/diff", get(v2::session_diff))
5552 .route(
5553 "/session/{sessionID}/share",
5554 axum::routing::post(v2::session_share).delete(v2::session_unshare),
5555 )
5556 .route("/experimental/console/orgs", get(v2::console_orgs))
5558 .route(
5559 "/experimental/console/switch",
5560 axum::routing::post(v2::console_switch),
5561 )
5562 .route(
5563 "/experimental/workspace/adapter",
5564 get(v2::workspace_adapter),
5565 )
5566 .route(
5567 "/experimental/workspace/sync-list",
5568 axum::routing::post(v2::workspace_sync_list),
5569 )
5570 .route(
5571 "/experimental/workspace/warp",
5572 axum::routing::post(v2::workspace_warp),
5573 )
5574 .route(
5575 "/experimental/worktree",
5576 get(v2::worktree_list).post(v2::worktree_create),
5577 )
5578 .route(
5579 "/experimental/worktree",
5580 axum::routing::delete(v2::worktree_remove),
5581 )
5582 .route(
5583 "/experimental/worktree/reset",
5584 axum::routing::post(v2::worktree_reset),
5585 )
5586 .route("/api/session/{sessionID}/event", get(v2::session_event))
5587 .route(
5588 "/project/{projectID}",
5589 axum::routing::patch(v2::patch_project),
5590 )
5591 .route("/sync/start", axum::routing::post(v2::sync_start))
5594 .route("/sync/replay", axum::routing::post(v2::sync_replay))
5595 .route("/sync/history", axum::routing::post(v2::sync_history))
5596 .route("/sync/steal", axum::routing::post(v2::sync_steal))
5597 .route("/project/git/init", axum::routing::post(v2::git_init))
5598 .route("/global/upgrade", axum::routing::post(v2::global_upgrade))
5599 .route(
5600 "/api/integration/{integrationID}/connect/key",
5601 axum::routing::post(v2::integration_connect_key),
5602 )
5603 .route(
5604 "/api/integration/{integrationID}/connect/oauth",
5605 axum::routing::post(v2::integration_connect_oauth),
5606 )
5607 .route(
5608 "/api/integration/attempt/{attemptID}",
5609 get(v2::integration_attempt_get).delete(v2::integration_attempt_delete),
5610 )
5611 .route(
5612 "/api/integration/attempt/{attemptID}/complete",
5613 axum::routing::post(v2::integration_attempt_complete),
5614 )
5615 .route(
5616 "/api/credential/{credentialID}",
5617 axum::routing::patch(v2::credential_update).delete(v2::credential_remove),
5618 )
5619 .route(
5620 "/experimental/control-plane/move-session",
5621 axum::routing::post(v2::control_move_session),
5622 )
5623 .route(
5624 "/experimental/project/{projectID}/copy",
5625 axum::routing::post(v2::project_copy_create).delete(v2::project_copy_remove),
5626 )
5627 .route(
5628 "/experimental/project/{projectID}/copy/generate-name",
5629 axum::routing::post(v2::project_copy_generate_name),
5630 )
5631 .route(
5632 "/experimental/project/{projectID}/copy/refresh",
5633 axum::routing::post(v2::project_copy_refresh),
5634 )
5635 .route(
5636 "/experimental/session/{sessionID}/background",
5637 axum::routing::post(v2::session_background),
5638 )
5639 .route(
5640 "/experimental/workspace/{id}",
5641 axum::routing::delete(v2::workspace_delete),
5642 )
5643 .layer(axum::middleware::from_fn_with_state(
5644 state.clone(),
5645 auth_gate,
5646 ))
5647 .layer(axum::middleware::from_fn_with_state(
5650 state.clone(),
5651 crate::ui::cors_gate,
5652 ))
5653 .fallback(crate::ui::ui_fallback)
5657 .method_not_allowed_fallback(crate::ui::ui_fallback)
5661 .with_state(state)
5662}
5663
5664fn read_cors_env() -> Vec<String> {
5668 std::env::var("OCSERVE_CORS")
5669 .ok()
5670 .map(|v| {
5671 v.split(',')
5672 .map(str::trim)
5673 .filter(|s| !s.is_empty())
5674 .map(str::to_string)
5675 .collect()
5676 })
5677 .unwrap_or_default()
5678}
5679
5680pub fn keypaths(v: &serde_json::Value, prefix: &str, out: &mut std::collections::BTreeSet<String>) {
5683 match v {
5684 serde_json::Value::Object(m) => {
5685 for (k, val) in m {
5686 let p = format!("{prefix}.{k}");
5687 out.insert(p.clone());
5688 keypaths(val, &p, out);
5689 }
5690 }
5691 serde_json::Value::Array(a) => {
5692 let p = format!("{prefix}[]");
5693 if let Some(first) = a.first() {
5694 keypaths(first, &p, out);
5695 } else {
5696 out.insert(p);
5697 }
5698 }
5699 _ => {}
5700 }
5701}
5702
5703pub fn sse_expected_headers() -> Vec<(&'static str, &'static str)> {
5705 vec![
5706 ("cache-control", "no-cache, no-transform"),
5707 ("x-content-type-options", "nosniff"),
5708 ("content-type", "text/event-stream"),
5709 ("x-accel-buffering", "no"),
5710 ]
5711}
5712
5713#[allow(dead_code)]
5714fn _assert_header_value(v: &str) -> HeaderValue {
5715 HeaderValue::from_str(v).expect("static header value")
5716}
5717
5718#[cfg(test)]
5719mod limit_matrix {
5720 use super::parse_limit;
5721
5722 #[test]
5723 fn matches_probe_envelopes() {
5724 assert_eq!(
5726 parse_limit("abc").unwrap_err(),
5727 "Expected an integer, got NaN\n at [\"limit\"]"
5728 );
5729 assert_eq!(
5730 parse_limit("5.5").unwrap_err(),
5731 "Expected an integer, got 5.5\n at [\"limit\"]"
5732 );
5733 assert_eq!(
5734 parse_limit("-0.5").unwrap_err(),
5735 "Expected an integer, got -0.5\n at [\"limit\"]"
5736 );
5737 assert_eq!(
5738 parse_limit("-1").unwrap_err(),
5739 "Expected a value greater than or equal to 0, got -1\n at [\"limit\"]"
5740 );
5741 assert_eq!(
5742 parse_limit("999999999999999999999").unwrap_err(),
5743 "Expected an integer, got 1e+21\n at [\"limit\"]"
5744 );
5745 assert_eq!(
5746 parse_limit("Infinity").unwrap_err(),
5747 "Expected an integer, got Infinity\n at [\"limit\"]"
5748 );
5749 }
5750
5751 #[test]
5752 fn accepts_integers_like_js_number() {
5753 assert_eq!(parse_limit("+5"), Ok(5));
5754 assert_eq!(parse_limit("1e3"), Ok(1000)); assert_eq!(parse_limit("0"), Ok(0));
5756 assert_eq!(parse_limit("9007199254740991"), Ok(9007199254740991)); }
5758}
5759
5760#[cfg(test)]
5761mod f1_blocking_tests {
5762 use super::{AppState, Payloads, router, run_blocking};
5763 use std::sync::Arc;
5764 use std::sync::atomic::{AtomicU64, Ordering};
5765 use std::time::Duration;
5766
5767 #[tokio::test]
5780 async fn find_files_walk_runs_off_the_worker() {
5781 use tower::ServiceExt as _;
5782 let dir = std::env::temp_dir().join(format!(
5783 "ocserve-m3-walk-{}-{}",
5784 std::process::id(),
5785 std::time::SystemTime::now()
5786 .duration_since(std::time::UNIX_EPOCH)
5787 .unwrap()
5788 .as_nanos()
5789 ));
5790 std::fs::create_dir_all(&dir).unwrap();
5791 for i in 0..200 {
5792 std::fs::write(dir.join(format!("f{i}.txt")), b"x").unwrap();
5793 }
5794
5795 let mut payloads = Payloads::default();
5796 payloads.config["directory"] = serde_json::Value::String(dir.to_string_lossy().to_string());
5797 let st = AppState::with_payloads(None, payloads);
5798
5799 let handler_thread = std::thread::current().id();
5800 let req = axum::http::Request::builder()
5801 .uri("/find/file?query=f1&limit=10")
5802 .body(axum::body::Body::empty())
5803 .unwrap();
5804 let resp = tokio::spawn(async move { router(st).oneshot(req).await })
5805 .await
5806 .expect("route task")
5807 .expect("infallible");
5808 assert_eq!(resp.status(), 200);
5809
5810 let walked_on = super::WALK_THREAD.lock().ok().and_then(|g| *g);
5811 let walked_on = walked_on.expect("walk_files must record its thread");
5812 assert_ne!(
5813 walked_on, handler_thread,
5814 "the walk ran on the async worker — blocking syscalls must go \
5815 through spawn_blocking (M3)"
5816 );
5817 let _ = std::fs::remove_dir_all(&dir);
5818 }
5819
5820 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
5826
5827 async fn run_blocking_frees_the_worker() {
5828 let ticks = Arc::new(AtomicU64::new(0));
5829 let t = ticks.clone();
5830 let ticker = tokio::spawn(async move {
5831 let mut i = 0u64;
5832 loop {
5833 tokio::time::sleep(Duration::from_millis(10)).await;
5834 i += 1;
5835 t.store(i, Ordering::Relaxed);
5836 }
5837 });
5838 tokio::time::sleep(Duration::from_millis(30)).await;
5839 let before = ticks.load(Ordering::Relaxed);
5840 assert!(before > 0, "ticker must be running before the probe");
5841
5842 let probe = tokio::spawn(async move {
5847 run_blocking(std::path::Path::new("/nonexistent/f1-probe"), |_db| {
5848 std::thread::sleep(Duration::from_millis(250));
5849 Ok::<(), anyhow::Error>(())
5850 })
5851 .await
5852 .expect("probe op");
5853 });
5854 probe.await.expect("probe join");
5855
5856 let after = ticks.load(Ordering::Relaxed);
5857 ticker.abort();
5858 assert!(
5859 after > before,
5860 "blocking call parked the worker: before={before} after={after}"
5861 );
5862 }
5863}
5864
5865#[cfg(test)]
5866mod f5_wire {
5867 use super::*;
5868
5869 #[test]
5872 fn wire_bytes_are_serde_identical() {
5873 let st = AppState::new();
5874 let p = st.payloads.read();
5875 let w = st.wire.read();
5876 for (k, v) in [
5877 ("config", &p.config),
5878 ("config_providers", &p.config_providers),
5879 ("provider", &p.provider),
5880 ("console", &p.console),
5881 ("capabilities", &p.capabilities),
5882 ] {
5883 let cached = w.get(k).unwrap_or_else(|| panic!("missing wire key {k}"));
5884 let want = serde_json::to_vec(v).unwrap();
5885 assert_eq!(
5886 &cached.identity[..],
5887 &want[..],
5888 "wire bytes for {k} must equal Value serialization"
5889 );
5890 }
5891 for (k, v) in [("agent", &p.agent), ("command", &p.command)] {
5892 let cached = w.get(k).unwrap_or_else(|| panic!("missing wire key {k}"));
5893 let want = serde_json::to_vec(v).unwrap();
5894 assert_eq!(&cached.identity[..], &want[..]);
5895 }
5896 }
5897
5898 #[test]
5900 fn wire_off_disables_cache() {
5901 let empty = rebuild_wire(
5902 &Payloads::default(),
5903 true,
5904 &json!({"directory": "/", "project": {"id": "global", "directory": "/"}}),
5905 );
5906 assert!(empty.is_empty(), "off => nothing serialized");
5907 let st = AppState::new();
5908 st.wire_off
5909 .store(true, std::sync::atomic::Ordering::Relaxed);
5910 assert!(wire_json(&st, "config").is_none(), "off => Value path");
5911 }
5912
5913 #[test]
5917 fn v2_metadata_wire_matches_builders() {
5918 for (catalog, name) in [
5919 (
5920 serde_json::json!({"deepseek": {"name": "DeepSeek"}}),
5921 "with-catalog",
5922 ),
5923 (serde_json::json!({}), "empty-catalog"),
5924 ] {
5925 let payloads = Payloads {
5926 provider: serde_json::json!({
5927 "connected": ["opencode", "deepseek"],
5928 "all": [
5929 {"id": "opencode", "name": "opencode", "models": {"m": {"id": "m", "providerID": "opencode", "name": "M"}}},
5930 {"id": "deepseek", "name": "DeepSeek", "models": {"d": {"id": "d", "providerID": "deepseek", "name": "D"}}},
5931 ]
5932 }),
5933 config: serde_json::json!({"provider": {"entrim": {"name": "Entrim"}}}),
5934 catalog: std::sync::Arc::new(catalog),
5935 ..Default::default()
5936 };
5937 let location = json!({"directory": "/", "project": {"id": "global", "directory": "/"}});
5938 let wire = rebuild_wire(&payloads, false, &location);
5939 for (key, want) in [
5940 ("api_model", crate::v2::models_body(&payloads, &location)),
5941 (
5942 "api_provider",
5943 crate::v2::providers_body(&payloads, &location),
5944 ),
5945 (
5946 "api_integration",
5947 crate::v2::integrations_body(&payloads, &location),
5948 ),
5949 ] {
5950 let e = wire
5951 .get(key)
5952 .unwrap_or_else(|| panic!("[{name}] missing wire key {key}"));
5953 assert_eq!(
5954 &e.identity[..],
5955 &serde_json::to_vec(&want).unwrap()[..],
5956 "[{name}] {key} wire bytes must equal the pure builder"
5957 );
5958 }
5959 }
5960 }
5961}
5962
5963#[cfg(test)]
5964mod doc_contract {
5965 #[test]
5969 fn spec_is_valid_and_complete() {
5970 let d: serde_json::Value =
5971 serde_json::from_str(crate::doc::SPEC_JSON).expect("vendored spec must be valid JSON");
5972 assert_eq!(d["openapi"], "3.1.0");
5973 let paths = d["paths"].as_object().expect("paths object");
5974 assert_eq!(paths.len(), 162, "frozen 1.18.31 has 162 paths");
5975 let ops: usize = paths
5976 .values()
5977 .map(|v| {
5978 v.as_object()
5979 .unwrap()
5980 .keys()
5981 .filter(|k| matches!(k.as_str(), "get" | "post" | "put" | "delete" | "patch"))
5982 .count()
5983 })
5984 .sum();
5985 assert_eq!(ops, 188, "frozen 1.18.31 has 188 operations");
5986 assert!(d["components"]["schemas"].as_object().unwrap().len() >= 400);
5987 }
5988}