Skip to main content

ocserve_http/
v2.rs

1//! v2 `/api/*` surface (opencode 1.18.31 contract).
2//!
3//! The 1.18.31 TUI and web UI are hybrid clients: their `SyncProvider` boots
4//! on v1 paths (served elsewhere in this crate) while `DataProvider` and the
5//! session view call the v2 SDK (`client.v2.*`), which resolves to `/api/*`.
6//! Before this module every `/api/*` call fell through the web-UI catch-all
7//! to SPA **HTML**, and JSON clients that parse it degrade or crash (e.g. the
8//! `session.diff` → `SidebarFiles.flatMap` TypeError on an HTML string,
9//! 2026-10-08).
10//!
11//! Shapes are taken from the vendored OpenAPI contract
12//! (`assets/openapi/1.18.31.json`, served at `/doc`) and probed live from the
13//! freeze. Envelope rules captured live:
14//!   - `{location, data}` for location-scoped collections (agent/model/…),
15//!   - `{data: …}` for single resources,
16//!   - `{data, cursor:{previous,next}}` for paginated message lists.
17//!
18//! The v2 session prompt route does NOT duplicate the prompt loop — it
19//! normalizes the v2 body and calls the same `ocserve_core::prompt::run_prompt`
20//! executor the v1 routes use (single owner of the state machine).
21
22use 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
30/// GET /api/health — `{healthy:true}` (schema: enum [true]).
31pub async fn health() -> impl IntoResponse {
32    Json(json!({"healthy": true}))
33}
34
35/// GET /api/location — bare LocationInfo (not enveloped).
36pub async fn location(State(st): State<Arc<AppState>>) -> impl IntoResponse {
37    Json(api_location(&st))
38}
39
40// ---------------------------------------------------------------------------
41// providers / models
42// ---------------------------------------------------------------------------
43
44/// Provider ids considered "available" on the v2 wire, mirroring upstream's
45/// `CatalogV2.provider.available`:
46///   available = !disabled && (apiKey is a string || integration connected ||
47///                              no integration at all)
48/// Translated to ocserve's data: a provider is available when it is the
49/// built-in `opencode` (keyless `public`), has an explicit non-empty
50/// `options.apiKey` in config, or is a custom provider absent from the models
51/// catalog (hence with no integration record). This reproduces the observed
52/// freeze set `{opencode, entrim, nube}` from this box's config.
53fn 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            // not in the models catalog => no integration record => available
81            cat_obj.map(|c| !c.contains_key(pid)).unwrap_or(true)
82        })
83        .collect()
84}
85
86/// Project a v1 model entry to the v2 `ModelV2Info` shape (freeze-probed).
87fn 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    // v2 `ModelApi` is a discriminated union (spec 1.18.31):
92    //   {id, type:"aisdk", package, url[, settings]}  — npm-backed
93    //   {id, type:"native", url, settings}            — built-in
94    // The v1 entry carries `npm` when npm-backed; presence picks the arm.
95    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    // capabilities boolean map → v2 {tools, input[], output[]}
107    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    // cost (single-element array form in v2)
132    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    // variants: v2 `ModelV2Info.variants` is an array of
144    // `{id, headers, body}` objects (spec 1.18.31). The v1 entry keys
145    // variants by name; each becomes one object carrying its own body when
146    // the v1 variant has one.
147    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    // "YYYY-MM-DD" → epoch ms at UTC midnight (upstream `time.released`).
189    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    // days since epoch (civil-from-days, Howard Hinnant).
197    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
206/// GET /api/provider — `{location, data:[ProviderV2Info]}` for available providers.
207pub 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
223/// Pure projections of the v2 metadata group over the payloads — the ONE
224/// source of truth for both the request path and `rebuild_wire` (the wire
225/// bytes are these values serialized with serde_json). Byte-parity pinned by
226/// `tests/v2_body_parity.rs` (including a planted-drift negative control).
227pub 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
247/// Pure model-list projection (the `/api/model` body).
248pub 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
285/// Pure integration-list projection (the `/api/integration` body).
286pub 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    // catalog-less custom providers (e.g. entrim/nube here) are integrations too
301    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    // request.body: carry apiKey only when it is the keyless public tier or a
341    // config-supplied key (never a secret read from auth.json — v2 freeze
342    // does not expose those either).
343    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
359/// GET /api/provider/{providerID} — `{location, data:ProviderV2Info}`.
360pub 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
386/// GET /api/model — `{location, data:[ModelV2Info]}`, available providers only,
387/// non-deprecated models, sorted by release descending (upstream order).
388pub 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
404// ---------------------------------------------------------------------------
405// skills / integrations / references
406// ---------------------------------------------------------------------------
407
408/// GET /api/skill — `{location, data:[SkillV2Info]}`. ocserve exposes the one
409/// built-in skill it ships (the `customize-opencode` command, source "skill").
410pub 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
429/// Integration methods for one catalog provider (key + env), freeze-probed.
430fn 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
438/// GET /api/integration — `{location, data:[IntegrationInfo]}`.
439pub 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
455/// GET /api/integration/{integrationID} — single integration.
456pub 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    // freeze returns `{location, data:null}` for an unknown id (probed), not a
463    // 404 — the web UI reads `data` and treats null as "not configured".
464    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
477/// GET /api/reference — `{location, data:[]}` (no references configured).
478pub async fn list_references(State(st): State<Arc<AppState>>) -> impl IntoResponse {
479    Json(json!({"location": api_location(&st), "data": []}))
480}
481
482// ---------------------------------------------------------------------------
483// filesystem
484// ---------------------------------------------------------------------------
485
486/// GET /api/fs/find?query=&type=&limit= — `{location, data:[FileSystemEntry]}`.
487pub 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    // `query` is REQUIRED (freeze: missing → 400 `_tag:InvalidRequestError`
493    // kind Query); empty accepted.
494    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
548/// GET /api/fs/list?path= — direct children of `path` (default ".").
549pub 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    // Containment (the 2026-10-11 traversal fix): resolve + canonicalize and
556    // reject a path that escapes the project root. `git rev-parse`-style
557    // lexical `..` never listed /etc on freeze (it 500s); ocserve listed it.
558    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
596/// Resolve `rel` under `base` and refuse to escape it. Canonicalizes the base
597/// (the project root exists) and the joined target; a target that does not
598/// canonicalize (missing) is still allowed only if its lexical form stays
599/// inside `base` (so `fs/list?path=missing` reports "No such file" rather than
600/// silently passing). `..` that resolves outside `base` is rejected.
601fn 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        // not found: fall back to a lexical containment check on the joined path
616        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
630/// Lexically resolve `.`/`..` without touching the filesystem (used only for
631/// the not-found branch; a normalized join stays comparable to a canonical base).
632fn 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
647/// GET /api/fs/read/*path — raw bytes of one file (Uint8Array on the wire).
648pub 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
678// ---------------------------------------------------------------------------
679// session diff — the proven TUI sidebar crash path
680// ---------------------------------------------------------------------------
681
682/// GET /session/{id}/diff — with `?messageID=`, the message's stored
683/// `summary.diffs` (upstream `SessionSummary.diff`); without it, the session
684/// convenience path returns `[]`. Crucially this is **JSON**, not SPA HTML: the
685/// TUI's `SidebarFiles` flatMaps the result and an HTML string crashes it.
686pub 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
711/// v2 tagged SessionNotFoundError (`{_tag, sessionID, message}`) — freeze
712/// returns this for `/api/session/{id}/...` on an unknown session; ocserve
713/// previously 204'd (blind UPDATE) or used the v1 `{name,data}` envelope.
714fn 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
722// ---------------------------------------------------------------------------
723// session core
724// ---------------------------------------------------------------------------
725
726/// Project a stored v1 session row to `SessionV2Info` (freeze-probed).
727fn 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    // subpath: path relative to the first path segment (freeze strips the
750    // leading "/", e.g. directory "/srv/project" -> "srv/project").
751    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    // revert: SessionV2Info carries RevertState (messageID-bearing object).
757    if let Some(r) = s.get("revert")
758        && !r.is_null()
759    {
760        out["revert"] = r.clone();
761    }
762    out
763}
764
765/// GET /api/session — `{data:[SessionV2Info], cursor:{previous,next}}`.
766pub 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    // Query decode precedes the handler (freeze): invalid limit/order/cursor
771    // → 400 with the v2 tagged envelope
772    // `{"_tag":"InvalidRequestError","message":…,"kind":"Query"}`.
773    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    // freeze always emits both cursor keys (opaque base64url cursors); ocserve
836    // does not paginate v2 session list, so both are null (shape-faithful).
837    let _ = more;
838    Ok(Json(
839        json!({"data": window, "cursor": {"previous": Value::Null, "next": Value::Null}}),
840    ))
841}
842
843/// GET /api/session/active — `{data:{}}` (no per-request foreground drains
844/// are tracked on the v2 wire; ocserve's own prompt locks are v1 status).
845pub async fn active_sessions() -> impl IntoResponse {
846    Json(json!({"data": {}}))
847}
848
849/// GET /api/session/{sessionID} — `{data:SessionV2Info}`.
850pub 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        // freeze v2 uses the tagged SessionNotFoundError (probe), not the v1
861        // {name,data} envelope.
862        None => Err(session_not_found(&id)),
863    }
864}
865
866/// GET /api/session/{sessionID}/message — paginated projected messages.
867pub 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    // freeze always emits BOTH cursor keys (previous/next), null when absent.
898    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
905/// Project a stored v1 message `info` (JSON text) to a v2 `SessionMessage`.
906/// The v2 message model is a projection of the same durable stream; we map
907/// the role/type to the closest v2 variant and always return JSON.
908fn 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
947/// GET /api/session/{sessionID}/message/{messageID} — single message.
948pub 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
972/// GET /api/session/{sessionID}/context — `{data:[SessionMessage.Message]}`.
973/// Projection over the stored message stream (thinner than upstream's full
974/// event-sourcing; documented divergence).
975pub 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
982/// GET /api/session/{sessionID}/history — `{data:[DurableEvent], hasMore}`.
983/// ocserve serves its own event log; the wire shape matches, semantics are
984/// a subset (documented).
985pub 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
998// ---------------------------------------------------------------------------
999// permissions / questions
1000// ---------------------------------------------------------------------------
1001
1002/// GET /api/permission/saved — `{data:[PermissionSavedInfo]}`.
1003pub async fn permission_saved(State(st): State<Arc<AppState>>) -> impl IntoResponse {
1004    // ocserve stores per-session always-grants as string keys, not the v2
1005    // PermissionSavedInfo rows; there is no project-level saved set => [].
1006    let _ = &st;
1007    Json(json!({"data": []}))
1008}
1009
1010/// GET /api/permission/request — `{location, data:[...]}` pending asks.
1011pub 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
1016/// GET /api/question/request — `{location, data:[...]}` pending questions.
1017pub 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
1022/// GET /api/session/{sessionID}/permission — `{data:[]}` (per-session asks).
1023pub 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
1030/// GET /api/session/{sessionID}/question — `{data:[]}` (per-session questions).
1031pub 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
1038// ---------------------------------------------------------------------------
1039// writes: session lifecycle (shared executor for prompt)
1040// ---------------------------------------------------------------------------
1041
1042/// POST /api/session/{sessionID}/agent — switch agent (NoContent).
1043pub 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
1053/// POST /api/session/{sessionID}/model — switch model (NoContent).
1054pub 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    // freeze 404s an unknown session (SessionNotFoundError); the old code ran
1087    // the UPDATE blind and 204'd regardless of rows affected.
1088    // Perf dive 2026-10-11: blocking store calls on this async handler's
1089    // worker — move both the existence check and the write to the blocking
1090    // pool (F1 class).
1091    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
1122// ---------------------------------------------------------------------------
1123// experimental stubs (freeze-probed shapes)
1124// ---------------------------------------------------------------------------
1125
1126/// GET /experimental/console/orgs — `{orgs:[]}`.
1127pub async fn console_orgs() -> impl IntoResponse {
1128    Json(json!({"orgs": []}))
1129}
1130
1131/// GET /experimental/workspace — `[]`.
1132pub async fn workspace_list() -> impl IntoResponse {
1133    Json(json!([]))
1134}
1135
1136/// GET /experimental/workspace/status — `[]`.
1137pub async fn workspace_status() -> impl IntoResponse {
1138    Json(json!([]))
1139}
1140
1141/// GET /experimental/workspace/adapter — `[{type,name,description}]`.
1142pub async fn workspace_adapter() -> impl IntoResponse {
1143    Json(json!([{"type": "worktree", "name": "Worktree", "description": "Create a git worktree"}]))
1144}
1145
1146/// GET /experimental/worktree — `[]`.
1147pub async fn worktree_list() -> impl IntoResponse {
1148    Json(json!([]))
1149}
1150
1151// ---------------------------------------------------------------------------
1152// session writes — share the v1 prompt executor (no loop duplication)
1153// ---------------------------------------------------------------------------
1154
1155/// POST /api/session/{sessionID}/prompt — spec `SessionInput.Admitted`.
1156/// Normalizes the v2 body `{id?, prompt:{text,files,agents}, delivery, resume}`
1157/// into the v1 payload form and runs the SAME `ocserve_core::prompt::run_prompt`
1158/// the v1 `/session/{id}/message` and `/prompt_async` routes use. This route is
1159/// synchronous (the v1 `session.prompt` semantics), so the loop stays in one
1160/// place; only the body shape differs.
1161pub 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    // boundary decode FIRST with the v2 envelope (`_tag:InvalidRequestError`,
1167    // field-probes.md) — session/endpoint checks follow.
1168    let input = crate::decode_v2(ocserve_core::wire::V2PromptInput::decode(&body))?;
1169    let req = ocserve_core::wire::PromptRequest {
1170        message_id: input.id.clone(), // already validated `^msg_`
1171        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            // absent/null delivery defaults to "steer" (probe: fresh session,
1192            // no delivery field → "delivery":"steer"; NOT "queue").
1193            "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
1199/// v2 `PromptInput` → v1 parts array (text + file + agent attachments),
1200/// built from the decoded struct (wire names live in wire.rs).
1201fn normalize_v2_prompt_parts(input: &ocserve_core::wire::V2PromptInput) -> Vec<Value> {
1202    let mut parts = Vec::new();
1203    // text is required by decode — always present (empty string included,
1204    // matching the old `if let Some(text)` behavior for `text:""`).
1205    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
1215/// POST /api/session/{sessionID}/interrupt — NoContent (abort the task).
1216pub 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
1226/// POST /api/session/{sessionID}/compact — NoContent (triggers summarize).
1227pub async fn compact(
1228    State(st): State<Arc<AppState>>,
1229    Path(id): Path<String>,
1230) -> Result<StatusCode, ApiError> {
1231    // Freeze: ServiceUnavailable when no model is available; ocserve maps to
1232    // the same when the session has no resolvable model. Otherwise summarize.
1233    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
1254/// POST /api/session/{sessionID}/wait — freeze returns ServiceUnavailable
1255/// ("not available yet"); mirror honestly (no long-poll engine).
1256pub 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
1265/// POST /api/session/{sessionID}/revert/stage — `{data:RevertState}`. Runs the
1266/// real revert engine (restore files, compute the range diff, set the marker)
1267/// and emits `session.next.revert.staged` so the web UI resyncs.
1268pub 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
1295/// POST /api/session/{sessionID}/revert/clear — restore the snapshot, clear the
1296/// marker, emit `session.next.revert.cleared`; NoContent.
1297pub async fn revert_clear(
1298    State(st): State<Arc<AppState>>,
1299    Path(id): Path<String>,
1300) -> Result<StatusCode, ApiError> {
1301    // restore the marker's snapshot (upstream unrevert)
1302    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
1334/// POST /api/session/{sessionID}/revert/commit — finalize the revert: delete
1335/// the reverted range and clear the marker; emit `session.next.revert.committed`.
1336pub 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        // delete messages from the revert target onward (upstream revert.cleanup)
1354        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
1375/// v2 `/api/*` payload error — probe-pinned envelope (field-probes.md):
1376/// `{"_tag":"InvalidRequestError","message":"Missing key\n  at [\"<key>\"]",
1377/// "kind":"Payload"}` [400]. NOTE: differs from v1 routes (name/data nesting)
1378/// — this is the v2 HttpApi tagged-error shape.
1379fn 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
1394/// POST /api/session/{sessionID}/permission/{requestID}/reply — reply to ask.
1395/// Body decode precedes the 404 (same Payload envelope as v1); the old
1396/// `unwrap_or("reject")` silently rejected a real ask on a malformed body.
1397pub 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
1414/// POST /api/session/{sessionID}/question/{requestID}/reply — reply.
1415pub 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
1450/// POST /api/session/{sessionID}/question/{requestID}/reject — reject.
1451pub 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
1463/// GET /api/session/{sessionID}/permission/{requestID} — one ask.
1464pub 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
1488/// DELETE /api/permission/saved/{id} — NoContent (best-effort grant removal).
1489pub async fn permission_saved_delete(
1490    State(_): State<Arc<AppState>>,
1491    Path(_id): Path<String>,
1492) -> StatusCode {
1493    StatusCode::NO_CONTENT
1494}
1495
1496// ---------------------------------------------------------------------------
1497// pty under /api (same implementation as v1 /pty)
1498// ---------------------------------------------------------------------------
1499
1500/// GET /api/pty — `{location, data:[Pty]}`.
1501pub 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
1505/// POST /api/pty — create (location-wrapped info).
1506pub 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
1521/// GET /api/pty/{ptyID} — get.
1522pub 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
1529/// PUT /api/pty/{ptyID} — update title/size.
1530pub 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
1553/// DELETE /api/pty/{ptyID} — remove (true).
1554pub 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
1568/// POST /api/pty/{ptyID}/connect-token.
1569pub async fn pty_connect_token(
1570    State(st): State<Arc<AppState>>,
1571    Path(id): Path<String>,
1572) -> impl IntoResponse {
1573    // Freeze shape: `{location, data:{ticket,expires_in}}`. ocserve's ticket
1574    // is embedded in the same `issue_ticket` payload; surface the `ticket`.
1575    let t = st.pty.issue_ticket(&id);
1576    Json(json!({"location": api_location(&st), "data": t})).into_response()
1577}
1578
1579// ---------------------------------------------------------------------------
1580// experimental action stubs (freeze-probed: exact error envelopes)
1581// ---------------------------------------------------------------------------
1582
1583/// POST /experimental/console/switch — 400 payload (accountID required).
1584pub 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
1595/// POST /experimental/workspace/sync-list — 204.
1596pub async fn workspace_sync_list() -> StatusCode {
1597    StatusCode::NO_CONTENT
1598}
1599
1600/// POST /experimental/workspace/warp — 400 payload (id required).
1601pub 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
1612/// POST /experimental/worktree — WorktreeNotGitError on non-git.
1613pub 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
1622/// POST /experimental/worktree/reset — 400 payload (directory required).
1623pub 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
1634/// POST /experimental/control-plane/move-session — 400 (sessionID required).
1635pub 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
1646/// POST /experimental/project/{projectID}/copy — 400 (strategy required).
1647pub 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
1661/// POST /experimental/project/{projectID}/copy/generate-name — random name.
1662pub async fn project_copy_generate_name(Path(_id): Path<String>) -> impl IntoResponse {
1663    // Two lowercase words, hyphen-joined (freeze: "glowing-pixel").
1664    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
1677/// POST /experimental/project/{projectID}/copy/refresh — 204.
1678pub async fn project_copy_refresh(Path(_id): Path<String>) -> StatusCode {
1679    StatusCode::NO_CONTENT
1680}
1681
1682/// DELETE /experimental/project/{projectID}/copy — 400 (body required).
1683pub 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
1692/// POST /experimental/session/{sessionID}/background — false.
1693pub async fn session_background(Path(_id): Path<String>) -> impl IntoResponse {
1694    Json(json!(false))
1695}
1696
1697/// DELETE /experimental/workspace/{id} — 204.
1698pub async fn workspace_delete(Path(_id): Path<String>) -> StatusCode {
1699    StatusCode::NO_CONTENT
1700}
1701
1702// ---------------------------------------------------------------------------
1703// v1 residual: share
1704// ---------------------------------------------------------------------------
1705
1706/// POST /session/{id}/share — freeze returns the Session record (share is a
1707/// local-only marker; ocserve has no remote share backend, so the record is
1708/// returned with `share` unset — a named divergence `D-SHARE-NOBACKEND`).
1709pub 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
1721/// DELETE /session/{id}/share — freeze returns the (unshared) Session record.
1722pub 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
1734/// GET /api/session/{sessionID}/event — durable SSE for one session. ocserve
1735/// has no per-session event stream distinct from the global bus; alias the
1736/// global SSE (frames carry sessionID, clients filter). Semantics are a
1737/// subset; wire shape (text/event-stream) matches.
1738pub async fn session_event(State(st): State<Arc<AppState>>) -> axum::response::Response {
1739    crate::global_event(State(st)).await
1740}
1741
1742/// POST /api/session/{sessionID}/permission — create a permission request.
1743/// ocserve's gate is a rendezvous for the RUNNING prompt; a standalone
1744/// create is acknowledged with the evaluated effect (freeze shape
1745/// `{data:{id,effect}}`). Full blocking semantics belong to the prompt path.
1746pub 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        // probe-pinned bytes (save:false → freeze v2 envelope); null = absent
1763        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    // The v2 create IS the permission oracle: it returns the evaluated effect
1773    // (upstream PermissionV2.ask) with NO tool execution. It must evaluate the
1774    // session agent's ruleset — the previous hardcoded `effect:"allow"` was
1775    // semantically empty (found live 2026-10-09: freeze edit /etc/passwd → ask,
1776    // ocserve → allow). Agent precedence: payload `agent` → session stored →
1777    // default. Combined effect: any deny → deny, else any ask → ask, else allow
1778    // (upstream evaluateInput).
1779    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    // `ask` registers a real pending request (upstream `create`); other effects
1805    // do not. The request body mirrors the v1 event shape so GET /permission
1806    // and the reply routes see it.
1807    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
1822/// Session's stored agent, else the runtime default. Read-only, best-effort.
1823fn 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
1837/// Perf dive 2026-10-11: `session_agent` opens a reader (blocking) — the
1838/// async permission path calls this variant so the lookup runs on the
1839/// blocking pool (F1 class).
1840async 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
1848/// DELETE /experimental/worktree — 400 WorktreeRemoveInput required.
1849pub 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
1858/// PATCH /project/{projectID} — echo the project (freeze returns the project
1859/// record; updates to name/icon are not persisted by ocserve).
1860pub 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
1873/// POST /sync/start — `true` (freeze: workspace sync started).
1874pub async fn sync_start() -> impl IntoResponse {
1875    Json(json!(true))
1876}
1877
1878/// POST /project/git/init — Project record (freeze shape). ocserve does not
1879/// initialize a git repo; it echoes the current project with `vcs:"git"`
1880/// when the worktree is a git checkout (probed 2026-10-09).
1881pub 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
1896/// PATCH /api/credential/{credentialID} — NoContent (no credential store;
1897/// ocserve has no OAuth credential backend — named divergence D-CREDENTIAL).
1898pub async fn credential_update(Path(_id): Path<String>) -> StatusCode {
1899    StatusCode::NO_CONTENT
1900}
1901
1902/// DELETE /api/credential/{credentialID} — NoContent.
1903pub async fn credential_remove(Path(_id): Path<String>) -> StatusCode {
1904    StatusCode::NO_CONTENT
1905}
1906
1907/// POST /api/integration/{integrationID}/connect/key — NoContent (no
1908/// credential backend; named divergence D-CREDENTIAL).
1909pub async fn integration_connect_key(Path(_id): Path<String>) -> StatusCode {
1910    StatusCode::NO_CONTENT
1911}
1912
1913/// POST /api/integration/{integrationID}/connect/oauth — 401 (no OAuth
1914/// backend configured; freezes probe returns UnauthorizedError).
1915pub 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
1922/// GET /api/integration/attempt/{attemptID} — 404 (no attempts).
1923pub 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
1930/// DELETE /api/integration/attempt/{attemptID} — 404 (no attempts).
1931pub 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
1938/// POST /api/integration/attempt/{attemptID}/complete — 404 (no attempts).
1939pub 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
1946/// POST /global/upgrade — 400 BadRequest (missing target). ocserve does not
1947/// self-upgrade; cited divergence.
1948pub 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
1963/// POST /sync/replay — `true`.
1964pub async fn sync_replay(Json(_body): Json<Value>) -> impl IntoResponse {
1965    Json(json!(true))
1966}
1967
1968/// POST /sync/history — `{data:[]}`.
1969pub async fn sync_history(Json(_body): Json<Value>) -> impl IntoResponse {
1970    Json(json!({"data": []}))
1971}
1972
1973/// POST /sync/steal — `true`.
1974pub async fn sync_steal(Json(_body): Json<Value>) -> impl IntoResponse {
1975    Json(json!(true))
1976}
1977
1978/// GET /skill — bare array (v1 shape; distinct from /api/skill's envelope).
1979pub 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
1997/// POST /vcs/apply — apply a patch to the session directory's git worktree
1998/// (upstream `project/vcs.ts:399`): `git apply` → `{applied:true}`; not a git
1999/// worktree → `reason:"non-git"`; patch rejected → `reason:"not-clean"`.
2000/// missing `patch` → 400 payload. The route DOES mutate the worktree.
2001pub 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    // upstream honors `?directory=` (WorkspaceRoutingQuery); default the
2014    // server's worktree.
2015    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
2040/// POST /experimental/workspace — create a workspace. Freeze requires `type`
2041/// and rejects unknown adapters. ocserve registers the built-in adapters and
2042/// mirrors the error envelope for others.
2043pub 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
2063/// POST /api/session — create a session (v2). Shares the v1 creation path.
2064pub 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}