1use crate::{ApiError, AppState, HttpError, api_location, run_blocking};
23use axum::Json;
24use axum::extract::{Path, Query, State};
25use axum::http::StatusCode;
26use axum::response::IntoResponse;
27use serde_json::{Value, json};
28use std::sync::Arc;
29
30pub async fn health() -> impl IntoResponse {
32 Json(json!({"healthy": true}))
33}
34
35pub async fn location(State(st): State<Arc<AppState>>) -> impl IntoResponse {
37 Json(api_location(&st))
38}
39
40fn available_provider_ids(payloads: &crate::Payloads) -> Vec<String> {
54 let connected: Vec<String> = payloads
55 .provider
56 .get("connected")
57 .and_then(|v| v.as_array())
58 .map(|a| {
59 a.iter()
60 .filter_map(|v| v.as_str().map(String::from))
61 .collect()
62 })
63 .unwrap_or_default();
64 let cfg_providers = payloads.config.get("provider").and_then(|v| v.as_object());
65 let cat_obj = payloads.catalog.as_object();
66 connected
67 .into_iter()
68 .filter(|pid| {
69 if pid == "opencode" {
70 return true;
71 }
72 let has_cfg_key = cfg_providers
73 .and_then(|c| c.get(pid))
74 .and_then(|v| v.pointer("/options/apiKey"))
75 .and_then(|v| v.as_str())
76 .is_some_and(|s| !s.is_empty());
77 if has_cfg_key {
78 return true;
79 }
80 cat_obj.map(|c| !c.contains_key(pid)).unwrap_or(true)
82 })
83 .collect()
84}
85
86fn project_model_v2(v1: &Value) -> Value {
88 let id = v1.get("id").cloned().unwrap_or(Value::Null);
89 let provider_id = v1.get("providerID").cloned().unwrap_or(Value::Null);
90 let api = v1.get("api").cloned().unwrap_or(json!({}));
91 let npm = api
96 .get("npm")
97 .and_then(|v| v.as_str())
98 .filter(|s| !s.is_empty());
99 let mut v2api = match npm {
100 Some(pkg) => json!({"id": id, "type": "aisdk", "package": pkg}),
101 None => json!({"id": id, "type": "native", "settings": {}}),
102 };
103 if let Some(url) = api.get("url") {
104 v2api["url"] = url.clone();
105 }
106 let caps = v1.get("capabilities").cloned().unwrap_or(json!({}));
108 let arr_of = |keys: &[&str]| -> Vec<Value> {
109 keys.iter()
110 .filter(|k| caps.get(**k).and_then(|v| v.as_bool()).unwrap_or(false))
111 .map(|k| json!(k))
112 .collect()
113 };
114 let input = {
115 let mut v = vec![json!("text")];
116 if caps
117 .get("attachment")
118 .and_then(|v| v.as_bool())
119 .unwrap_or(false)
120 {
121 v.push(json!("image"));
122 }
123 v
124 };
125 let _ = arr_of;
126 let capabilities = json!({
127 "tools": caps.get("toolcall").and_then(|v| v.as_bool()).unwrap_or(false),
128 "input": input,
129 "output": [json!("text")],
130 });
131 let cost = v1.get("cost").cloned().unwrap_or(json!({}));
133 let cache = cost.get("cache").cloned().unwrap_or(json!({}));
134 let cost_v2 = json!([{
135 "input": cost.get("input").cloned().unwrap_or(json!(0)),
136 "output": cost.get("output").cloned().unwrap_or(json!(0)),
137 "cache": {
138 "read": cache.get("read").cloned().unwrap_or(json!(0)),
139 "write": cache.get("write").cloned().unwrap_or(json!(0)),
140 }
141 }]);
142 let limit = v1.get("limit").cloned().unwrap_or(json!({}));
143 let variants: Vec<Value> = v1
148 .get("variants")
149 .and_then(|v| v.as_object())
150 .map(|m| {
151 m.iter()
152 .map(|(k, vv)| {
153 let body = vv
154 .get("body")
155 .or_else(|| vv.get("settings"))
156 .cloned()
157 .unwrap_or(json!({}));
158 json!({"id": k, "headers": {}, "body": body})
159 })
160 .collect()
161 })
162 .unwrap_or_default();
163 let released = v1
164 .get("release_date")
165 .and_then(|v| v.as_str())
166 .and_then(parse_release_ms);
167 let mut out = json!({
168 "id": id,
169 "providerID": provider_id,
170 "name": v1.get("name").cloned().unwrap_or(Value::Null),
171 "api": v2api,
172 "capabilities": capabilities,
173 "request": {"headers": {}, "body": {}},
174 "variants": variants,
175 "time": {"released": released.unwrap_or(0)},
176 "cost": cost_v2,
177 "status": v1.get("status").cloned().unwrap_or(json!("active")),
178 "enabled": v1.get("status").and_then(|s| s.as_str()).map(|s| s != "deprecated").unwrap_or(true),
179 "limit": {"context": limit.get("context").cloned().unwrap_or(json!(0)), "output": limit.get("output").cloned().unwrap_or(json!(0))},
180 });
181 if let Some(fam) = v1.get("family") {
182 out["family"] = fam.clone();
183 }
184 out
185}
186
187fn parse_release_ms(date: &str) -> Option<i64> {
188 let parts: Vec<&str> = date.split('-').collect();
190 if parts.len() != 3 {
191 return None;
192 }
193 let y: i64 = parts[0].parse().ok()?;
194 let m: i64 = parts[1].parse().ok()?;
195 let d: i64 = parts[2].parse().ok()?;
196 let y2 = if m <= 2 { y - 1 } else { y };
198 let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
199 let yoe = y2 - era * 400;
200 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
201 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
202 let days = era * 146097 + doe - 719468;
203 Some(days * 86_400_000)
204}
205
206pub async fn list_providers(
208 State(st): State<Arc<AppState>>,
209 headers: axum::http::HeaderMap,
210) -> axum::response::Response {
211 let ae = headers
212 .get(axum::http::header::ACCEPT_ENCODING)
213 .and_then(|v| v.to_str().ok());
214 let inm = headers
215 .get(axum::http::header::IF_NONE_MATCH)
216 .and_then(|v| v.to_str().ok());
217 crate::wire_json_h(&st, "api_provider", ae, inm).unwrap_or_else(|| {
218 let payloads = st.payloads.read();
219 Json(providers_body(&payloads, &api_location(&st))).into_response()
220 })
221}
222
223pub fn providers_body(p: &crate::Payloads, location: &Value) -> Value {
228 let avail = available_provider_ids(p);
229 let all = p
230 .provider
231 .get("all")
232 .and_then(|v| v.as_array())
233 .map(|v| v.as_slice())
234 .unwrap_or(&[]);
235 let data: Vec<Value> = all
236 .iter()
237 .filter(|p| {
238 p.get("id")
239 .and_then(|v| v.as_str())
240 .is_some_and(|id| avail.iter().any(|a| a == id))
241 })
242 .map(project_provider_v2)
243 .collect();
244 json!({"location": location, "data": data})
245}
246
247pub fn models_body(p: &crate::Payloads, location: &Value) -> Value {
249 let avail = available_provider_ids(p);
250 let all = p
251 .provider
252 .get("all")
253 .and_then(|v| v.as_array())
254 .map(|v| v.as_slice())
255 .unwrap_or(&[]);
256 let mut data: Vec<Value> = Vec::new();
257 for p in all {
258 let pid = p.get("id").and_then(|v| v.as_str()).unwrap_or("");
259 if !avail.iter().any(|a| a == pid) {
260 continue;
261 }
262 if let Some(models) = p.get("models").and_then(|m| m.as_object()) {
263 for m in models.values() {
264 if m.get("status").and_then(|s| s.as_str()) == Some("deprecated") {
265 continue;
266 }
267 data.push(project_model_v2(m));
268 }
269 }
270 }
271 data.sort_by(|a, b| {
272 let ra = a
273 .pointer("/time/released")
274 .and_then(|v| v.as_i64())
275 .unwrap_or(0);
276 let rb = b
277 .pointer("/time/released")
278 .and_then(|v| v.as_i64())
279 .unwrap_or(0);
280 rb.cmp(&ra)
281 });
282 json!({"location": location, "data": data})
283}
284
285pub fn integrations_body(p: &crate::Payloads, location: &Value) -> Value {
287 let catalog = p.catalog.as_object();
288 let mut out: Vec<Value> = Vec::new();
289 if let Some(map) = catalog {
290 for (id, entry) in map {
291 let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(id);
292 let env = entry
293 .get("env")
294 .and_then(|v| v.as_array())
295 .cloned()
296 .unwrap_or_default();
297 out.push(integration_entry(id, name, &env));
298 }
299 }
300 if let Some(cps) = p.config.get("provider").and_then(|v| v.as_object()) {
302 for (id, v) in cps {
303 if catalog.map(|c| c.contains_key(id)).unwrap_or(false) {
304 continue;
305 }
306 let name = v.get("name").and_then(|x| x.as_str()).unwrap_or(id);
307 out.push(integration_entry(id, name, &[]));
308 }
309 }
310 json!({"location": location, "data": out})
311}
312
313fn project_provider_v2(v1: &Value) -> Value {
314 let id = v1.get("id").cloned().unwrap_or(Value::Null);
315 let npm = v1
316 .pointer("/models")
317 .and_then(|m| m.as_object())
318 .and_then(|m| m.values().next())
319 .and_then(|m| m.pointer("/api/npm"))
320 .and_then(|v| v.as_str());
321 let url = v1
322 .pointer("/options/baseURL")
323 .and_then(|v| v.as_str())
324 .or_else(|| {
325 v1.pointer("/models")
326 .and_then(|m| m.as_object())
327 .and_then(|m| m.values().next())
328 .and_then(|m| m.pointer("/api/url"))
329 .and_then(|v| v.as_str())
330 })
331 .unwrap_or("");
332 let mut api = json!({"type": if npm.is_some() { "aisdk" } else { "native" }});
333 if let Some(n) = npm {
334 api["package"] = json!(n);
335 }
336 if !url.is_empty() {
337 api["url"] = json!(url);
338 }
339 api["settings"] = json!({});
340 let mut body = json!({});
344 if id == json!("opencode") {
345 body = json!({"apiKey": "public"});
346 } else if let Some(k) = v1.pointer("/options/apiKey").and_then(|v| v.as_str())
347 && !k.is_empty()
348 {
349 body = json!({"apiKey": k});
350 }
351 json!({
352 "id": id,
353 "name": v1.get("name").cloned().unwrap_or(Value::Null),
354 "api": api,
355 "request": {"headers": {}, "body": body},
356 })
357}
358
359pub async fn get_provider(
361 State(st): State<Arc<AppState>>,
362 Path(provider_id): Path<String>,
363) -> Result<Json<Value>, ApiError> {
364 let payloads = st.payloads.read();
365 let all = payloads
366 .provider
367 .get("all")
368 .and_then(|v| v.as_array())
369 .map(|v| v.as_slice())
370 .unwrap_or(&[]);
371 match all
372 .iter()
373 .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(&provider_id))
374 {
375 Some(p) => Ok(Json(
376 json!({"location": api_location(&st), "data": project_provider_v2(p)}),
377 )),
378 None => Err(ApiError {
379 status: StatusCode::NOT_FOUND,
380 name: "NotFoundError",
381 message: format!("Provider not found: {provider_id}"),
382 }),
383 }
384}
385
386pub async fn list_models(
389 State(st): State<Arc<AppState>>,
390 headers: axum::http::HeaderMap,
391) -> axum::response::Response {
392 let ae = headers
393 .get(axum::http::header::ACCEPT_ENCODING)
394 .and_then(|v| v.to_str().ok());
395 let inm = headers
396 .get(axum::http::header::IF_NONE_MATCH)
397 .and_then(|v| v.to_str().ok());
398 crate::wire_json_h(&st, "api_model", ae, inm).unwrap_or_else(|| {
399 let payloads = st.payloads.read();
400 Json(models_body(&payloads, &api_location(&st))).into_response()
401 })
402}
403
404pub async fn list_skills(State(st): State<Arc<AppState>>) -> impl IntoResponse {
411 let payloads = st.payloads.read();
412 let data: Vec<Value> = payloads
413 .command
414 .iter()
415 .filter(|c| c.get("source").and_then(|v| v.as_str()) == Some("skill"))
416 .map(|c| {
417 json!({
418 "name": c.get("name").cloned().unwrap_or(Value::Null),
419 "description": c.get("description").cloned().unwrap_or(json!("")),
420 "slash": false,
421 "location": "",
422 "content": c.get("template").cloned().unwrap_or(json!("")),
423 })
424 })
425 .collect();
426 Json(json!({"location": api_location(&st), "data": data}))
427}
428
429fn integration_entry(id: &str, name: &str, env: &[Value]) -> Value {
431 let mut methods = vec![json!({"type": "key"})];
432 if !env.is_empty() {
433 methods.push(json!({"type": "env", "names": env}));
434 }
435 json!({"id": id, "name": name, "methods": methods, "connections": []})
436}
437
438pub async fn list_integrations(
440 State(st): State<Arc<AppState>>,
441 headers: axum::http::HeaderMap,
442) -> axum::response::Response {
443 let ae = headers
444 .get(axum::http::header::ACCEPT_ENCODING)
445 .and_then(|v| v.to_str().ok());
446 let inm = headers
447 .get(axum::http::header::IF_NONE_MATCH)
448 .and_then(|v| v.to_str().ok());
449 crate::wire_json_h(&st, "api_integration", ae, inm).unwrap_or_else(|| {
450 let payloads = st.payloads.read();
451 Json(integrations_body(&payloads, &api_location(&st))).into_response()
452 })
453}
454
455pub async fn get_integration(
457 State(st): State<Arc<AppState>>,
458 Path(id): Path<String>,
459) -> Result<Json<Value>, ApiError> {
460 let payloads = st.payloads.read();
461 let items = integrations_body(&payloads, &api_location(&st));
462 match items["data"]
465 .as_array()
466 .and_then(|a| {
467 a.iter()
468 .find(|i| i.get("id").and_then(|v| v.as_str()) == Some(&id))
469 })
470 .cloned()
471 {
472 Some(i) => Ok(Json(json!({"location": api_location(&st), "data": i}))),
473 None => Ok(Json(json!({"location": api_location(&st), "data": null}))),
474 }
475}
476
477pub async fn list_references(State(st): State<Arc<AppState>>) -> impl IntoResponse {
479 Json(json!({"location": api_location(&st), "data": []}))
480}
481
482pub async fn fs_find(
488 State(st): State<Arc<AppState>>,
489 Query(q): Query<std::collections::HashMap<String, String>>,
490) -> Result<Json<Value>, crate::HttpError> {
491 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
492 let query = q
495 .get("query")
496 .ok_or_else(|| crate::HttpError::TaggedData {
497 status: StatusCode::BAD_REQUEST,
498 tag: "InvalidRequestError",
499 fields: json!({"message": "Missing key\n at [\"query\"]", "kind": "Query"}),
500 })?
501 .to_lowercase();
502 if query.is_empty() {
503 return Ok(Json(json!({"location": api_location(&st), "data": []})));
504 }
505 let want_type = q.get("type").map(String::as_str).unwrap_or("").to_string();
506 let limit: usize = q
507 .get("limit")
508 .and_then(|l| l.parse().ok())
509 .unwrap_or(50)
510 .clamp(1, 200);
511 let glob_mode = query.contains('*') || query.contains('?');
512 let walk_base = base.clone();
513 let out = tokio::task::spawn_blocking(move || {
514 crate::walk_files(&walk_base, &query, &want_type, limit, glob_mode)
515 })
516 .await
517 .map_err(|e| {
518 crate::HttpError::Api(ApiError {
519 status: StatusCode::INTERNAL_SERVER_ERROR,
520 name: "InternalServerError",
521 message: format!("fs_find worker: {e}"),
522 })
523 })?
524 .map_err(|e| {
525 crate::HttpError::Api(ApiError {
526 status: StatusCode::BAD_REQUEST,
527 name: "BadRequest",
528 message: format!("fs_find: {e:#}"),
529 })
530 })?;
531 let data: Vec<Value> = out
532 .into_iter()
533 .map(|p| {
534 let is_dir = p.ends_with('/')
535 || std::fs::metadata(base_join(&base, &p))
536 .map(|m| m.is_dir())
537 .unwrap_or(false);
538 json!({"path": p, "type": if is_dir { "directory" } else { "file" }})
539 })
540 .collect();
541 Ok(Json(json!({"location": api_location(&st), "data": data})))
542}
543
544fn base_join(base: &std::path::Path, rel: &str) -> std::path::PathBuf {
545 base.join(rel.trim_end_matches('/'))
546}
547
548pub async fn fs_list(
550 State(st): State<Arc<AppState>>,
551 Query(q): Query<std::collections::HashMap<String, String>>,
552) -> Result<Json<Value>, ApiError> {
553 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
554 let rel = q.get("path").cloned().unwrap_or_default();
555 let dir = resolve_within(&base, &rel).map_err(|_| ApiError {
559 status: StatusCode::BAD_REQUEST,
560 name: "BadRequest",
561 message: "path escapes the project directory".into(),
562 })?;
563 let entries = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<Value>> {
564 let mut out = Vec::new();
565 let rd = std::fs::read_dir(&dir)?;
566 for e in rd.flatten() {
567 let name = e.file_name().to_string_lossy().to_string();
568 if name == ".git" || name == "node_modules" {
569 continue;
570 }
571 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
572 out.push(json!({
573 "path": if is_dir { format!("{name}/") } else { name },
574 "type": if is_dir { "directory" } else { "file" },
575 }));
576 }
577 out.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str()));
578 Ok(out)
579 })
580 .await
581 .map_err(|e| ApiError {
582 status: StatusCode::INTERNAL_SERVER_ERROR,
583 name: "InternalServerError",
584 message: format!("fs_list worker: {e}"),
585 })?
586 .map_err(|e| ApiError {
587 status: StatusCode::BAD_REQUEST,
588 name: "BadRequest",
589 message: format!("fs_list: {e:#}"),
590 })?;
591 Ok(Json(
592 json!({"location": api_location(&st), "data": entries}),
593 ))
594}
595
596fn resolve_within(base: &std::path::Path, rel: &str) -> std::io::Result<std::path::PathBuf> {
602 let base_canon = base.canonicalize()?;
603 let joined = base_canon.join(rel.trim_start_matches('/'));
604 match joined.canonicalize() {
605 Ok(real) => {
606 if real.starts_with(&base_canon) {
607 Ok(real)
608 } else {
609 Err(std::io::Error::new(
610 std::io::ErrorKind::PermissionDenied,
611 "path escapes the project directory",
612 ))
613 }
614 }
615 Err(_) => {
617 let lex = lexically_normalize(&joined);
618 if lex.starts_with(&base_canon) {
619 Ok(joined)
620 } else {
621 Err(std::io::Error::new(
622 std::io::ErrorKind::PermissionDenied,
623 "path escapes the project directory",
624 ))
625 }
626 }
627 }
628}
629
630fn lexically_normalize(p: &std::path::Path) -> std::path::PathBuf {
633 use std::path::Component;
634 let mut out = std::path::PathBuf::new();
635 for c in p.components() {
636 match c {
637 Component::ParentDir => {
638 out.pop();
639 }
640 Component::CurDir => {}
641 other => out.push(other.as_os_str()),
642 }
643 }
644 out
645}
646
647pub async fn fs_read(
649 State(st): State<Arc<AppState>>,
650 Path(path): Path<String>,
651) -> Result<axum::response::Response, ApiError> {
652 let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
653 let full = base.join(path.trim_start_matches('/'));
654 let canon = full.canonicalize().map_err(|_| ApiError {
655 status: StatusCode::NOT_FOUND,
656 name: "NotFoundError",
657 message: format!("File not found: {path}"),
658 })?;
659 if !canon.starts_with(&base) {
660 return Err(ApiError {
661 status: StatusCode::BAD_REQUEST,
662 name: "BadRequest",
663 message: "path escapes the project directory".into(),
664 });
665 }
666 let bytes = tokio::fs::read(&canon).await.map_err(|e| ApiError {
667 status: StatusCode::NOT_FOUND,
668 name: "NotFoundError",
669 message: format!("File not found: {path} ({e})"),
670 })?;
671 Ok((
672 [(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
673 bytes,
674 )
675 .into_response())
676}
677
678pub async fn session_diff(
687 State(st): State<Arc<AppState>>,
688 Path(id): Path<String>,
689) -> Result<Json<Value>, ApiError> {
690 let sid = id.clone();
691 let s = run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
692 .await
693 .map_err(internal)?;
694 let files = s
695 .as_ref()
696 .and_then(|s| s.get("revert"))
697 .and_then(|r| r.get("files"))
698 .cloned()
699 .unwrap_or(json!([]));
700 Ok(Json(files))
701}
702
703fn internal(e: anyhow::Error) -> ApiError {
704 ApiError {
705 status: StatusCode::INTERNAL_SERVER_ERROR,
706 name: "InternalError",
707 message: format!("{e:#}"),
708 }
709}
710
711fn session_not_found(id: &str) -> crate::HttpError {
715 crate::HttpError::TaggedData {
716 status: StatusCode::NOT_FOUND,
717 tag: "SessionNotFoundError",
718 fields: json!({"sessionID": id, "message": format!("Session not found: {id}")}),
719 }
720}
721
722fn session_v2(s: &Value) -> Value {
728 let dir = s.get("directory").and_then(|v| v.as_str()).unwrap_or("/");
729 let mut out = json!({
730 "id": s.get("id").cloned().unwrap_or(Value::Null),
731 "projectID": s.get("projectID").cloned().unwrap_or(json!("global")),
732 "cost": s.get("cost").cloned().unwrap_or(json!(0)),
733 "tokens": s.get("tokens").cloned().unwrap_or(json!({"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}})),
734 "time": s.get("time").cloned().unwrap_or(json!({"created":0,"updated":0})),
735 "title": s.get("title").cloned().unwrap_or(json!("")),
736 "location": {"directory": dir},
737 });
738 if let Some(pid) = s.get("parentID").and_then(|v| v.as_str()) {
739 out["parentID"] = json!(pid);
740 }
741 if let Some(a) = s.get("agent").and_then(|v| v.as_str()) {
742 out["agent"] = json!(a);
743 }
744 if let Some(m) = s.get("model")
745 && !m.is_null()
746 {
747 out["model"] = m.clone();
748 }
749 if let Some(path) = s.get("path").and_then(|v| v.as_str())
752 && !path.is_empty()
753 {
754 out["subpath"] = json!(path);
755 }
756 if let Some(r) = s.get("revert")
758 && !r.is_null()
759 {
760 out["revert"] = r.clone();
761 }
762 out
763}
764
765pub async fn list_sessions(
767 State(st): State<Arc<AppState>>,
768 Query(q): Query<std::collections::HashMap<String, String>>,
769) -> Result<Json<Value>, crate::HttpError> {
770 let q_err = |message: String| crate::HttpError::TaggedData {
774 status: StatusCode::BAD_REQUEST,
775 tag: "InvalidRequestError",
776 fields: json!({"message": message, "kind": "Query"}),
777 };
778 let limit: usize = match q.get("limit") {
779 None => 100,
780 Some(raw) => {
781 let n: f64 = raw
782 .parse()
783 .map_err(|_| q_err("Expected an integer, got NaN\n at [\"limit\"]".into()))?;
784 if !n.is_finite() || n < 0.0 || n.fract() != 0.0 {
785 return Err(q_err(format!(
786 "Expected an integer, got {raw}\n at [\"limit\"]"
787 )));
788 }
789 (n as usize).clamp(1, 200)
790 }
791 };
792 if let Some(order) = q.get("order")
793 && order != "asc"
794 && order != "desc"
795 {
796 return Err(q_err(format!(
797 "Expected \"asc\" | \"desc\", got \"{order}\"\n at [\"order\"]"
798 )));
799 }
800 if let Some(cur) = q.get("cursor")
801 && ocserve_store::decode_cursor(cur).is_err()
802 {
803 return Err(crate::HttpError::TaggedData {
804 status: StatusCode::BAD_REQUEST,
805 tag: "InvalidCursorError",
806 fields: json!({"message": "Invalid cursor"}),
807 });
808 }
809 let roots = q.get("roots").map(|v| v == "true").unwrap_or(false);
810 let search = q.get("search").cloned().unwrap_or_default().to_lowercase();
811 let sid = q.get("sessionID").cloned();
812 let db = st.db.clone();
813 let mut sessions = run_blocking(&db, ocserve_store::load_sessions_wire)
814 .await
815 .map_err(|e| crate::HttpError::Api(internal(e)))?;
816 if roots {
817 sessions.retain(|s| s.get("parentID").map(|p| p.is_null()).unwrap_or(true));
818 }
819 if !search.is_empty() {
820 sessions.retain(|s| {
821 s.get("title")
822 .and_then(|v| v.as_str())
823 .map(|t| t.to_lowercase().contains(&search))
824 .unwrap_or(false)
825 });
826 }
827 let _ = sid;
828 let total = sessions.len();
829 let more = total > limit;
830 let window: Vec<Value> = sessions
831 .into_iter()
832 .take(limit)
833 .map(|s| session_v2(&s))
834 .collect();
835 let _ = more;
838 Ok(Json(
839 json!({"data": window, "cursor": {"previous": Value::Null, "next": Value::Null}}),
840 ))
841}
842
843pub async fn active_sessions() -> impl IntoResponse {
846 Json(json!({"data": {}}))
847}
848
849pub async fn get_session(
851 State(st): State<Arc<AppState>>,
852 Path(id): Path<String>,
853) -> Result<Json<Value>, crate::HttpError> {
854 let sid = id.clone();
855 let s = run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
856 .await
857 .map_err(|e| crate::HttpError::Api(internal(e)))?;
858 match s {
859 Some(s) => Ok(Json(json!({"data": session_v2(&s)}))),
860 None => Err(session_not_found(&id)),
863 }
864}
865
866pub async fn list_messages(
868 State(st): State<Arc<AppState>>,
869 Path(id): Path<String>,
870 Query(q): Query<std::collections::HashMap<String, String>>,
871) -> Result<Json<Value>, crate::HttpError> {
872 let limit: usize = q
873 .get("limit")
874 .and_then(|l| l.parse().ok())
875 .unwrap_or(50)
876 .clamp(1, 200);
877 let s = {
878 let sid = id.clone();
879 run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
880 .await
881 .map_err(|e| crate::HttpError::Api(internal(e)))?
882 };
883 if s.is_none() {
884 return Err(session_not_found(&id));
885 }
886 let sid = id.clone();
887 let msgs = run_blocking(&st.db, move |db| {
888 ocserve_store::page_messages(db, &sid, limit as u64, None)
889 })
890 .await
891 .map_err(|e| crate::HttpError::Api(internal(e)))?;
892 let data: Vec<Value> = msgs
893 .0
894 .into_iter()
895 .filter_map(|(mid, info)| project_message_v2(&mid, &info))
896 .collect();
897 let mut cursor = json!({"previous": Value::Null, "next": Value::Null});
899 if let Some(next) = msgs.2 {
900 cursor["next"] = json!(next);
901 }
902 Ok(Json(json!({"data": data, "cursor": cursor})))
903}
904
905fn project_message_v2(id: &str, info: &str) -> Option<Value> {
909 let v: Value = serde_json::from_str(info).ok()?;
910 let role = v.get("role").and_then(|r| r.as_str()).unwrap_or("");
911 let created = v.pointer("/time/created").cloned().unwrap_or(json!(0));
912 let time = json!({"created": created});
913 match role {
914 "user" => {
915 let text = v
916 .pointer("/summary/title")
917 .and_then(|t| t.as_str())
918 .unwrap_or("")
919 .to_string();
920 Some(json!({"id": id, "time": time, "type": "user", "text": text}))
921 }
922 "assistant" => Some(json!({
923 "id": id,
924 "time": time,
925 "type": "assistant",
926 "agent": v.get("agent").cloned().unwrap_or(json!("build")),
927 "model": {"id": v.get("modelID").cloned().unwrap_or(json!("")), "providerID": v.get("providerID").cloned().unwrap_or(json!(""))},
928 "content": [],
929 "cost": v.get("cost").cloned().unwrap_or(json!(0)),
930 "tokens": normalize_tokens(v.get("tokens")),
931 })),
932 _ => None,
933 }
934}
935
936fn normalize_tokens(t: Option<&Value>) -> Value {
937 let t = t.cloned().unwrap_or(json!({}));
938 let cache = t.get("cache").cloned().unwrap_or(json!({}));
939 json!({
940 "input": t.get("input").cloned().unwrap_or(json!(0)),
941 "output": t.get("output").cloned().unwrap_or(json!(0)),
942 "reasoning": t.get("reasoning").cloned().unwrap_or(json!(0)),
943 "cache": {"read": cache.get("read").cloned().unwrap_or(json!(0)), "write": cache.get("write").cloned().unwrap_or(json!(0))},
944 })
945}
946
947pub async fn get_message(
949 State(st): State<Arc<AppState>>,
950 Path((id, mid)): Path<(String, String)>,
951) -> Result<Json<Value>, ApiError> {
952 let sid = id.clone();
953 let mid2 = mid.clone();
954 let row = run_blocking(&st.db, move |db| {
955 ocserve_store::message_by_id(db, &sid, &mid2)
956 })
957 .await
958 .map_err(internal)?;
959 let info = row
960 .and_then(|v| v.get("info").map(|i| i.to_string()))
961 .and_then(|info| project_message_v2(&mid, &info));
962 match info {
963 Some(m) => Ok(Json(json!({"data": m}))),
964 None => Err(ApiError {
965 status: StatusCode::NOT_FOUND,
966 name: "NotFoundError",
967 message: format!("Message not found: {mid} (session {id})"),
968 }),
969 }
970}
971
972pub async fn session_context(
976 State(st): State<Arc<AppState>>,
977 Path(id): Path<String>,
978) -> Result<Json<Value>, crate::HttpError> {
979 list_messages(State(st), Path(id), Query(std::collections::HashMap::new())).await
980}
981
982pub async fn session_history(
986 State(st): State<Arc<AppState>>,
987 Path(id): Path<String>,
988) -> Result<Json<Value>, ApiError> {
989 let sid = id.clone();
990 let events = run_blocking(&st.db, move |db| {
991 ocserve_store::load_session_events(db, &sid, 100)
992 })
993 .await
994 .map_err(internal)?;
995 Ok(Json(json!({"data": events, "hasMore": false})))
996}
997
998pub async fn permission_saved(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1004 let _ = &st;
1007 Json(json!({"data": []}))
1008}
1009
1010pub async fn permission_request(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1012 let data = st.gate.list();
1013 Json(json!({"location": api_location(&st), "data": data}))
1014}
1015
1016pub async fn question_request(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1018 let data = st.question_gate.list();
1019 Json(json!({"location": api_location(&st), "data": data}))
1020}
1021
1022pub async fn session_permission(
1024 State(st): State<Arc<AppState>>,
1025 Path(_id): Path<String>,
1026) -> impl IntoResponse {
1027 Json(json!({"data": st.gate.list()}))
1028}
1029
1030pub async fn session_question(
1032 State(st): State<Arc<AppState>>,
1033 Path(_id): Path<String>,
1034) -> impl IntoResponse {
1035 Json(json!({"data": st.question_gate.list()}))
1036}
1037
1038pub async fn switch_agent(
1044 State(st): State<Arc<AppState>>,
1045 Path(id): Path<String>,
1046 Json(body): Json<Value>,
1047) -> Result<StatusCode, crate::HttpError> {
1048 let agent = body.get("agent").and_then(|v| v.as_str()).unwrap_or("");
1049 set_session_field(&st, &id, "agent", json!(agent)).await?;
1050 Ok(StatusCode::NO_CONTENT)
1051}
1052
1053pub async fn switch_model(
1055 State(st): State<Arc<AppState>>,
1056 Path(id): Path<String>,
1057 Json(body): Json<Value>,
1058) -> Result<StatusCode, crate::HttpError> {
1059 set_session_field(
1060 &st,
1061 &id,
1062 "model",
1063 body.get("model").cloned().unwrap_or(json!({})),
1064 )
1065 .await?;
1066 Ok(StatusCode::NO_CONTENT)
1067}
1068
1069async fn set_session_field(
1070 st: &AppState,
1071 id: &str,
1072 field: &str,
1073 val: Value,
1074) -> Result<(), crate::HttpError> {
1075 let col = match field {
1076 "agent" => "agent",
1077 "model" => "model",
1078 _ => {
1079 return Err(crate::HttpError::Api(ApiError {
1080 status: StatusCode::BAD_REQUEST,
1081 name: "BadRequest",
1082 message: format!("unknown field {field}"),
1083 }));
1084 }
1085 };
1086 let db = st.db.clone();
1092 let id_check = id.to_string();
1093 let exists = tokio::task::spawn_blocking(move || ocserve_store::session_exists(&db, &id_check))
1094 .await
1095 .map_err(|e| crate::HttpError::Api(internal(anyhow::anyhow!("exists join: {e}"))))?
1096 .map_err(|e| crate::HttpError::Api(internal(e)))?;
1097 if !exists {
1098 return Err(session_not_found(id));
1099 }
1100 let text = if field == "model" && val.is_object() {
1101 val.to_string()
1102 } else if let Some(s) = val.as_str() {
1103 s.to_string()
1104 } else {
1105 val.to_string()
1106 };
1107 let w = st.writer.clone();
1108 let id_w = id.to_string();
1109 let col_w = col.to_string();
1110 tokio::task::spawn_blocking(move || {
1111 w.write(vec![ocserve_store::WriteOp::Sql {
1112 sql: format!("UPDATE session SET {col_w} = ?2 WHERE id = ?1"),
1113 params: vec![id_w.into(), text.into()],
1114 }])
1115 })
1116 .await
1117 .map_err(|e| crate::HttpError::Api(internal(anyhow::anyhow!("write join: {e}"))))?
1118 .map_err(|e| crate::HttpError::Api(internal(e)))?;
1119 Ok(())
1120}
1121
1122pub async fn console_orgs() -> impl IntoResponse {
1128 Json(json!({"orgs": []}))
1129}
1130
1131pub async fn workspace_list() -> impl IntoResponse {
1133 Json(json!([]))
1134}
1135
1136pub async fn workspace_status() -> impl IntoResponse {
1138 Json(json!([]))
1139}
1140
1141pub async fn workspace_adapter() -> impl IntoResponse {
1143 Json(json!([{"type": "worktree", "name": "Worktree", "description": "Create a git worktree"}]))
1144}
1145
1146pub async fn worktree_list() -> impl IntoResponse {
1148 Json(json!([]))
1149}
1150
1151pub async fn prompt(
1162 State(st): State<Arc<AppState>>,
1163 Path(id): Path<String>,
1164 Json(body): Json<Value>,
1165) -> Result<Json<Value>, HttpError> {
1166 let input = crate::decode_v2(ocserve_core::wire::V2PromptInput::decode(&body))?;
1169 let req = ocserve_core::wire::PromptRequest {
1170 message_id: input.id.clone(), parts: normalize_v2_prompt_parts(&input),
1172 ..Default::default()
1173 };
1174 let ctx = crate::build_prompt_context(&st, &req, &id)?;
1175 let release = crate::lock_session(&st, &id).await?;
1176 let Ok(_guard) = release.arc().try_lock() else {
1177 return Err(crate::session_busy(&id).into());
1178 };
1179 let writer = st.writer.clone();
1180 let (info, _parts) = ocserve_core::prompt::run_prompt(&ctx, &writer, &id, &req)
1181 .await
1182 .map_err(crate::prompt_err)?;
1183 drop(_guard);
1184 let msg_id = info.get("id").cloned().unwrap_or(Value::Null);
1185 Ok(Json(json!({
1186 "data": {
1187 "id": msg_id,
1188 "sessionID": id,
1189 "admittedSeq": 0,
1190 "prompt": body.get("prompt").cloned().unwrap_or(json!({"text": ""})),
1191 "delivery": Value::String(input.delivery.unwrap_or_else(|| "steer".into())),
1194 "timeCreated": info.pointer("/time/created").cloned().unwrap_or(json!(0)),
1195 }
1196 })))
1197}
1198
1199fn normalize_v2_prompt_parts(input: &ocserve_core::wire::V2PromptInput) -> Vec<Value> {
1202 let mut parts = Vec::new();
1203 parts.push(json!({"type": "text", "text": input.prompt_text}));
1206 for f in &input.prompt_files {
1207 parts.push(json!({"type": "file", "file": f}));
1208 }
1209 for a in &input.prompt_agents {
1210 parts.push(json!({"type": "agent", "agent": a}));
1211 }
1212 parts
1213}
1214
1215pub async fn interrupt(
1217 State(st): State<Arc<AppState>>,
1218 Path(id): Path<String>,
1219) -> Result<StatusCode, ApiError> {
1220 if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
1221 handle.abort();
1222 }
1223 Ok(StatusCode::NO_CONTENT)
1224}
1225
1226pub async fn compact(
1228 State(st): State<Arc<AppState>>,
1229 Path(id): Path<String>,
1230) -> Result<StatusCode, ApiError> {
1231 let payload = ocserve_core::wire::PromptRequest::default();
1234 let ctx = match crate::build_prompt_context(&st, &payload, &id) {
1235 Ok(c) => c,
1236 Err(e) => {
1237 return Err(ApiError {
1238 status: StatusCode::SERVICE_UNAVAILABLE,
1239 name: "ServiceUnavailableError",
1240 message: e.message,
1241 });
1242 }
1243 };
1244 let release = crate::lock_session(&st, &id).await?;
1245 let Ok(_guard) = release.arc().try_lock() else {
1246 return Err(crate::session_busy(&id));
1247 };
1248 let writer = st.writer.clone();
1249 let _ = crate::run_compact(&st, &ctx, &writer, &id).await;
1250 drop(_guard);
1251 Ok(StatusCode::NO_CONTENT)
1252}
1253
1254pub async fn wait(State(_): State<Arc<AppState>>, Path(_id): Path<String>) -> impl IntoResponse {
1257 (
1258 StatusCode::SERVICE_UNAVAILABLE,
1259 Json(
1260 json!({"_tag": "ServiceUnavailableError", "message": "Session wait is not available yet", "service": "session.wait"}),
1261 ),
1262 )
1263}
1264
1265pub async fn revert_stage(
1269 State(st): State<Arc<AppState>>,
1270 Path(id): Path<String>,
1271 Json(body): Json<Value>,
1272) -> Result<Json<Value>, HttpError> {
1273 let mid = body.get("messageID").and_then(|v| v.as_str()).unwrap_or("");
1274 if mid.is_empty() {
1275 return Err(payload_err("messageID"));
1276 }
1277 let part_id = body
1278 .get("partID")
1279 .and_then(|v| v.as_str())
1280 .filter(|p| !p.is_empty())
1281 .map(String::from);
1282 let marker = crate::revert_apply(&st, &id, mid, part_id.as_deref())
1283 .await
1284 .map_err(HttpError::Api)?;
1285 crate::emit_durable_http(
1286 &st,
1287 &id,
1288 "session.next.revert.staged",
1289 json!({"sessionID": id, "timestamp": now_ms_http(), "revert": marker.to_value()}),
1290 )
1291 .map_err(HttpError::Api)?;
1292 Ok(Json(json!({"data": marker.to_value()})))
1293}
1294
1295pub async fn revert_clear(
1298 State(st): State<Arc<AppState>>,
1299 Path(id): Path<String>,
1300) -> Result<StatusCode, ApiError> {
1301 let existing = run_blocking(&st.db, {
1303 let sid = id.to_string();
1304 move |db| ocserve_store::load_session_wire(db, &sid)
1305 })
1306 .await
1307 .map_err(internal)?
1308 .and_then(|s| {
1309 s.get("revert")
1310 .and_then(|r| r.get("snapshot"))
1311 .and_then(|v| v.as_str())
1312 .map(String::from)
1313 });
1314 let dir = crate::session_directory_http(&st, &id).await;
1315 if let Some(snap) = crate::snapshots_for(&st, &dir)
1316 && let Some(s) = existing
1317 {
1318 let _ = run_blocking(&st.db, move |_db| {
1319 snap.restore(&s);
1320 Ok::<_, anyhow::Error>(())
1321 })
1322 .await;
1323 }
1324 ocserve_store::set_session_revert(&st.writer, &id, None).map_err(internal)?;
1325 crate::emit_durable_http(
1326 &st,
1327 &id,
1328 "session.next.revert.cleared",
1329 json!({"sessionID": id}),
1330 )?;
1331 Ok(StatusCode::NO_CONTENT)
1332}
1333
1334pub async fn revert_commit(
1337 State(st): State<Arc<AppState>>,
1338 Path(id): Path<String>,
1339) -> Result<StatusCode, ApiError> {
1340 let mid = run_blocking(&st.db, {
1341 let sid = id.to_string();
1342 move |db| ocserve_store::load_session_wire(db, &sid)
1343 })
1344 .await
1345 .map_err(internal)?
1346 .and_then(|s| {
1347 s.get("revert")
1348 .and_then(|r| r.get("messageID"))
1349 .and_then(|v| v.as_str())
1350 .map(String::from)
1351 });
1352 if let Some(mid) = mid {
1353 let rows = run_blocking(&st.db, {
1355 let sid = id.to_string();
1356 let mid = mid.clone();
1357 move |db| ocserve_store::messages_from(db, &sid, &mid)
1358 })
1359 .await
1360 .map_err(internal)?;
1361 for m in rows {
1362 let _ = ocserve_store::delete_message(&st.writer, &st.db, &id, &m);
1363 }
1364 ocserve_store::set_session_revert(&st.writer, &id, None).map_err(internal)?;
1365 crate::emit_durable_http(
1366 &st,
1367 &id,
1368 "session.next.revert.committed",
1369 json!({"sessionID": id, "messageID": mid, "timestamp": now_ms_http()}),
1370 )?;
1371 }
1372 Ok(StatusCode::NO_CONTENT)
1373}
1374
1375fn now_ms_http() -> i64 {
1380 std::time::SystemTime::now()
1381 .duration_since(std::time::UNIX_EPOCH)
1382 .map(|d| d.as_millis() as i64)
1383 .unwrap_or(0)
1384}
1385
1386fn payload_err(key: &str) -> HttpError {
1387 HttpError::TaggedData {
1388 status: StatusCode::BAD_REQUEST,
1389 tag: "InvalidRequestError",
1390 fields: json!({"message": format!("Missing key\n at [\"{key}\"]"), "kind": "Payload"}),
1391 }
1392}
1393
1394pub async fn permission_reply(
1398 State(st): State<Arc<AppState>>,
1399 Path((_sid, rid)): Path<(String, String)>,
1400 Json(body): Json<Value>,
1401) -> Result<Json<Value>, crate::HttpError> {
1402 let reply = crate::validate_permission_reply(&body)?;
1403 if st.gate.reply(&rid, reply) {
1404 Ok(Json(json!({"data": {}})))
1405 } else {
1406 Err(crate::HttpError::TaggedData {
1407 status: StatusCode::NOT_FOUND,
1408 tag: "PermissionNotFoundError",
1409 fields: json!({"requestID": rid, "message": format!("Permission request not found: {rid}")}),
1410 })
1411 }
1412}
1413
1414pub async fn question_reply(
1416 State(st): State<Arc<AppState>>,
1417 Path((_sid, rid)): Path<(String, String)>,
1418 Json(body): Json<Value>,
1419) -> Result<Json<Value>, crate::HttpError> {
1420 crate::validate_request_prefix(&rid, "que")?;
1421 let raw = body
1422 .get("answers")
1423 .and_then(|a| a.as_array())
1424 .ok_or_else(|| {
1425 crate::HttpError::Api(ApiError {
1426 status: StatusCode::BAD_REQUEST,
1427 name: "BadRequest",
1428 message: "body must be {answers: [[String]]}".into(),
1429 })
1430 })?;
1431 let mut answers: Vec<Vec<String>> = Vec::with_capacity(raw.len());
1432 for a in raw {
1433 answers.push(
1434 a.as_array()
1435 .map(|r| {
1436 r.iter()
1437 .map(|l| l.as_str().unwrap_or_default().to_string())
1438 .collect()
1439 })
1440 .unwrap_or_default(),
1441 );
1442 }
1443 if st.question_gate.reply(&rid, answers) {
1444 Ok(Json(json!({"data": {}})))
1445 } else {
1446 Err(crate::question_not_found(&rid))
1447 }
1448}
1449
1450pub async fn question_reject(
1452 State(st): State<Arc<AppState>>,
1453 Path((_sid, rid)): Path<(String, String)>,
1454) -> Result<Json<Value>, crate::HttpError> {
1455 crate::validate_request_prefix(&rid, "que")?;
1456 if st.question_gate.reject(&rid) {
1457 Ok(Json(json!({"data": {}})))
1458 } else {
1459 Err(crate::question_not_found(&rid))
1460 }
1461}
1462
1463pub async fn session_permission_one(
1465 State(st): State<Arc<AppState>>,
1466 Path((_sid, rid)): Path<(String, String)>,
1467) -> Result<Json<Value>, ApiError> {
1468 if !rid.starts_with("per") {
1469 return Err(ApiError {
1470 status: StatusCode::BAD_REQUEST,
1471 name: "InvalidRequestError",
1472 message: format!("Expected a string starting with \"per\", got \"{rid}\""),
1473 });
1474 }
1475 let found = st
1476 .gate
1477 .list()
1478 .into_iter()
1479 .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(rid.as_str()));
1480 match found {
1481 Some(p) => Ok(Json(json!({"data": p}))),
1482 None => Err(ApiError::not_found(format!(
1483 "Permission request not found: {rid}"
1484 ))),
1485 }
1486}
1487
1488pub async fn permission_saved_delete(
1490 State(_): State<Arc<AppState>>,
1491 Path(_id): Path<String>,
1492) -> StatusCode {
1493 StatusCode::NO_CONTENT
1494}
1495
1496pub async fn pty_list(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1502 Json(json!({"location": api_location(&st), "data": st.pty.list_running()}))
1503}
1504
1505pub async fn pty_create(
1507 State(st): State<Arc<AppState>>,
1508 body: Option<Json<Value>>,
1509) -> impl IntoResponse {
1510 let input = body.map(|b| b.0).unwrap_or_else(|| json!({}));
1511 match st.pty.create(&input) {
1512 Ok(info) => Json(json!({"location": api_location(&st), "data": info})).into_response(),
1513 Err(e) => (
1514 StatusCode::INTERNAL_SERVER_ERROR,
1515 Json(json!({"name": "UnknownError", "data": {"message": e}})),
1516 )
1517 .into_response(),
1518 }
1519}
1520
1521pub async fn pty_get(State(st): State<Arc<AppState>>, Path(id): Path<String>) -> impl IntoResponse {
1523 match st.pty.get_running(&id).ok() {
1524 Some(info) => Json(json!({"location": api_location(&st), "data": info})).into_response(),
1525 None => (StatusCode::NOT_FOUND, Json(json!({"name": "NotFoundError", "data": {"message": format!("pty not found: {id}")}}))).into_response(),
1526 }
1527}
1528
1529pub async fn pty_update(
1531 State(st): State<Arc<AppState>>,
1532 Path(id): Path<String>,
1533 body: Option<Json<Value>>,
1534) -> impl IntoResponse {
1535 let input = body.map(|b| b.0).unwrap_or_else(|| json!({}));
1536 match st.pty.update(&id, &input) {
1537 Ok(info) => Json(json!({"location": api_location(&st), "data": info})).into_response(),
1538 Err(e) => (
1539 StatusCode::NOT_FOUND,
1540 Json(json!({"name": "NotFoundError", "data": {"message": attach_err_msg(&e, &id)}})),
1541 )
1542 .into_response(),
1543 }
1544}
1545
1546fn attach_err_msg(e: &ocserve_pty::AttachError, id: &str) -> String {
1547 match e {
1548 ocserve_pty::AttachError::NotFound => format!("pty not found: {id}"),
1549 ocserve_pty::AttachError::Exited => format!("pty exited: {id}"),
1550 }
1551}
1552
1553pub async fn pty_remove(
1555 State(st): State<Arc<AppState>>,
1556 Path(id): Path<String>,
1557) -> impl IntoResponse {
1558 match st.pty.remove(&id) {
1559 Ok(_) => Json(json!({"location": api_location(&st), "data": true})).into_response(),
1560 Err(e) => (
1561 StatusCode::NOT_FOUND,
1562 Json(json!({"name": "NotFoundError", "data": {"message": attach_err_msg(&e, &id)}})),
1563 )
1564 .into_response(),
1565 }
1566}
1567
1568pub async fn pty_connect_token(
1570 State(st): State<Arc<AppState>>,
1571 Path(id): Path<String>,
1572) -> impl IntoResponse {
1573 let t = st.pty.issue_ticket(&id);
1576 Json(json!({"location": api_location(&st), "data": t})).into_response()
1577}
1578
1579pub async fn console_switch(Json(body): Json<Value>) -> impl IntoResponse {
1585 if body.get("accountID").and_then(|v| v.as_str()).is_none() {
1586 return (
1587 StatusCode::BAD_REQUEST,
1588 Json(json!({"name": "BadRequest", "data": {"message": "Missing key\n at [\"accountID\"]", "kind": "Payload"}})),
1589 )
1590 .into_response();
1591 }
1592 Json(json!({})).into_response()
1593}
1594
1595pub async fn workspace_sync_list() -> StatusCode {
1597 StatusCode::NO_CONTENT
1598}
1599
1600pub async fn workspace_warp(Json(body): Json<Value>) -> impl IntoResponse {
1602 if body.get("id").and_then(|v| v.as_str()).is_none() {
1603 return (
1604 StatusCode::BAD_REQUEST,
1605 Json(json!({"name": "BadRequest", "data": {"message": "Missing key\n at [\"id\"]", "kind": "Payload"}})),
1606 )
1607 .into_response();
1608 }
1609 Json(json!({})).into_response()
1610}
1611
1612pub async fn worktree_create(Json(_body): Json<Value>) -> impl IntoResponse {
1614 (
1615 StatusCode::BAD_REQUEST,
1616 Json(
1617 json!({"name": "WorktreeNotGitError", "data": {"message": "Worktrees are only supported for git projects"}}),
1618 ),
1619 )
1620}
1621
1622pub async fn worktree_reset(Json(body): Json<Value>) -> impl IntoResponse {
1624 if body.get("directory").and_then(|v| v.as_str()).is_none() {
1625 return (
1626 StatusCode::BAD_REQUEST,
1627 Json(json!({"name": "BadRequest", "data": {"message": "Missing key\n at [\"directory\"]", "kind": "Payload"}})),
1628 )
1629 .into_response();
1630 }
1631 Json(json!({})).into_response()
1632}
1633
1634pub async fn control_move_session(Json(body): Json<Value>) -> impl IntoResponse {
1636 if body.get("sessionID").and_then(|v| v.as_str()).is_none() {
1637 return (
1638 StatusCode::BAD_REQUEST,
1639 Json(json!({"name": "BadRequest", "data": {"message": "Missing key\n at [\"sessionID\"]", "kind": "Payload"}})),
1640 )
1641 .into_response();
1642 }
1643 Json(json!({})).into_response()
1644}
1645
1646pub async fn project_copy_create(
1648 Path(_id): Path<String>,
1649 Json(body): Json<Value>,
1650) -> impl IntoResponse {
1651 if body.get("strategy").is_none() {
1652 return (
1653 StatusCode::BAD_REQUEST,
1654 Json(json!({"_tag": "InvalidRequestError", "message": "Missing key\n at [\"strategy\"]", "kind": "Payload"})),
1655 )
1656 .into_response();
1657 }
1658 Json(json!({})).into_response()
1659}
1660
1661pub async fn project_copy_generate_name(Path(_id): Path<String>) -> impl IntoResponse {
1663 const A: [&str; 8] = [
1665 "glowing", "silent", "rapid", "amber", "quiet", "brave", "lunar", "vivid",
1666 ];
1667 const B: [&str; 8] = [
1668 "pixel", "river", "comet", "forest", "harbor", "ember", "meadow", "falcon",
1669 ];
1670 let n = std::time::SystemTime::now()
1671 .duration_since(std::time::UNIX_EPOCH)
1672 .map(|d| d.subsec_nanos() as usize)
1673 .unwrap_or(0);
1674 Json(json!({"name": format!("{}-{}", A[n % A.len()], B[(n / A.len()) % B.len()])}))
1675}
1676
1677pub async fn project_copy_refresh(Path(_id): Path<String>) -> StatusCode {
1679 StatusCode::NO_CONTENT
1680}
1681
1682pub async fn project_copy_remove(Path(_id): Path<String>) -> impl IntoResponse {
1684 (
1685 StatusCode::BAD_REQUEST,
1686 Json(
1687 json!({"_tag": "InvalidRequestError", "message": "Expected object, got undefined", "kind": "Payload"}),
1688 ),
1689 )
1690}
1691
1692pub async fn session_background(Path(_id): Path<String>) -> impl IntoResponse {
1694 Json(json!(false))
1695}
1696
1697pub async fn workspace_delete(Path(_id): Path<String>) -> StatusCode {
1699 StatusCode::NO_CONTENT
1700}
1701
1702pub async fn session_share(
1710 State(st): State<Arc<AppState>>,
1711 Path(id): Path<String>,
1712) -> Result<Json<Value>, ApiError> {
1713 let sid = id.clone();
1714 run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
1715 .await
1716 .map_err(internal)?
1717 .map(Json)
1718 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
1719}
1720
1721pub async fn session_unshare(
1723 State(st): State<Arc<AppState>>,
1724 Path(id): Path<String>,
1725) -> Result<Json<Value>, ApiError> {
1726 let sid = id.clone();
1727 run_blocking(&st.db, move |db| ocserve_store::load_session_wire(db, &sid))
1728 .await
1729 .map_err(internal)?
1730 .map(Json)
1731 .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
1732}
1733
1734pub async fn session_event(State(st): State<Arc<AppState>>) -> axum::response::Response {
1739 crate::global_event(State(st)).await
1740}
1741
1742pub async fn permission_create(
1747 State(st): State<Arc<AppState>>,
1748 Path(id): Path<String>,
1749 Json(body): Json<Value>,
1750) -> Result<Json<Value>, HttpError> {
1751 let action = body.get("action").and_then(|v| v.as_str());
1752 let resources = body.get("resources").and_then(|v| v.as_array());
1753 let (Some(action), Some(resources)) = (action, resources) else {
1754 let key = if action.is_none() {
1755 "action"
1756 } else {
1757 "resources"
1758 };
1759 return Err(payload_err(key));
1760 };
1761 if let Some(sv) = body.get("save").filter(|v| !v.is_null() && !v.is_array()) {
1762 return Err(HttpError::TaggedData {
1764 status: StatusCode::BAD_REQUEST,
1765 tag: "InvalidRequestError",
1766 fields: json!({
1767 "message": format!("Expected array, got {sv}\n at [\"save\"]"),
1768 "kind": "Payload",
1769 }),
1770 });
1771 }
1772 let agent = body
1780 .get("agent")
1781 .and_then(|v| v.as_str())
1782 .filter(|s| !s.is_empty())
1783 .map(String::from)
1784 .unwrap_or_default();
1785 let agent = if agent.is_empty() {
1786 session_agent_async(&st, &id).await
1787 } else {
1788 agent
1789 };
1790 let rules = crate::agent_rules(&st, &agent);
1791 let mut effect = "allow";
1792 for r in resources {
1793 let Some(res) = r.as_str() else { continue };
1794 match ocserve_tools::evaluate(action, res, &rules).as_str() {
1795 "deny" => {
1796 effect = "deny";
1797 break;
1798 }
1799 "ask" if effect != "deny" => effect = "ask",
1800 _ => {}
1801 }
1802 }
1803 let pid = format!("per_{}", ocserve_core::ids::ascending_tail());
1804 if effect == "ask" {
1808 let request = json!({
1809 "id": pid,
1810 "sessionID": id,
1811 "action": action,
1812 "resources": resources,
1813 "patterns": resources,
1814 "always": resources,
1815 "metadata": body.get("metadata").cloned().unwrap_or(json!({})),
1816 });
1817 st.gate.register_persistent(&pid, request);
1818 }
1819 Ok(Json(json!({"data": {"id": pid, "effect": effect}})))
1820}
1821
1822fn session_agent(st: &Arc<AppState>, session_id: &str) -> String {
1824 if let Ok(conn) = ocserve_store::pragma::open_reader(&st.db)
1825 && let Ok(Some(raw)) = conn.query_row(
1826 "SELECT agent FROM session WHERE id = ?1",
1827 [session_id],
1828 |r| r.get::<_, Option<String>>(0),
1829 )
1830 && !raw.is_empty()
1831 {
1832 return raw;
1833 }
1834 st.llm.read().default_agent.clone()
1835}
1836
1837async fn session_agent_async(st: &Arc<AppState>, session_id: &str) -> String {
1841 let st2 = st.clone();
1842 let sid = session_id.to_string();
1843 tokio::task::spawn_blocking(move || session_agent(&st2, &sid))
1844 .await
1845 .unwrap_or_else(|_| st.llm.read().default_agent.clone())
1846}
1847
1848pub async fn worktree_remove() -> impl IntoResponse {
1850 (
1851 StatusCode::BAD_REQUEST,
1852 Json(
1853 json!({"name": "BadRequest", "data": {"message": "Expected WorktreeRemoveInput, got undefined", "kind": "Payload"}}),
1854 ),
1855 )
1856}
1857
1858pub async fn patch_project(Path(id): Path<String>) -> impl IntoResponse {
1861 let now_ms = std::time::SystemTime::now()
1862 .duration_since(std::time::UNIX_EPOCH)
1863 .map(|d| d.as_millis() as i64)
1864 .unwrap_or(0);
1865 Json(json!({
1866 "id": id,
1867 "worktree": "/",
1868 "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
1869 "sandboxes": [],
1870 }))
1871}
1872
1873pub async fn sync_start() -> impl IntoResponse {
1875 Json(json!(true))
1876}
1877
1878pub async fn git_init(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1882 let now_ms = std::time::SystemTime::now()
1883 .duration_since(std::time::UNIX_EPOCH)
1884 .map(|d| d.as_millis() as i64)
1885 .unwrap_or(0);
1886 let worktree = st.paths["directory"].as_str().unwrap_or("/").to_string();
1887 Json(json!({
1888 "id": "global",
1889 "worktree": worktree,
1890 "vcs": "git",
1891 "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
1892 "sandboxes": [],
1893 }))
1894}
1895
1896pub async fn credential_update(Path(_id): Path<String>) -> StatusCode {
1899 StatusCode::NO_CONTENT
1900}
1901
1902pub async fn credential_remove(Path(_id): Path<String>) -> StatusCode {
1904 StatusCode::NO_CONTENT
1905}
1906
1907pub async fn integration_connect_key(Path(_id): Path<String>) -> StatusCode {
1910 StatusCode::NO_CONTENT
1911}
1912
1913pub async fn integration_connect_oauth(Path(_id): Path<String>) -> impl IntoResponse {
1916 (
1917 StatusCode::UNAUTHORIZED,
1918 Json(json!({"_tag": "UnauthorizedError", "message": "No OAuth credentials available"})),
1919 )
1920}
1921
1922pub async fn integration_attempt_get(Path(id): Path<String>) -> impl IntoResponse {
1924 (
1925 StatusCode::NOT_FOUND,
1926 Json(json!({"_tag": "InvalidRequestError", "message": format!("Attempt not found: {id}")})),
1927 )
1928}
1929
1930pub async fn integration_attempt_delete(Path(id): Path<String>) -> impl IntoResponse {
1932 (
1933 StatusCode::NOT_FOUND,
1934 Json(json!({"_tag": "InvalidRequestError", "message": format!("Attempt not found: {id}")})),
1935 )
1936}
1937
1938pub async fn integration_attempt_complete(Path(id): Path<String>) -> impl IntoResponse {
1940 (
1941 StatusCode::NOT_FOUND,
1942 Json(json!({"_tag": "InvalidRequestError", "message": format!("Attempt not found: {id}")})),
1943 )
1944}
1945
1946pub async fn global_upgrade(Json(body): Json<Value>) -> impl IntoResponse {
1949 if body.get("target").and_then(|v| v.as_str()).is_none() {
1950 return (
1951 StatusCode::BAD_REQUEST,
1952 Json(json!({"name": "BadRequest", "data": {"message": "Missing key\n at [\"target\"]", "kind": "Payload"}})),
1953 )
1954 .into_response();
1955 }
1956 (
1957 StatusCode::BAD_REQUEST,
1958 Json(json!({"name": "BadRequest", "data": {"message": "Upgrade is not supported", "kind": "Payload"}})),
1959 )
1960 .into_response()
1961}
1962
1963pub async fn sync_replay(Json(_body): Json<Value>) -> impl IntoResponse {
1965 Json(json!(true))
1966}
1967
1968pub async fn sync_history(Json(_body): Json<Value>) -> impl IntoResponse {
1970 Json(json!({"data": []}))
1971}
1972
1973pub async fn sync_steal(Json(_body): Json<Value>) -> impl IntoResponse {
1975 Json(json!(true))
1976}
1977
1978pub async fn list_skills_raw(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1980 let payloads = st.payloads.read();
1981 let data: Vec<Value> = payloads
1982 .command
1983 .iter()
1984 .filter(|c| c.get("source").and_then(|v| v.as_str()) == Some("skill"))
1985 .map(|c| {
1986 json!({
1987 "name": c.get("name").cloned().unwrap_or(Value::Null),
1988 "description": c.get("description").cloned().unwrap_or(json!("")),
1989 "location": "",
1990 "content": c.get("template").cloned().unwrap_or(json!("")),
1991 })
1992 })
1993 .collect();
1994 Json(data)
1995}
1996
1997pub async fn vcs_apply(
2002 State(st): State<Arc<AppState>>,
2003 Query(q): Query<std::collections::HashMap<String, String>>,
2004 Json(body): Json<Value>,
2005) -> impl IntoResponse {
2006 let Some(patch) = body.get("patch").and_then(|v| v.as_str()) else {
2007 return (
2008 StatusCode::BAD_REQUEST,
2009 Json(json!({"name": "BadRequest", "data": {"message": "Missing key\n at [\"patch\"]", "kind": "Payload"}})),
2010 )
2011 .into_response();
2012 };
2013 let dir = q
2016 .get("directory")
2017 .cloned()
2018 .unwrap_or_else(|| st.paths["directory"].as_str().unwrap_or("/").to_string());
2019 let patch = patch.to_string();
2020 let applied = crate::run_blocking(&st.db, move |_db| {
2021 Ok::<_, anyhow::Error>(crate::vcs::apply(&dir, &patch))
2022 })
2023 .await
2024 .unwrap_or(None);
2025 match applied {
2026 Some(true) => (StatusCode::OK, Json(json!({"applied": true}))).into_response(),
2027 Some(false) => (
2028 StatusCode::BAD_REQUEST,
2029 Json(json!({"name": "VcsApplyError", "data": {"message": "Patch can't be applied", "reason": "not-clean"}})),
2030 )
2031 .into_response(),
2032 None => (
2033 StatusCode::BAD_REQUEST,
2034 Json(json!({"name": "VcsApplyError", "data": {"message": "Patch can't be applied because the project is not git-based", "reason": "non-git"}})),
2035 )
2036 .into_response(),
2037 }
2038}
2039
2040pub async fn workspace_create(Json(body): Json<Value>) -> impl IntoResponse {
2044 let Some(wtype) = body.get("type").and_then(|v| v.as_str()) else {
2045 return (
2046 StatusCode::BAD_REQUEST,
2047 Json(json!({"name": "BadRequest", "data": {"message": "Missing key\n at [\"type\"]", "kind": "Payload"}})),
2048 )
2049 .into_response();
2050 };
2051 let known = matches!(wtype, "worktree" | "local" | "directory");
2052 if !known {
2053 return (
2054 StatusCode::BAD_REQUEST,
2055 Json(json!({"name": "WorkspaceCreateError", "data": {"message": format!("Unknown workspace adapter: {wtype}")}})),
2056 )
2057 .into_response();
2058 }
2059 Json(json!({"id": format!("wrk_{}", ocserve_core::ids::ascending_tail()), "type": wtype}))
2060 .into_response()
2061}
2062
2063pub async fn create_session(
2065 State(st): State<Arc<AppState>>,
2066 Json(body): Json<Value>,
2067) -> Result<Json<Value>, ApiError> {
2068 let resolved = crate::resolve_create_dir_path(&st, &body, None).await;
2069 let info = crate::create_session_record(&st, &body, resolved)?;
2070 Ok(Json(json!({"data": info})))
2071}