Skip to main content

ocserve_http/
lib.rs

1//! ocserve-http: axum router, auth, byte-golden SSE, upstream error envelope.
2
3use axum::extract::State;
4use axum::http::{HeaderValue, StatusCode};
5use axum::response::sse::{Event, Sse};
6use axum::response::{IntoResponse, Response};
7use axum::routing::get;
8use axum::{Json, Router};
9use futures_util::StreamExt;
10use futures_util::stream;
11use serde_json::{Value, json};
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::Duration;
15
16/// Freeze (PLAN §3): version reported by `/global/health`.
17pub const FREEZE_VERSION: &str = "1.18.31";
18
19/// Pre-assembled payloads for config-derived routes (built by ocserve's
20/// `Runtime::load()`; kept crate-local so http doesn't depend on cli).
21/// JSON deep merge: objects merge recursively, everything else (arrays,
22/// scalars) replaces. Used by overlay layering + PATCH /config (W4).
23pub fn deep_merge(base: &mut serde_json::Value, patch: serde_json::Value) {
24    match (base, patch) {
25        (serde_json::Value::Object(b), serde_json::Value::Object(p)) => {
26            for (k, v) in p {
27                match b.get_mut(&k) {
28                    Some(bv) => deep_merge(bv, v),
29                    None => {
30                        b.insert(k, v);
31                    }
32                }
33            }
34        }
35        (b, p) => *b = p,
36    }
37}
38
39/// W4 config-write target: default = the SHARED opencode.json (drop-in
40/// contract — both servers read it); `OCSERVE_CONFIG_WRITE=overlay` switches
41/// to a ocserve-owned file that layers over the base at load (never touches
42/// the other server's config).
43pub fn config_write_path() -> std::path::PathBuf {
44    let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
45    if std::env::var("OCSERVE_CONFIG_WRITE")
46        .map(|v| v == "overlay")
47        .unwrap_or(false)
48    {
49        std::path::PathBuf::from(home).join(".config/ocserve/config.json")
50    } else {
51        std::path::PathBuf::from(home).join(".config/opencode/opencode.json")
52    }
53}
54
55/// Boot-injected config reloader (W4): Runtime::load lives in ocserve —
56/// the closure avoids a crate cycle; None in tests. Returns derived payloads
57/// AND a rebuilt LLM registry (H2 — auth/provider/default-model edits apply
58/// without restart). Callers go through `watch::reconcile`, never the raw fn.
59pub type ConfigReloader =
60    std::sync::Arc<dyn Fn() -> anyhow::Result<(Payloads, LlmRegistry)> + Send + Sync>;
61
62#[derive(Default)]
63pub struct Payloads {
64    pub config: serde_json::Value,
65    pub agent: Vec<serde_json::Value>,
66    pub api_agent: Vec<serde_json::Value>,
67    pub command: Vec<serde_json::Value>,
68    pub config_providers: serde_json::Value,
69    pub provider: serde_json::Value,
70    pub console: serde_json::Value,
71    pub capabilities: serde_json::Value,
72    /// shared-config `compaction` section (M6 trigger/knobs).
73    pub compaction: serde_json::Value,
74}
75
76/// PERF-10X F1: run a blocking store call on the runtime's blocking pool.
77/// Store fns are sync by design (STORAGE: never hold a connection across
78/// `.await`) — calling them inline parks a tokio worker for the query's
79/// duration. Baseline measured exactly that convoy: one 6s search held a
80/// worker and every other route queued behind it (config p95 998ms for a
81/// payload read; starvation signature: tail explodes while CPU ≪100%).
82async fn run_blocking<T, F>(db: &std::path::Path, op: F) -> anyhow::Result<T>
83where
84    F: FnOnce(&std::path::Path) -> anyhow::Result<T> + Send + 'static,
85    T: Send + 'static,
86{
87    let db = db.to_path_buf();
88    tokio::task::spawn_blocking(move || op(&db))
89        .await
90        .map_err(|e| anyhow::anyhow!("blocking task join: {e}"))?
91}
92
93pub mod tui;
94pub mod ui;
95
96/// Upstream error envelope: {"name":"NotFoundError","data":{"message":"..."}} (captured live).
97pub struct ApiError {
98    pub status: StatusCode,
99    pub name: &'static str,
100    pub message: String,
101}
102
103impl ApiError {
104    pub fn not_found(msg: impl Into<String>) -> Self {
105        Self {
106            status: StatusCode::NOT_FOUND,
107            name: "NotFoundError",
108            message: msg.into(),
109        }
110    }
111}
112
113impl IntoResponse for ApiError {
114    fn into_response(self) -> Response {
115        let body = json!({"name": self.name, "data": {"message": self.message}});
116        (self.status, Json(body)).into_response()
117    }
118}
119
120/// Handler error: normal envelopes + Effect HttpApi tagged decode errors
121/// (freeze parity: `?before=` → body exactly {"_tag":"BadRequest"} §1090).
122pub mod sync;
123pub mod watch;
124
125pub enum HttpError {
126    Api(ApiError),
127    Tagged {
128        status: StatusCode,
129        tag: &'static str,
130    },
131    /// Effect query-decode failure envelope (freeze probes: `?limit=abc` →
132    /// {"name":"BadRequest","data":{"message":...,"kind":"Query"}}).
133    Query {
134        message: String,
135    },
136    /// Effect TaggedErrorClass envelope with schema fields at top level
137    /// (errors.ts:143 McpServerNotFoundError → {"_tag":..., name, message}).
138    TaggedData {
139        status: StatusCode,
140        tag: &'static str,
141        fields: serde_json::Value,
142    },
143}
144
145impl From<ApiError> for HttpError {
146    fn from(e: ApiError) -> Self {
147        HttpError::Api(e)
148    }
149}
150
151impl IntoResponse for HttpError {
152    fn into_response(self) -> Response {
153        match self {
154            HttpError::Api(e) => e.into_response(),
155            HttpError::Tagged { status, tag } => {
156                let body = format!("{{\"_tag\":\"{tag}\"}}");
157                (
158                    status,
159                    [(axum::http::header::CONTENT_TYPE, "application/json")],
160                    body,
161                )
162                    .into_response()
163            }
164            HttpError::TaggedData {
165                status,
166                tag,
167                fields,
168            } => {
169                let mut obj = serde_json::Map::new();
170                obj.insert("_tag".into(), serde_json::Value::String(tag.into()));
171                if let serde_json::Value::Object(f) = fields {
172                    obj.extend(f);
173                }
174                (
175                    status,
176                    [(axum::http::header::CONTENT_TYPE, "application/json")],
177                    serde_json::Value::Object(obj).to_string(),
178                )
179                    .into_response()
180            }
181            HttpError::Query { message } => {
182                let body = serde_json::json!({
183                    "name": "BadRequest",
184                    "data": {"message": message, "kind": "Query"}
185                })
186                .to_string();
187                (
188                    StatusCode::BAD_REQUEST,
189                    [(axum::http::header::CONTENT_TYPE, "application/json")],
190                    body,
191                )
192                    .into_response()
193            }
194        }
195    }
196}
197
198/// session → (generation, background prompt task) for POST /abort.
199pub type PromptTask = (u64, tokio::task::JoinHandle<()>);
200
201/// App state shared by handlers.
202pub struct AppState {
203    /// Config-derived route payloads (W4: swappable after PATCH /config —
204    /// upstream marks the instance for disposal and serves fresh config).
205    pub payloads: parking_lot::RwLock<Payloads>,
206    /// PERF-10X F5: pre-serialized bytes for the hot Json-clone handlers
207    /// (config/agent/command/config_providers/provider/console/capabilities).
208    /// Built at construction + every reload, so a request is a Bytes clone
209    /// (refcount) + zero-copy Body — no per-request Value clone/serialize.
210    /// Kill-switch: OCSERVE_WIRE_CACHE=0 (wire_off) falls back to the Value
211    /// path; both paths serve serde-identical bytes (unit-tested).
212    pub wire: parking_lot::RwLock<HashMap<&'static str, bytes::Bytes>>,
213    pub wire_off: std::sync::atomic::AtomicBool,
214    /// Boot-injected config reloader (Runtime lives in ocserve; the
215    /// closure avoids a crate cycle). None in tests → PATCH still writes
216    /// the file, swap skipped (logged).
217    pub reloader: parking_lot::RwLock<Option<ConfigReloader>>,
218    /// Known sessions (M1: served from store as it comes online).
219    pub sessions: parking_lot::RwLock<HashMap<String, serde_json::Value>>,
220    /// Runtime paths for GET /path (env-derived at boot; never stored in repo).
221    pub paths: serde_json::Value,
222    /// Auth mode: None = off (freeze default), Some(("user","pass")) = basic.
223    pub auth: Option<(String, String)>,
224    /// Request counter for /metrics (bounded, monotonic).
225    pub requests: std::sync::atomic::AtomicU64,
226    // ---- M2 prompt runtime (wired by CLI) ----
227    /// Bounded broadcast of encoded global-event frames.
228    pub bus: ocserve_core::EventBus,
229    pub db: std::path::PathBuf,
230    pub blobs: std::sync::Arc<ocserve_store::BlobStore>,
231    pub writer: std::sync::Arc<ocserve_store::Writer>,
232    /// Provider registry + default model + agent systems (from Runtime).
233    /// RwLock since H2: rebuilds on config hot-reload (watch::reconcile);
234    /// in-flight prompts keep the values they resolved at build time.
235    pub llm: parking_lot::RwLock<LlmRegistry>,
236    /// Config hot-reload watch state (H1): polled paths + observed tuples.
237    pub watch: parking_lot::RwLock<watch::WatchState>,
238    /// Serializes reconcile across PATCH/auth/watcher callers (tokio Mutex —
239    /// held across reload + MCP awaits; never a parking_lot guard over await).
240    pub reconcile_lock: tokio::sync::Mutex<()>,
241    /// K-AUTONOMY knobs — read from env ONCE at construction (changing
242    /// OCSERVE_PROMPT_MAX_ROUNDS / OCSERVE_PROMPT_MAX_COST_USD = restart).
243    pub max_rounds: usize,
244    pub cost_ceiling: f64,
245    /// Permission rendezvous shared by runner + reply routes.
246    pub gate: std::sync::Arc<ocserve_core::PermissionGate>,
247    /// MCP hub probed at serve boot (OnceLock: tests run without probing).
248    pub mcp: std::sync::OnceLock<std::sync::Arc<ocserve_mcp::McpHub>>,
249    /// Plugin sidecar (M4b): async mutex — trigger() takes &mut across await.
250    pub plugins: std::sync::OnceLock<std::sync::Arc<tokio::sync::Mutex<ocserve_plugin::Sidecar>>>,
251    /// Per-session prompt serialization (queue semantics). Bounded: entries
252    /// exist only for in-flight prompts + transient races, removed post-run
253    /// when uncontended (AGENTS §2.3 — active-set bound, no eviction).
254    pub prompt_locks: std::sync::Arc<
255        parking_lot::Mutex<
256            std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
257        >,
258    >,
259    /// Background prompt tasks per session: (generation, JoinHandle) —
260    /// POST /abort cancels the current generation (v1 session.abort).
261    /// Bound: one entry per in-flight async prompt, removed on completion.
262    pub prompt_tasks:
263        std::sync::Arc<parking_lot::Mutex<std::collections::HashMap<String, PromptTask>>>,
264    /// Monotonic generation source for prompt_tasks.
265    pub prompt_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
266    /// Question rendezvous (v1 Question service): pending asks + reply/reject.
267    pub question_gate: std::sync::Arc<ocserve_core::question::QuestionGate>,
268    /// /tui/control rendezvous queues (v1 shared/tui-control.ts)
269    pub tui: crate::tui::TuiControl,
270    /// Web-UI reverse proxy (Phase 7; upstream serveUIEffect parity)
271    pub ui: crate::ui::UiProxy,
272    /// User CORS allowlist (config `server.cors` + `--cors`), read once at boot
273    pub cors_extra: Vec<String>,
274}
275
276/// LLM endpoint resolution for the prompt runner (assembled by Runtime).
277#[derive(Default)]
278pub struct LlmRegistry {
279    /// providerID → (base_url, api_key)
280    pub endpoints: std::collections::HashMap<String, (String, String)>,
281    /// (providerID, modelID) → (input, output, cache_read) USD/MTok
282    pub pricing: std::collections::HashMap<(String, String), (f64, f64, f64)>,
283    /// (providerID, modelID) → `limit` block (context/input) — M6 trigger
284    pub limits: std::collections::HashMap<(String, String), serde_json::Value>,
285    /// default (providerID, modelID) from opencode state
286    pub default_model: (String, String),
287    /// agent name → system prompt (only agents with explicit prompts)
288    pub systems: std::collections::HashMap<String, String>,
289    /// config default_agent (upstream session rows record it at prompt time)
290    pub default_agent: String,
291}
292
293/// Real runtime pieces passed at construction (no interior mutability, no unsafe).
294pub struct Wires {
295    pub db: std::path::PathBuf,
296    pub blobs: std::sync::Arc<ocserve_store::BlobStore>,
297    pub writer: std::sync::Arc<ocserve_store::Writer>,
298    pub llm: LlmRegistry,
299}
300
301/// Serialize the hot payload fields once (reload-time). off => empty map
302/// (handlers fall back to the Value path).
303pub(crate) fn rebuild_wire(p: &Payloads, off: bool) -> HashMap<&'static str, bytes::Bytes> {
304    let mut m = HashMap::new();
305    if off {
306        return m;
307    }
308    // serde_json::to_vec is exactly what axum's Json uses — byte-identical.
309    fn one<T: serde::Serialize>(
310        m: &mut HashMap<&'static str, bytes::Bytes>,
311        k: &'static str,
312        v: &T,
313    ) {
314        if let Ok(b) = serde_json::to_vec(v) {
315            m.insert(k, bytes::Bytes::from(b));
316        }
317    }
318    one(&mut m, "config", &p.config);
319    one(&mut m, "agent", &p.agent);
320    one(&mut m, "command", &p.command);
321    one(&mut m, "config_providers", &p.config_providers);
322    one(&mut m, "provider", &p.provider);
323    one(&mut m, "console", &p.console);
324    one(&mut m, "capabilities", &p.capabilities);
325    m
326}
327
328/// Serve cached bytes as a JSON response; None = fall back to Value clone.
329fn wire_json(st: &AppState, key: &'static str) -> Option<axum::response::Response> {
330    if st.wire_off.load(std::sync::atomic::Ordering::Relaxed) {
331        return None;
332    }
333    let b = st.wire.read().get(key).cloned()?;
334    Some(
335        axum::response::Response::builder()
336            .header(axum::http::header::CONTENT_TYPE, "application/json")
337            .body(axum::body::Body::from(b))
338            .expect("static response"),
339    )
340}
341
342impl AppState {
343    pub fn new() -> Arc<Self> {
344        Self::with_auth(None)
345    }
346
347    pub fn with_auth(auth: Option<(String, String)>) -> Arc<Self> {
348        Self::with_payloads(auth, Payloads::default())
349    }
350
351    /// Build from pre-assembled route payloads with a temp-backed store
352    /// (tests / simple construction — real pieces, no placeholders).
353    pub fn with_payloads(auth: Option<(String, String)>, p: Payloads) -> Arc<Self> {
354        let dir = std::env::temp_dir().join(format!(
355            "ocserve-http-{}-{}",
356            std::process::id(),
357            std::time::SystemTime::now()
358                .duration_since(std::time::UNIX_EPOCH)
359                .map(|d| d.as_nanos())
360                .unwrap_or(0)
361        ));
362        std::fs::create_dir_all(&dir).expect("temp data dir");
363        let db = ocserve_store::writer::db_path(&dir);
364        let writer =
365            std::sync::Arc::new(ocserve_store::Writer::spawn(db.clone()).expect("temp writer"));
366        let blobs = std::sync::Arc::new(
367            ocserve_store::BlobStore::new(dir.join("blobs")).expect("temp blobs"),
368        );
369        Self::with_wiring(
370            auth,
371            p,
372            Wires {
373                db,
374                blobs,
375                writer,
376                llm: LlmRegistry::default(),
377            },
378        )
379    }
380
381    /// One-shot construction with real runtime wiring (CLI path).
382    pub fn with_wiring(auth: Option<(String, String)>, p: Payloads, w: Wires) -> Arc<Self> {
383        let home = std::env::var("HOME").unwrap_or_default();
384        let worktree = std::env::current_dir()
385            .map(|p| p.display().to_string())
386            .unwrap_or_else(|_| "/".into());
387        let wire_off = std::env::var("OCSERVE_WIRE_CACHE")
388            .map(|v| v == "0")
389            .unwrap_or(false);
390        let wire_map = rebuild_wire(&p, wire_off);
391        Arc::new(Self {
392            payloads: parking_lot::RwLock::new(p),
393            wire: parking_lot::RwLock::new(wire_map),
394            wire_off: std::sync::atomic::AtomicBool::new(wire_off),
395            reloader: parking_lot::RwLock::new(None),
396            sessions: parking_lot::RwLock::new(HashMap::new()),
397            // upstream /path shape (keys golden: home/state/config/worktree/directory)
398            paths: json!({
399                "home": home,
400                "state": format!("{home}/.local/state/opencode"),
401                "config": format!("{home}/.config/opencode"),
402                "worktree": worktree,
403                "directory": worktree,
404            }),
405            auth,
406            requests: std::sync::atomic::AtomicU64::new(0),
407            bus: ocserve_core::EventBus::new(),
408            gate: ocserve_core::PermissionGate::new(),
409            mcp: std::sync::OnceLock::new(),
410            plugins: std::sync::OnceLock::new(),
411            prompt_locks: std::sync::Arc::new(parking_lot::Mutex::new(
412                std::collections::HashMap::new(),
413            )),
414            prompt_tasks: std::sync::Arc::new(parking_lot::Mutex::new(
415                std::collections::HashMap::new(),
416            )),
417            prompt_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)),
418            question_gate: ocserve_core::question::QuestionGate::new(),
419            tui: crate::tui::TuiControl::default(),
420            ui: crate::ui::UiProxy::from_env(),
421            cors_extra: read_cors_env(),
422            db: w.db,
423            blobs: w.blobs,
424            writer: w.writer,
425            llm: parking_lot::RwLock::new(w.llm),
426            watch: parking_lot::RwLock::new(watch::WatchState::default()),
427            reconcile_lock: tokio::sync::Mutex::new(()),
428            max_rounds: std::env::var("OCSERVE_PROMPT_MAX_ROUNDS")
429                .ok()
430                .and_then(|v| v.parse().ok())
431                .unwrap_or(0),
432            cost_ceiling: std::env::var("OCSERVE_PROMPT_MAX_COST_USD")
433                .ok()
434                .and_then(|v| v.parse().ok())
435                .unwrap_or(0.0),
436        })
437    }
438}
439
440async fn health() -> impl IntoResponse {
441    Json(json!({"healthy": true, "version": FREEZE_VERSION}))
442}
443
444async fn get_config(State(st): State<Arc<AppState>>) -> axum::response::Response {
445    wire_json(&st, "config")
446        .unwrap_or_else(|| Json(st.payloads.read().config.clone()).into_response())
447}
448
449async fn get_agent(State(st): State<Arc<AppState>>) -> axum::response::Response {
450    wire_json(&st, "agent")
451        .unwrap_or_else(|| Json(st.payloads.read().agent.clone()).into_response())
452}
453
454async fn get_command(State(st): State<Arc<AppState>>) -> axum::response::Response {
455    wire_json(&st, "command")
456        .unwrap_or_else(|| Json(st.payloads.read().command.clone()).into_response())
457}
458
459async fn get_config_providers(State(st): State<Arc<AppState>>) -> axum::response::Response {
460    wire_json(&st, "config_providers")
461        .unwrap_or_else(|| Json(st.payloads.read().config_providers.clone()).into_response())
462}
463
464async fn get_provider(State(st): State<Arc<AppState>>) -> axum::response::Response {
465    wire_json(&st, "provider")
466        .unwrap_or_else(|| Json(st.payloads.read().provider.clone()).into_response())
467}
468
469async fn get_console(State(st): State<Arc<AppState>>) -> axum::response::Response {
470    wire_json(&st, "console")
471        .unwrap_or_else(|| Json(st.payloads.read().console.clone()).into_response())
472}
473
474async fn get_capabilities(State(st): State<Arc<AppState>>) -> axum::response::Response {
475    wire_json(&st, "capabilities")
476        .unwrap_or_else(|| Json(st.payloads.read().capabilities.clone()).into_response())
477}
478
479/// v2-style location envelope shared by /api/* routes (captured live).
480fn api_location(st: &AppState) -> Value {
481    let dir = st.paths["directory"].as_str().unwrap_or("/");
482    json!({
483        "directory": dir,
484        "project": {"id": "global", "directory": "/"},
485    })
486}
487
488/// GET /api/location — captured shape (keys golden: directory/project/{id,directory}).
489async fn get_api_location(State(st): State<Arc<AppState>>) -> impl IntoResponse {
490    Json(api_location(&st))
491}
492
493/// GET /api/agent — v2-shaped agent list (permission triple renamed to
494/// {action, resource, effect}; natives-first order; no plugin agents).
495async fn get_api_agent(State(st): State<Arc<AppState>>) -> impl IntoResponse {
496    let payloads = st.payloads.read();
497    let data: Vec<Value> = payloads
498        .api_agent
499        .iter()
500        .map(|a| {
501            let perms: Vec<Value> = a["permission"]
502                .as_array()
503                .map(|rules| {
504                    rules
505                        .iter()
506                        .map(|r| {
507                            json!({
508                                "action": r["permission"],
509                                "resource": r["pattern"],
510                                "effect": r["action"],
511                            })
512                        })
513                        .collect()
514                })
515                .unwrap_or_default();
516            let mut out = json!({
517                "id": a["name"],
518                "hidden": a.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false),
519                "mode": a["mode"],
520                "permissions": perms,
521                "request": {"headers": {}, "body": {}},
522            });
523            if let Some(d) = a.get("description") {
524                out["description"] = d.clone();
525            }
526            // system: explicit prompt, else the captured build default
527            if let Some(p) = a.get("prompt") {
528                out["system"] = p.clone();
529            } else if a["name"] == "build" {
530                out["system"] = json!(BUILD_SYSTEM_BLURB);
531            }
532            out
533        })
534        .collect();
535    Json(json!({"location": api_location(&st), "data": data}))
536}
537
538/// Captured from live /api/agent (build has no prompt; upstream serves this blurb).
539const BUILD_SYSTEM_BLURB: &str = "You are an AI coding agent. Help the user accomplish software \
540engineering tasks by inspecting the workspace, making targeted changes, and using tools according \
541to the configured permissions.";
542
543/// GET /api/command — {location, data:[{description,name,template}]}.
544async fn get_api_command(State(st): State<Arc<AppState>>) -> impl IntoResponse {
545    let payloads = st.payloads.read();
546    let data: Vec<Value> = payloads
547        .command
548        .iter()
549        .map(|c| {
550            json!({
551                "name": c["name"],
552                "description": c.get("description").and_then(|v| v.as_str()).unwrap_or(""),
553                "template": c.get("template").and_then(|v| v.as_str()).unwrap_or(""),
554            })
555        })
556        .collect();
557    Json(json!({"location": api_location(&st), "data": data}))
558}
559
560/// GET /api/reference — always empty (no references configured).
561async fn get_api_reference(State(st): State<Arc<AppState>>) -> impl IntoResponse {
562    Json(json!({"location": api_location(&st), "data": []}))
563}
564
565/// Empty-shape routes captured live: {}, [].
566/// GET /experimental/tool?provider=&model= — v1 ToolList (groups/
567/// experimental.ts:50-61; handlers/experimental.ts:94-105): [{id,
568/// description, parameters}] = builtin + MCP tools. Effect validates the
569/// query (missing provider/model → BadRequest) — mirrored here. Denied
570/// tools excluded, matching what the model actually sees (W5).
571async fn get_experimental_tool(
572    State(st): State<Arc<AppState>>,
573    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
574) -> Result<Json<Value>, ApiError> {
575    let has = |k: &str| q.get(k).map(|s| !s.is_empty()).unwrap_or(false);
576    if !has("provider") || !has("model") {
577        return Err(ApiError {
578            status: StatusCode::BAD_REQUEST,
579            name: "BadRequest",
580            message: "Invalid query: provider, model required".into(),
581        });
582    }
583    let mut schemas = ocserve_tools::schemas();
584    if let Some(hub) = st.mcp.get() {
585        let mcp_tools =
586            tokio::time::timeout(std::time::Duration::from_secs(10), hub.tool_schemas())
587                .await
588                .unwrap_or_else(|_| {
589                    tracing::warn!("mcp tool schema fetch timed out (/experimental/tool)");
590                    Vec::new()
591                });
592        schemas.extend(mcp_tools);
593    }
594    let items: Vec<Value> = schemas
595        .iter()
596        .filter_map(|t| {
597            let f = t.get("function")?;
598            Some(json!({
599                "id": f.get("name")?,
600                "description": f.get("description")?,
601                "parameters": f.get("parameters")?,
602            }))
603        })
604        .collect();
605    Ok(Json(Value::Array(items)))
606}
607
608/// GET /experimental/tool/ids — registry.ids(): plain string array.
609async fn get_experimental_tool_ids() -> Json<Value> {
610    let ids: Vec<Value> = ocserve_tools::schemas()
611        .iter()
612        .filter_map(|t| t.pointer("/function/name").and_then(|n| n.as_str()))
613        .map(|n| json!(n))
614        .collect();
615    Json(Value::Array(ids))
616}
617
618async fn experimental_resource() -> Json<Value> {
619    Json(json!({}))
620}
621async fn experimental_workspace() -> Json<Value> {
622    Json(json!([]))
623}
624
625/// GET /experimental/workspace/status — freeze: `[]` 200 (probed live).
626async fn experimental_workspace_status() -> Json<Value> {
627    Json(json!([]))
628}
629async fn formatter_list() -> Json<Value> {
630    Json(json!([]))
631}
632async fn lsp_list() -> Json<Value> {
633    Json(json!([]))
634}
635async fn project_directories() -> Json<Value> {
636    Json(json!([]))
637}
638
639/// GET /mcp — {name: {status, error?}} (freeze capture §1146).
640async fn get_mcp(State(st): State<Arc<AppState>>) -> Json<Value> {
641    let hub = st
642        .mcp
643        .get()
644        .cloned()
645        .unwrap_or_else(|| std::sync::Arc::new(ocserve_mcp::McpHub::default()));
646    Json(hub.statuses())
647}
648
649/// GET /experimental/session?search=&roots=&limit= — session title search
650/// (oc-remote searchSessions: "not a content search"); rows = list wire shape
651/// + embedded project {id, worktree} (observed contract, manifest keys mode).
652async fn get_experimental_sessions(
653    State(st): State<Arc<AppState>>,
654    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
655) -> Json<Value> {
656    let mut v = run_blocking(&st.db, ocserve_store::load_sessions_wire)
657        .await
658        .unwrap_or_else(|e| {
659            tracing::error!("session list read failed: {e:#}");
660            Vec::new()
661        });
662    if let Some(search) = q.get("search").filter(|s| !s.is_empty()) {
663        let needle = search.to_lowercase();
664        v.retain(|s| {
665            s["title"]
666                .as_str()
667                .unwrap_or_default()
668                .to_lowercase()
669                .contains(&needle)
670        });
671    }
672    if let Some(limit) = q.get("limit").and_then(|l| l.parse::<usize>().ok()) {
673        v.truncate(limit);
674    }
675    // roots=true is implicit: our store holds root sessions only (M3 scope).
676    for row in &mut v {
677        row["project"] = json!({"id": "global", "worktree": "/"});
678    }
679    Json(Value::Array(v))
680}
681
682/// POST /permission/{id}/reply — {reply: once|always|reject} (oc-remote contract).
683async fn post_permission_reply(
684    State(st): State<Arc<AppState>>,
685    axum::extract::Path(id): axum::extract::Path<String>,
686    Json(body): Json<Value>,
687) -> Result<Json<Value>, ApiError> {
688    let reply = body
689        .get("reply")
690        .and_then(|v| v.as_str())
691        .unwrap_or("reject")
692        .to_string();
693    if !st.gate.reply(&id, &reply) {
694        return Err(ApiError::not_found(format!(
695            "Permission request not found: {id}"
696        )));
697    }
698    Ok(Json(json!({})))
699}
700
701/// GET /permission — pending permission requests.
702async fn get_permissions(State(st): State<Arc<AppState>>) -> Json<Value> {
703    Json(serde_json::Value::Array(st.gate.list()))
704}
705
706/// GET /vcs — {branch, default_branch} (values cwd-dependent; keys golden).
707async fn vcs_info() -> impl IntoResponse {
708    let git = |args: &[&str]| -> Option<String> {
709        let out = std::process::Command::new("git").args(args).output().ok()?;
710        if !out.status.success() {
711            return None;
712        }
713        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
714        (!s.is_empty()).then_some(s)
715    };
716    Json(json!({
717        "branch": git(&["rev-parse", "--abbrev-ref", "HEAD"]),
718        "default_branch": git(&["symbolic-ref", "refs/remotes/origin/HEAD"])
719            .map(|r| r.trim_start_matches("refs/remotes/origin/").to_string()),
720    }))
721}
722
723// ---- PERF-10X F9: paged /message wire memo ----
724//
725// message_page was the CPU hog (route isolation: ~3 ms CPU/page — parse +
726// merge + serialize per message, blocking-pool thread, channel framing).
727// A paged response (limit>0, ≤50 messages) is assembled ONCE per
728// (db, session, limit, cursor, write-epoch) and served as refcounted
729// Bytes; hits skip reader, queries, parse, thread and channel entirely.
730// Bound by construction: entries ≤ 4 MiB (larger assemblies abort and
731// fall back to the original streaming path — the unbounded full-history
732// stream is NEVER memoized, AGENTS §2.3), FIFO capped at 32 entries.
733// Exactness: keys carry the write epoch; a writer bump invalidates
734// (lazy clear on epoch mismatch — same rule as F7/F8). Kill switch:
735// OCSERVE_PAGE_MEMO=0.
736pub const PAGE_MEMO_ENTRY_CAP: usize = 4 * 1024 * 1024;
737/// Count + BYTE bounds (F9c): the first cap (32) thrashed against the
738/// fixture's 200-session spread working set (VU150/300 spread fell to
739/// 3,794 rps, p95 278ms — every rotation missed and paid the full
740/// assemble path; arrival never recovered from it). Working set ≈
741/// sessions x pages-per-loop ≈ 600 keys; typical pages are 10-50KB so a
742/// 64 MiB total budget holds the whole set (env: OCSERVE_PAGE_MEMO_MB).
743pub const PAGE_MEMO_MAX_ENTRIES: usize = 2048;
744pub const PAGE_MEMO_MAX_BYTES: usize = 64 * 1024 * 1024;
745
746struct PageEntry {
747    body: bytes::Bytes,
748    next: Option<String>,
749}
750
751struct PageMemoState {
752    epoch: u64,
753    entries: std::collections::VecDeque<(String, PageEntry)>,
754    bytes: usize,
755}
756
757impl PageMemoState {
758    fn new() -> Self {
759        Self {
760            epoch: 0,
761            entries: std::collections::VecDeque::new(),
762            bytes: 0,
763        }
764    }
765    fn get(&mut self, key: &str, epoch: u64) -> Option<PageEntry> {
766        if self.epoch != epoch {
767            self.entries.clear();
768            self.bytes = 0;
769            self.epoch = epoch;
770        }
771        self.entries
772            .iter()
773            .find(|(k, _)| k == key)
774            .map(|(_, e)| PageEntry {
775                body: e.body.clone(),
776                next: e.next.clone(),
777            })
778    }
779    fn put(&mut self, key: String, entry: PageEntry, epoch: u64) {
780        if entry.body.len() > PAGE_MEMO_ENTRY_CAP {
781            return;
782        }
783        if self.epoch != epoch {
784            self.entries.clear();
785            self.bytes = 0;
786            self.epoch = epoch;
787        }
788        // drop same-key old copy (byte accounting)
789        if let Some(pos) = self.entries.iter().position(|(k, _)| k == &key) {
790            let (_, old) = self.entries.remove(pos).expect("position found");
791            self.bytes -= old.body.len();
792        }
793        let budget = std::env::var("OCSERVE_PAGE_MEMO_MB")
794            .ok()
795            .and_then(|v| v.parse::<usize>().ok())
796            .unwrap_or(64)
797            * 1024
798            * 1024;
799        self.bytes += entry.body.len();
800        self.entries.push_back((key, entry));
801        while self.entries.len() > PAGE_MEMO_MAX_ENTRIES
802            || (self.bytes > budget && self.entries.len() > 1)
803        {
804            if let Some((_, old)) = self.entries.pop_front() {
805                self.bytes -= old.body.len();
806            } else {
807                break;
808            }
809        }
810    }
811    fn len(&self) -> usize {
812        self.entries.len()
813    }
814}
815
816static PAGE_MEMO: std::sync::OnceLock<parking_lot::Mutex<PageMemoState>> =
817    std::sync::OnceLock::new();
818static PAGE_MEMO_HITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
819static PAGE_MEMO_MISSES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
820
821fn page_memo() -> &'static parking_lot::Mutex<PageMemoState> {
822    PAGE_MEMO.get_or_init(|| parking_lot::Mutex::new(PageMemoState::new()))
823}
824
825pub fn page_memo_enabled() -> bool {
826    std::env::var("OCSERVE_PAGE_MEMO")
827        .map(|v| v != "0")
828        .unwrap_or(true)
829}
830
831/// (hits, misses, entries) for the load report / tests.
832pub fn page_memo_stats() -> (u64, u64, usize) {
833    (
834        PAGE_MEMO_HITS.load(std::sync::atomic::Ordering::Relaxed),
835        PAGE_MEMO_MISSES.load(std::sync::atomic::Ordering::Relaxed),
836        page_memo().lock().len(),
837    )
838}
839
840/// Page response builder shared by the memo path and the streaming path
841/// (headers rule = upstream session.ts:133-147: Link echoes the request
842/// origin — Host / x-forwarded-proto — so headers are rebuilt per request
843/// even on a memo hit; only the BODY is cached).
844fn page_response(
845    headers: &axum::http::HeaderMap,
846    uri: &axum::http::Uri,
847    page_n: &Option<u64>,
848    page_next: &Option<String>,
849    body: axum::body::Body,
850) -> Result<axum::response::Response, ApiError> {
851    let mut builder = axum::response::Response::builder()
852        .header(axum::http::header::CONTENT_TYPE, "application/json");
853    if let (Some(n), Some(cur)) = (page_n, page_next) {
854        let scheme = headers
855            .get("x-forwarded-proto")
856            .and_then(|v| v.to_str().ok())
857            .unwrap_or("http");
858        let host = headers
859            .get("host")
860            .and_then(|v| v.to_str().ok())
861            .unwrap_or("localhost");
862        let link = format!(
863            "<{scheme}://{host}{}?limit={n}&before={cur}>; rel=\"next\"",
864            uri.path()
865        );
866        builder = builder
867            .header("x-next-cursor", cur.as_str())
868            .header("link", link)
869            .header("access-control-expose-headers", "Link, X-Next-Cursor");
870    }
871    builder.body(body).map_err(|e| ApiError {
872        status: StatusCode::INTERNAL_SERVER_ERROR,
873        name: "InternalError",
874        message: format!("response build: {e}"),
875    })
876}
877
878async fn get_sessions(State(st): State<Arc<AppState>>) -> axum::response::Response {
879    // Fresh DB rows (upstream semantics) — the boot map was stale after
880    // prompts (M2 finding); load_sessions_wire orders by time_updated DESC.
881    // F5-extends-F8: serialized once per write-epoch, then served as
882    // refcounted Bytes (zero serde per request on the hit path).
883    match run_blocking(&st.db, ocserve_store::load_sessions_wire_bytes).await {
884        Ok(b) => axum::response::Response::builder()
885            .header(axum::http::header::CONTENT_TYPE, "application/json")
886            .body(axum::body::Body::from((*b).clone()))
887            .expect("static response"),
888        Err(e) => {
889            tracing::error!("session list read failed: {e:#}");
890            Json(Vec::<serde_json::Value>::new()).into_response()
891        }
892    }
893}
894
895async fn get_session(
896    State(st): State<Arc<AppState>>,
897    axum::extract::Path(id): axum::extract::Path<String>,
898) -> Result<impl IntoResponse, ApiError> {
899    let wire_id = id.clone();
900    run_blocking(&st.db, move |db| {
901        ocserve_store::load_session_wire(db, &wire_id)
902    })
903    .await
904    .map_err(|e| ApiError {
905        status: StatusCode::INTERNAL_SERVER_ERROR,
906        name: "InternalError",
907        message: format!("{e:#}"),
908    })?
909    .map(Json)
910    .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))
911}
912
913/// GET /session/status — map of NON-idle sessions only (upstream
914/// session/status.ts deletes idle entries; probe fixture: status_busy.body).
915/// Busy = held/contended prompt lock OR a live background prompt task —
916/// queued-but-not-yet-running prompts count (client's send is admitted).
917async fn session_status(State(st): State<Arc<AppState>>) -> impl IntoResponse {
918    let mut out = serde_json::Map::new();
919    let mut busy = |sid: &str| {
920        out.insert(sid.to_string(), serde_json::json!({"type": "busy"}));
921    };
922    for (sid, arc) in st.prompt_locks.lock().iter() {
923        if arc.try_lock().is_err() {
924            busy(sid);
925        }
926    }
927    for (sid, (_gen, handle)) in st.prompt_tasks.lock().iter() {
928        if !handle.is_finished() {
929            busy(sid);
930        }
931    }
932    Json(serde_json::Value::Object(out))
933}
934
935/// GET /path — env-derived at boot (upstream keys: home/state/config/worktree/directory).
936async fn get_path(State(st): State<Arc<AppState>>) -> impl IntoResponse {
937    Json(st.paths.clone())
938}
939
940/// GET /project — global project + current worktree project (keys: id/worktree/time/sandboxes).
941async fn get_projects(State(st): State<Arc<AppState>>) -> impl IntoResponse {
942    let now_ms = std::time::SystemTime::now()
943        .duration_since(std::time::UNIX_EPOCH)
944        .map(|d| d.as_millis() as i64)
945        .unwrap_or(0);
946    let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
947    let project = |id: &str, wt: &str| {
948        json!({
949            "id": id,
950            "worktree": wt,
951            "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
952            "sandboxes": [],
953        })
954    };
955    Json(json!([
956        project("global", "/"),
957        project("current", &worktree),
958    ]))
959}
960
961/// GET /project/current — same shape, single object.
962async fn get_project_current(State(st): State<Arc<AppState>>) -> impl IntoResponse {
963    let now_ms = std::time::SystemTime::now()
964        .duration_since(std::time::UNIX_EPOCH)
965        .map(|d| d.as_millis() as i64)
966        .unwrap_or(0);
967    let worktree = st.paths["worktree"].as_str().unwrap_or("/");
968    Json(json!({
969        "id": "global",
970        "worktree": worktree,
971        "time": {"created": now_ms, "updated": now_ms, "initialized": now_ms},
972        "sandboxes": [],
973    }))
974}
975
976/// Prometheus text format for the KPI series (SRE §2). Internal bind only.
977async fn metrics(State(_st): State<Arc<AppState>>) -> impl IntoResponse {
978    // registry counters are incremented by the timing middleware; rss sampled
979    // here (scrape-time) so gauges are fresh
980    ocserve_metrics::sample_rss();
981    // Read-path fast activity (L1 splice + F5/F7/F8 memos), published as
982    // absolute cumulative gauges at scrape time — these counters live in
983    // ocserve-store as atomics with no event stream of their own.
984    //  - splice_rows/fallbacks: the corpus gate proves 0 fallbacks on real
985    //    data, so a non-zero fallback gauge means stored bytes hit a shape
986    //    the splicer refuses — a signal, not a silent fallback (lib.rs L1).
987    //  - memo hit/miss: the load report's cache-on/off variants must be
988    //    observable in production, not just in a bench run.
989    ocserve_metrics::gauge(
990        "ocserve_splice_rows_total",
991        ocserve_store::splice_rows() as i64,
992    );
993    ocserve_metrics::gauge(
994        "ocserve_splice_fallbacks_total",
995        ocserve_store::splice_fallbacks() as i64,
996    );
997    let (search_hits, search_misses, list_hits, list_misses, _pad, search_len, _epoch) =
998        ocserve_store::memo_stats();
999    ocserve_metrics::gauge("ocserve_memo_search_hits", search_hits as i64);
1000    ocserve_metrics::gauge("ocserve_memo_search_misses", search_misses as i64);
1001    ocserve_metrics::gauge("ocserve_memo_list_hits", list_hits as i64);
1002    ocserve_metrics::gauge("ocserve_memo_list_misses", list_misses as i64);
1003    ocserve_metrics::gauge("ocserve_memo_search_entries", search_len as i64);
1004    let body = ocserve_metrics::render();
1005    (
1006        [(
1007            axum::http::header::CONTENT_TYPE,
1008            "text/plain; version=0.0.4",
1009        )],
1010        body,
1011    )
1012}
1013
1014// ---- M2: session create + sync prompt + message list ----
1015
1016/// Two-word slug (upstream generates like "stellar-orchid" — entropy from id).
1017fn slug_for(id: &str) -> String {
1018    const A: &[&str] = &[
1019        "stellar", "amber", "quiet", "cobalt", "rapid", "lunar", "vivid", "crisp", "amber", "ivory",
1020    ];
1021    const B: &[&str] = &[
1022        "orchid", "falcon", "meadow", "canyon", "ember", "drift", "quartz", "harbor", "summit",
1023        "ripple",
1024    ];
1025    let h = id
1026        .bytes()
1027        .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
1028    format!(
1029        "{}-{}",
1030        A[(h % A.len() as u64) as usize],
1031        B[((h >> 16) % B.len() as u64) as usize]
1032    )
1033}
1034
1035fn now_ms() -> i64 {
1036    std::time::SystemTime::now()
1037        .duration_since(std::time::UNIX_EPOCH)
1038        .map(|d| d.as_millis() as i64)
1039        .unwrap_or(0)
1040}
1041
1042/// ISO-8601 UTC with milliseconds (freeze `Date.prototype.toISOString`
1043/// shape) — days-from-civil, no time-crate dependency.
1044fn iso8601_z(ms: i64) -> String {
1045    let ms = ms.rem_euclid(86_400_000_000_000);
1046    let (days, rem) = (ms.div_euclid(86_400_000), ms.rem_euclid(86_400_000));
1047    let (h, rem) = (rem.div_euclid(3_600_000), rem.rem_euclid(3_600_000));
1048    let (mi, rem) = (rem.div_euclid(60_000), rem.rem_euclid(60_000));
1049    let (sec, milli) = (rem.div_euclid(1_000), rem.rem_euclid(1_000));
1050    // civil_from_days (Howard Hinnant)
1051    let z = days + 719_468;
1052    let era = z.div_euclid(146_097);
1053    let doe = z.rem_euclid(146_097);
1054    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1055    let y = yoe + era * 400;
1056    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1057    let mp = (5 * doy + 2) / 153;
1058    let d = doy - (153 * mp + 2) / 5 + 1;
1059    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1060    let y = if m <= 2 { y + 1 } else { y };
1061    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}.{milli:03}Z")
1062}
1063
1064/// Freeze default session title (session.ts `parentTitlePrefix + ISO`) —
1065/// empty titles would render "untitled" in clients (K-TITLE).
1066fn default_session_title(now_ms: i64) -> String {
1067    format!("New session - {}", iso8601_z(now_ms))
1068}
1069
1070/// Freeze `getForkedTitle` (session.ts): `/^(.+) \(fork #(\d+)\)$/` →
1071/// `base (fork #(n+1))`, otherwise `title (fork #1)`. Ported with string ops
1072/// (regex `.+` needs ≥1 char before the suffix → `idx > 0`, greedy match →
1073/// last occurrence → `rfind`).
1074pub fn forked_title(title: &str) -> String {
1075    const PAT: &str = " (fork #";
1076    if let Some(idx) = title.rfind(PAT)
1077        && idx > 0
1078        && let Some(num) = title[idx + PAT.len()..].strip_suffix(')')
1079        && !num.is_empty()
1080        && num.bytes().all(|b| b.is_ascii_digit())
1081        && let Ok(n) = num.parse::<u64>()
1082    {
1083        return format!("{} (fork #{})", &title[..idx], n + 1);
1084    }
1085    format!("{title} (fork #1)")
1086}
1087
1088/// P0e: forward bus frames to the plugin sidecar's `event` hook (v1
1089/// plugin/index.ts:255-259). Lagged frames are skipped (upstream is
1090/// fire-and-forget); a closed bus ends the pump. Never blocks publishers.
1091pub fn start_plugin_event_pump(st: &Arc<AppState>) {
1092    // Subscribe SYNCHRONOUSLY here: last_seq is captured at call time, so
1093    // frames published between call and first recv are buffered (drain), not
1094    // skipped. (Spawning first raced: sync route handlers can publish before
1095    // the task ever ran — caught by the pump test.)
1096    let mut rx = st.bus.subscribe();
1097    let st = st.clone();
1098    tokio::spawn(async move {
1099        loop {
1100            match rx.recv().await {
1101                Ok(frame_str) => {
1102                    let Some(plug) = st.plugins.get() else {
1103                        continue; // sidecar not loaded — frames still on SSE
1104                    };
1105                    let Ok(v) = serde_json::from_str::<Value>(&frame_str) else {
1106                        continue;
1107                    };
1108                    let Some(payload) = v.get("payload").cloned() else {
1109                        continue;
1110                    };
1111                    let mut guard = plug.lock().await;
1112                    if let Err(e) = guard.emit_event(payload).await {
1113                        tracing::debug!("plugin event pump: {e:#}");
1114                    }
1115                }
1116                Err(ocserve_core::event::RecvError::Lagged(n)) => {
1117                    tracing::warn!("plugin event pump lagged {n} frames (skipped)");
1118                }
1119                Err(ocserve_core::event::RecvError::Closed) => break,
1120            }
1121        }
1122    });
1123}
1124
1125/// POST /session — create (wire shape from captured fixture: cost, directory,
1126/// id, path, projectID, slug, time, title, tokens, version).
1127async fn post_session(
1128    State(st): State<Arc<AppState>>,
1129    Json(body): Json<Value>,
1130) -> Result<impl IntoResponse, ApiError> {
1131    let id = ocserve_core::ids::ses_id();
1132    let now = now_ms();
1133    let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
1134    let title = body
1135        .get("title")
1136        .and_then(|t| t.as_str())
1137        .unwrap_or("")
1138        .to_string();
1139    // K-TITLE: freeze default when the client sends none/empty — an empty
1140    // title renders as "untitled" everywhere (upstream: parentTitlePrefix).
1141    let title = if title.trim().is_empty() {
1142        default_session_title(now)
1143    } else {
1144        title
1145    };
1146    let info = json!({
1147        "id": id,
1148        "projectID": "global",
1149        "directory": worktree,
1150        "path": worktree.trim_start_matches('/'),
1151        "slug": slug_for(&id),
1152        "title": title,
1153        "version": FREEZE_VERSION,
1154        "time": {"created": now, "updated": now},
1155        "cost": 0,
1156        "tokens": {"input": 0, "output": 0, "reasoning": 0,
1157                   "cache": {"read": 0, "write": 0}},
1158    });
1159    ocserve_store::insert_session(&st.writer, &info).map_err(|e| ApiError {
1160        status: StatusCode::INTERNAL_SERVER_ERROR,
1161        name: "InternalError",
1162        message: format!("{e:#}"),
1163    })?;
1164    st.bus.publish(ocserve_core::event::frame(
1165        st.paths["directory"].as_str().unwrap_or("/"),
1166        "session.created",
1167        json!({"sessionID": id, "info": info}),
1168    ));
1169    Ok(Json(info))
1170}
1171
1172/// POST /session/{id}/fork — freeze session.fork (groups/session.ts:248,
1173/// session.ts:691). Effect decodes the payload BEFORE the handler runs, so
1174/// payload errors precede the 404: bad JSON / non-object / non-string
1175/// `messageID` → 400 `{"_tag":"BadRequest"}`; empty or whitespace body is
1176/// the NoContent arm (copy all). Returns the NEW Session.Info; messages are
1177/// copied strictly before `messageID` (freeze `slice(0, findIndex)`; unknown
1178/// id → findIndex -1 → copy all).
1179async fn post_fork(
1180    State(st): State<Arc<AppState>>,
1181    axum::extract::Path(id): axum::extract::Path<String>,
1182    body: axum::body::Bytes,
1183) -> Result<Json<Value>, HttpError> {
1184    let upto: Option<String> = {
1185        let text = String::from_utf8_lossy(&body);
1186        if text.trim().is_empty() {
1187            None // NoContent arm
1188        } else {
1189            let v: Value = serde_json::from_str(&text).map_err(|_| tagged_bad_request())?;
1190            if !v.is_object() {
1191                return Err(tagged_bad_request()); // [] / "x" / 42 / null
1192            }
1193            match v.get("messageID") {
1194                None => None,
1195                Some(Value::String(s)) => Some(s.clone()),
1196                Some(_) => return Err(tagged_bad_request()), // non-string / null
1197            }
1198        }
1199    };
1200    let src_id = id.clone();
1201    let src = run_blocking(&st.db, move |db| {
1202        ocserve_store::load_session_wire(db, &src_id)
1203    })
1204    .await
1205    .map_err(|e| ApiError {
1206        status: StatusCode::INTERNAL_SERVER_ERROR,
1207        name: "InternalError",
1208        message: format!("{e:#}"),
1209    })?
1210    .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))?;
1211
1212    let new_id = ocserve_core::ids::ses_id();
1213    let now = now_ms();
1214    let worktree = st.paths["worktree"].as_str().unwrap_or("/").to_string();
1215    // createNext shape (session.ts): fresh id/slug, FORKED title, cost/tokens
1216    // zeroed (fork does not inherit usage), request-ctx directory/path —
1217    // workspaceID/metadata are not modeled by ocserve's schema (pre-existing).
1218    let info = json!({
1219        "id": new_id,
1220        "projectID": "global",
1221        "directory": worktree,
1222        "path": worktree.trim_start_matches('/'),
1223        "slug": slug_for(&new_id),
1224        "title": forked_title(src["title"].as_str().unwrap_or("")),
1225        "version": FREEZE_VERSION,
1226        "time": {"created": now, "updated": now},
1227        "cost": 0,
1228        "tokens": {"input": 0, "output": 0, "reasoning": 0,
1229                   "cache": {"read": 0, "write": 0}},
1230    });
1231    // PERF-10X F1: fork copies the whole source session (32k msgs measured)
1232    // — inline it parked a worker for the entire copy.
1233    let writer = st.writer.clone();
1234    let blobs = st.blobs.clone();
1235    let db = st.db.clone();
1236    let fork_info = info.clone();
1237    let fork_id = id.clone();
1238    let fork_upto = upto.clone();
1239    let stats = run_blocking(&db, move |db| {
1240        let env = ocserve_store::fork::ForkEnv {
1241            writer: &writer,
1242            blobs: Some(&*blobs),
1243            db,
1244        };
1245        ocserve_store::fork_session(
1246            &env,
1247            &fork_info,
1248            &fork_id,
1249            fork_upto.as_deref(),
1250            ocserve_core::ids::msg_id,
1251            ocserve_core::ids::prt_id,
1252        )
1253    })
1254    .await
1255    .map_err(|e| ApiError {
1256        status: StatusCode::INTERNAL_SERVER_ERROR,
1257        name: "InternalError",
1258        message: format!("{e:#}"),
1259    })?;
1260    ocserve_metrics::counter("ocserve_session_fork_total", 1);
1261    tracing::info!(
1262        fork = %new_id,
1263        source = %id,
1264        upto = ?upto,
1265        messages = stats.messages,
1266        parts = stats.parts,
1267        "session forked"
1268    );
1269    // session.created AFTER the copy (D-FORK-1): upstream publishes at
1270    // createNext BEFORE cloning — a client reacting to the event can read a
1271    // partial session; ours fires when the history is complete. Every known
1272    // consumer (dialog-fork, ACP, oc-remote) REST-loads after this response.
1273    st.bus.publish(ocserve_core::event::frame(
1274        st.paths["directory"].as_str().unwrap_or("/"),
1275        "session.created",
1276        json!({"sessionID": new_id, "info": info}),
1277    ));
1278    Ok(Json(info))
1279}
1280
1281/// GET /session/{id}/message — [{info, parts}] (captured wrapper shape).
1282async fn get_messages(
1283    State(st): State<Arc<AppState>>,
1284    axum::extract::Path(id): axum::extract::Path<String>,
1285    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
1286    uri: axum::http::Uri,
1287    headers: axum::http::HeaderMap,
1288) -> Result<impl IntoResponse, HttpError> {
1289    // Freeze rule order (handlers/session.ts:110-117 + observed matrix in
1290    // message_page_contract.json): query-shape errors FIRST (Effect decodes
1291    // the whole query before the handler), then before-rules, then session.
1292    let limit: Option<u64> = match q.get("limit") {
1293        None => None,
1294        Some(raw) if raw.is_empty() => None, // `limit=` observed as full history
1295        Some(raw) => Some(parse_limit(raw).map_err(|message| HttpError::Query { message })?),
1296    };
1297    let before: Option<(String, i64)> = match (q.get("before").map(String::as_str), limit) {
1298        (Some(_), None) => return Err(tagged_bad_request()),
1299        (Some(b), Some(_)) => {
1300            Some(ocserve_store::decode_cursor(b).map_err(|_| tagged_bad_request())?)
1301        }
1302        (None, _) => None,
1303    };
1304    let exists_id = id.clone();
1305    if !run_blocking(&st.db, move |db| {
1306        ocserve_store::session_exists(db, &exists_id)
1307    })
1308    .await
1309    .map_err(|e| ApiError {
1310        status: StatusCode::INTERNAL_SERVER_ERROR,
1311        name: "InternalError",
1312        message: format!("{e:#}"),
1313    })? {
1314        return Err(ApiError::not_found(format!("Session not found: {id}")).into());
1315    }
1316    // limit>0 → tuple-ordered cursor page (upstream MessageV2.page); absent/0
1317    // → full history. Headers ONLY when older messages remain (their rule).
1318    let mut page_n: Option<u64> = None;
1319    let mut page_next: Option<String> = None;
1320    let walk: ocserve_store::MessageWalk = match limit.filter(|n| *n > 0) {
1321        Some(n) => {
1322            let page_id = id.clone();
1323            let before_owned = before.clone();
1324            let (rows, _more, next) = run_blocking(&st.db, move |db| {
1325                ocserve_store::page_messages(
1326                    db,
1327                    &page_id,
1328                    n,
1329                    before_owned.as_ref().map(|(b, t)| (b.as_str(), *t)),
1330                )
1331            })
1332            .await
1333            .map_err(|e| ApiError {
1334                status: StatusCode::INTERNAL_SERVER_ERROR,
1335                name: "InternalError",
1336                message: format!("{e:#}"),
1337            })?;
1338            page_n = Some(n);
1339            page_next = next;
1340            ocserve_store::MessageWalk::Window(rows)
1341        }
1342        None => ocserve_store::MessageWalk::Seq { limit: None },
1343    };
1344    // F9: memoized paged body. Key carries db path (cross-test/process
1345    // isolation: same session id in another tmpdir must not collide) +
1346    // limit + cursor + the live write epoch (checked inside get).
1347    if page_n.is_some() && page_memo_enabled() {
1348        let memo_key = format!("{}\u{1}{id}\u{1}{limit:?}\u{1}{before:?}", st.db.display());
1349        let e0 = ocserve_store::write_epoch();
1350        {
1351            let mut m = page_memo().lock();
1352            if let Some(e) = m.get(&memo_key, e0) {
1353                drop(m);
1354                PAGE_MEMO_HITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1355                return page_response(
1356                    &headers,
1357                    &uri,
1358                    &page_n,
1359                    &page_next,
1360                    axum::body::Body::from(e.body),
1361                )
1362                .map_err(Into::into);
1363            }
1364        }
1365        PAGE_MEMO_MISSES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1366        // assemble the page bounded — oversize aborts the collect and we
1367        // fall through to the original streaming path (walk not consumed:
1368        // the assembler gets a clone).
1369        let walk_asm = walk.clone();
1370        let db_asm = st.db.clone();
1371        let sid_asm = id.clone();
1372        let asm: anyhow::Result<std::result::Result<Vec<u8>, ()>> =
1373            run_blocking(&db_asm, move |db| {
1374                let mut buf: Vec<u8> = Vec::new();
1375                let mut total = 0usize;
1376                let mut first = true;
1377                let r = ocserve_store::for_each_message_json(db, &sid_asm, walk_asm, |chunk| {
1378                    let framed = if first {
1379                        first = false;
1380                        format!("[{chunk}")
1381                    } else {
1382                        format!(",{chunk}")
1383                    };
1384                    if total + framed.len() > PAGE_MEMO_ENTRY_CAP {
1385                        return Err(anyhow::anyhow!("page memo entry cap"));
1386                    }
1387                    total += framed.len();
1388                    buf.extend_from_slice(framed.as_bytes());
1389                    Ok(())
1390                });
1391                match r {
1392                    Ok(()) => {
1393                        // mirror the streaming close exactly: zero chunks
1394                        // => "[]" (a bare "]" would not be valid JSON)
1395                        if first {
1396                            buf.extend_from_slice(b"[]");
1397                        } else {
1398                            buf.push(b']');
1399                        }
1400                        Ok(Ok(buf))
1401                    }
1402                    Err(e) if e.to_string().contains("entry cap") => Ok(Err(())),
1403                    Err(e) => Err(e),
1404                }
1405            })
1406            .await;
1407        if let Ok(Ok(buf)) = asm {
1408            let bytes = bytes::Bytes::from(buf);
1409            let entry = PageEntry {
1410                body: bytes.clone(),
1411                next: page_next.clone(),
1412            };
1413            page_memo().lock().put(memo_key, entry, e0);
1414            return page_response(
1415                &headers,
1416                &uri,
1417                &page_n,
1418                &page_next,
1419                axum::body::Body::from(bytes),
1420            )
1421            .map_err(Into::into);
1422        }
1423        // oversize or assemble error → original streaming path below
1424    }
1425    // STREAMED response: one message per chunk through a bounded channel —
1426    // materializing a 16k-message session as Values OOM-killed the cgroup
1427    // (93MB response ≈ 400MB+ parsed; AGENTS §2.3 violation caught live).
1428    // Peak = channel(4) chunks + the message being built.
1429    let (tx, rx) = tokio::sync::mpsc::channel::<String>(4);
1430    let db = st.db.clone();
1431    let sid = id.clone();
1432    // F4: the tokio blocking pool, not a per-request OS thread — every
1433    // message_page request used to spawn a fresh thread (spawn+1MB VA per
1434    // page under load). The producer only does sync store work + channel
1435    // sends; dropping the JoinHandle keeps it detached exactly like before,
1436    // and the pool caps concurrent producers (bounded-by-construction).
1437    tokio::task::spawn_blocking(move || {
1438        let mut first = true;
1439        let r = ocserve_store::for_each_message_json(&db, &sid, walk, |chunk| {
1440            let framed = if first {
1441                first = false;
1442                format!("[{chunk}")
1443            } else {
1444                format!(",{chunk}")
1445            };
1446            tx.blocking_send(framed)
1447                .map_err(|_| anyhow::anyhow!("client disconnected"))
1448        });
1449        if let Err(e) = r {
1450            tracing::error!("message stream aborted session={sid}: {e:#}");
1451        }
1452        // best-effort close bracket (valid JSON even after a mid-stream error)
1453        let _ = tx.blocking_send(if first {
1454            "[]".to_string()
1455        } else {
1456            "]".to_string()
1457        });
1458    });
1459    let body_stream = futures_util::stream::unfold(rx, |mut rx| async move {
1460        rx.recv().await.map(|chunk| {
1461            (
1462                Ok::<axum::body::Bytes, std::convert::Infallible>(axum::body::Bytes::from(chunk)),
1463                rx,
1464            )
1465        })
1466    });
1467    page_response(
1468        &headers,
1469        &uri,
1470        &page_n,
1471        &page_next,
1472        axum::body::Body::from_stream(body_stream),
1473    )
1474    .map_err(Into::into)
1475}
1476
1477/// PATCH /config + PATCH /global/config — W4 merge (v1 ConfigHttpApi.update:
1478/// deep-merges the payload, marks the instance for disposal, returns the
1479/// ECHOED payload — handlers/config.ts:18-21). Atomic file write (tmp in
1480/// the same dir → rename, .bak kept), then the boot-injected reloader
1481/// swaps derived payloads so the next GET serves fresh config. Divergence:
1482/// ocserve treats global==user config (single file); runtime endpoint
1483/// registries refresh on restart (TESTING §1.6).
1484async fn patch_config(
1485    State(st): State<Arc<AppState>>,
1486    Json(payload): Json<Value>,
1487) -> Result<Json<Value>, ApiError> {
1488    let path = config_write_path();
1489    let mut base: Value = if path.exists() {
1490        let raw = std::fs::read_to_string(&path).map_err(|e| ApiError {
1491            status: StatusCode::INTERNAL_SERVER_ERROR,
1492            name: "InternalError",
1493            message: format!("read {}: {e}", path.display()),
1494        })?;
1495        serde_json::from_str(&raw).map_err(|e| ApiError {
1496            status: StatusCode::INTERNAL_SERVER_ERROR,
1497            name: "InternalError",
1498            message: format!("parse {}: {e}", path.display()),
1499        })?
1500    } else {
1501        json!({})
1502    };
1503    deep_merge(&mut base, payload.clone());
1504    let dir = path.parent().ok_or_else(|| ApiError {
1505        status: StatusCode::INTERNAL_SERVER_ERROR,
1506        name: "InternalError",
1507        message: "config path has no parent".into(),
1508    })?;
1509    std::fs::create_dir_all(dir).map_err(|e| ApiError {
1510        status: StatusCode::INTERNAL_SERVER_ERROR,
1511        name: "InternalError",
1512        message: format!("mkdir {}: {e}", dir.display()),
1513    })?;
1514    let tmp = dir.join(format!(".opencode.json.tmp-{}", std::process::id()));
1515    let data = serde_json::to_vec_pretty(&base).map_err(|e| ApiError {
1516        status: StatusCode::INTERNAL_SERVER_ERROR,
1517        name: "InternalError",
1518        message: format!("serialize: {e}"),
1519    })?;
1520    std::fs::write(&tmp, &data).map_err(|e| ApiError {
1521        status: StatusCode::INTERNAL_SERVER_ERROR,
1522        name: "InternalError",
1523        message: format!("write {}: {e}", tmp.display()),
1524    })?;
1525    if path.exists() {
1526        let _ = std::fs::copy(&path, dir.join(".opencode.json.bak"));
1527    }
1528    std::fs::rename(&tmp, &path).map_err(|e| ApiError {
1529        status: StatusCode::INTERNAL_SERVER_ERROR,
1530        name: "InternalError",
1531        message: format!("rename {}: {e}", path.display()),
1532    })?;
1533    // instance disposal analog: rebuild derived payloads + registry AND
1534    // reconcile MCP (a PATCH that adds a server now connects it — H1
1535    // unifies every reload path through watch::reconcile)
1536    if let Err(e) = watch::reconcile(&st).await {
1537        tracing::warn!("config reload failed (restart to apply): {e:#}");
1538    }
1539    tracing::info!("config patched: {}", path.display());
1540    Ok(Json(payload))
1541}
1542
1543/// POST /session/{id}/summarize — W3 compaction-lite (v1 handlers/session.ts
1544/// summarize: payload {providerID, modelID, auto?} → true). Runs ONE
1545/// transient text-only turn carrying buildPrompt's instruction + serialized
1546/// history (ocserve-core::compact); the summary lands as an ordinary
1547/// assistant message (divergence: no compaction-state/history filtering —
1548/// TESTING §1.6). Synchronous like upstream (`yield* promptSvc.loop`).
1549async fn post_summarize(
1550    State(st): State<Arc<AppState>>,
1551    axum::extract::Path(sid): axum::extract::Path<String>,
1552    Json(payload): Json<Value>,
1553) -> Result<Json<Value>, ApiError> {
1554    let exists_id = sid.clone();
1555    if !run_blocking(&st.db, move |db| {
1556        ocserve_store::session_exists(db, &exists_id)
1557    })
1558    .await
1559    .map_err(|e| ApiError {
1560        status: StatusCode::INTERNAL_SERVER_ERROR,
1561        name: "InternalError",
1562        message: format!("{e:#}"),
1563    })? {
1564        return Err(ApiError::not_found(format!("Session not found: {sid}")));
1565    }
1566    let provider = payload
1567        .get("providerID")
1568        .and_then(|v| v.as_str())
1569        .filter(|s| !s.is_empty());
1570    let model_id = payload
1571        .get("modelID")
1572        .and_then(|v| v.as_str())
1573        .filter(|s| !s.is_empty());
1574    let (Some(provider), Some(model_id)) = (provider, model_id) else {
1575        return Err(ApiError {
1576            status: StatusCode::BAD_REQUEST,
1577            name: "BadRequest",
1578            message: "providerID and modelID are required".into(),
1579        });
1580    };
1581
1582    let release = lock_session(&st, &sid).await?;
1583    let Ok(_guard) = release.arc().try_lock() else {
1584        return Err(session_busy(&sid));
1585    };
1586
1587    // C5 retrofit (M6, COMPACTION.md §3): manual summarize = v1 source
1588    // (handlers/session.ts:273-293 at the FREEZE TAG v1.18.31, verified):
1589    // compactSvc.create(anchor with compaction part) + loop → pending task
1590    // → engine process → summary-exit. The run's outer machinery detects
1591    // the pending anchor — no generation runs (exit via last-message
1592    // summary check). Session-shaped history load ONLY for findLast user's
1593    // agent (v1 line 280).
1594    let history = run_blocking(&st.db, {
1595        let sid = sid.clone();
1596        move |db| ocserve_store::load_messages(db, &sid, None) // allow:load_messages (summarize compaction)
1597    })
1598    .await
1599    .map_err(|e| ApiError {
1600        status: StatusCode::INTERNAL_SERVER_ERROR,
1601        name: "InternalError",
1602        message: format!("{e:#}"),
1603    })?;
1604    let agent = history
1605        .iter()
1606        .rev()
1607        .find(|(info, _)| info["role"] == "user")
1608        .and_then(|(info, _)| info["agent"].as_str())
1609        .unwrap_or("build")
1610        .to_string();
1611    let prompt_payload = json!({
1612        "model": {"providerID": provider, "modelID": model_id},
1613        "agent": agent,
1614        "parts": [],
1615    });
1616    let ctx = build_prompt_context(&st, &prompt_payload, &sid)?;
1617    let writer = st.writer.clone();
1618    ocserve_core::compaction::persist_anchor(&ctx, &writer, &sid, &agent, false, false)
1619        .await
1620        .map_err(|e| ApiError {
1621            status: StatusCode::INTERNAL_SERVER_ERROR,
1622            name: "InternalError",
1623            message: format!("{e:#}"),
1624        })?;
1625    ocserve_core::prompt::run_prompt_with(
1626        &ctx,
1627        &writer,
1628        &sid,
1629        &prompt_payload,
1630        ocserve_core::prompt::RunOpts {
1631            // the ANCHOR is already the newest message — nothing from the
1632            // payload persists; pending→engine→summary-exit short-circuits
1633            // before any generation (persist_user=false keeps chat.message
1634            // silent, P0c invariant)
1635            persist_user: false,
1636            skip_history: false,
1637            prelude: None,
1638            tools_enabled: false,
1639            ..Default::default()
1640        },
1641    )
1642    .await
1643    .map_err(prompt_err)?;
1644    // P0e: session.compacted (magic-context consumes it after a summary)
1645    st.bus.publish(ocserve_core::event::frame(
1646        st.paths["directory"].as_str().unwrap_or("/"),
1647        "session.compacted",
1648        json!({"sessionID": sid}),
1649    ));
1650    Ok(Json(json!(true)))
1651}
1652
1653/// W5 admin bundle — POST /mcp/{name}/connect|disconnect (the client's
1654/// generic `/$action` path is identical to v1 McpPaths.connect/disconnect),
1655/// DELETE /mcp/{name}/auth, PUT/DELETE /auth/{providerID} (ocserve-OWN auth
1656/// overlay — NEVER the legacy auth.json ocserve reads), GET /provider/auth,
1657/// POST /global/dispose.
1658fn mcp_not_found(name: &str) -> HttpError {
1659    HttpError::TaggedData {
1660        status: StatusCode::NOT_FOUND,
1661        tag: "McpServerNotFoundError",
1662        fields: json!({"name": name, "message": format!("MCP server not found: {name}")}),
1663    }
1664}
1665
1666async fn mcp_action(
1667    State(st): State<Arc<AppState>>,
1668    axum::extract::Path((name, action)): axum::extract::Path<(String, String)>,
1669) -> Result<Json<Value>, HttpError> {
1670    let hub = st.mcp.get().cloned().ok_or_else(|| HttpError::TaggedData {
1671        status: StatusCode::SERVICE_UNAVAILABLE,
1672        tag: "McpUnavailable",
1673        fields: json!({"message": "MCP hub not initialized"}),
1674    })?;
1675    let res = match action.as_str() {
1676        "connect" => hub.connect(&name).await,
1677        "disconnect" => hub.disconnect(&name).await,
1678        _ => Err(anyhow::anyhow!("unknown MCP action: {action}")),
1679    };
1680    res.map_err(|e| {
1681        if e.to_string().contains("not found") {
1682            mcp_not_found(&name)
1683        } else {
1684            HttpError::Api(ApiError {
1685                status: StatusCode::INTERNAL_SERVER_ERROR,
1686                name: "InternalError",
1687                message: format!("{e:#}"),
1688            })
1689        }
1690    })?;
1691    Ok(Json(json!(true)))
1692}
1693
1694async fn mcp_auth_remove(
1695    State(st): State<Arc<AppState>>,
1696    axum::extract::Path(name): axum::extract::Path<String>,
1697) -> Result<Json<Value>, HttpError> {
1698    // v1 authRemove: existence check first, then removeAuth → {success:true}
1699    let known = st
1700        .mcp
1701        .get()
1702        .map(|h| {
1703            let m = h.statuses();
1704            m.as_object()
1705                .map(|o| o.contains_key(&name))
1706                .unwrap_or(false)
1707                || h.known(&name)
1708        })
1709        .unwrap_or(false);
1710    if !known {
1711        return Err(mcp_not_found(&name));
1712    }
1713    // no OAuth MCP servers are configured → removing stored auth is a no-op
1714    // success (divergence TESTING §1.6)
1715    Ok(Json(json!({"success": true})))
1716}
1717
1718/// ocserve-owned auth overlay — the file ocserve WRITES; the legacy auth.json
1719/// ocserve READS stays untouched (a bad write there would brick the TUI).
1720fn auth_overlay_path(st: &AppState) -> std::path::PathBuf {
1721    st.db
1722        .parent()
1723        .unwrap_or(std::path::Path::new("."))
1724        .join("auth-overlay.json")
1725}
1726
1727fn write_json_atomic(path: &std::path::Path, value: &Value) -> Result<(), ApiError> {
1728    let dir = path.parent().ok_or_else(|| ApiError {
1729        status: StatusCode::INTERNAL_SERVER_ERROR,
1730        name: "InternalError",
1731        message: "path has no parent".into(),
1732    })?;
1733    std::fs::create_dir_all(dir).map_err(|e| ApiError {
1734        status: StatusCode::INTERNAL_SERVER_ERROR,
1735        name: "InternalError",
1736        message: format!("mkdir: {e}"),
1737    })?;
1738    let tmp = dir.join(format!(".tmp-{}-auth", std::process::id()));
1739    let data = serde_json::to_vec_pretty(value).map_err(|e| ApiError {
1740        status: StatusCode::INTERNAL_SERVER_ERROR,
1741        name: "InternalError",
1742        message: format!("serialize: {e}"),
1743    })?;
1744    std::fs::write(&tmp, data).map_err(|e| ApiError {
1745        status: StatusCode::INTERNAL_SERVER_ERROR,
1746        name: "InternalError",
1747        message: format!("write: {e}"),
1748    })?;
1749    std::fs::rename(&tmp, path).map_err(|e| ApiError {
1750        status: StatusCode::INTERNAL_SERVER_ERROR,
1751        name: "InternalError",
1752        message: format!("rename: {e}"),
1753    })?;
1754    Ok(())
1755}
1756
1757async fn auth_put(
1758    State(st): State<Arc<AppState>>,
1759    axum::extract::Path(pid): axum::extract::Path<String>,
1760    Json(body): Json<Value>,
1761) -> Result<Json<Value>, ApiError> {
1762    if !body.is_object() {
1763        return Err(ApiError {
1764            status: StatusCode::BAD_REQUEST,
1765            name: "BadRequest",
1766            message: "auth payload must be an object".into(),
1767        });
1768    }
1769    let path = auth_overlay_path(&st);
1770    let mut overlay: Value = if path.exists() {
1771        std::fs::read_to_string(&path)
1772            .ok()
1773            .and_then(|r| serde_json::from_str(&r).ok())
1774            .unwrap_or_else(|| json!({}))
1775    } else {
1776        json!({})
1777    };
1778    overlay[&pid] = body; // Auth.Info union stored verbatim (discriminator "type")
1779    write_json_atomic(&path, &overlay)?;
1780    if let Err(e) = watch::reconcile(&st).await {
1781        tracing::warn!("payload reload failed: {e:#}");
1782    }
1783    tracing::info!("auth overlay set provider={pid}");
1784    Ok(Json(json!(true)))
1785}
1786
1787async fn auth_delete(
1788    State(st): State<Arc<AppState>>,
1789    axum::extract::Path(pid): axum::extract::Path<String>,
1790) -> Result<Json<Value>, ApiError> {
1791    let path = auth_overlay_path(&st);
1792    if path.exists() {
1793        let mut overlay: Value = std::fs::read_to_string(&path)
1794            .ok()
1795            .and_then(|r| serde_json::from_str(&r).ok())
1796            .unwrap_or_else(|| json!({}));
1797        if let Some(obj) = overlay.as_object_mut() {
1798            obj.remove(&pid);
1799        }
1800        write_json_atomic(&path, &overlay)?;
1801        if let Err(e) = watch::reconcile(&st).await {
1802            tracing::warn!("payload reload failed: {e:#}");
1803        }
1804    }
1805    tracing::info!("auth overlay remove provider={pid}");
1806    Ok(Json(json!(true)))
1807}
1808
1809/// GET /provider/auth — {providerID: [{type,label}]}. v1 derives these from
1810/// auth HOOKS (provider/auth.ts:131); ocserve serves what ocserve can do —
1811/// api-key methods for every configured provider (divergence TESTING §1.6;
1812/// matches the PUT /auth {type:"api"} client flow).
1813async fn provider_auth_methods(State(st): State<Arc<AppState>>) -> Json<Value> {
1814    let payloads = st.payloads.read();
1815    let mut out = serde_json::Map::new();
1816    // LIVE shape: config_providers.providers is a LIST of {id, ...} — the
1817    // dict assumption returned {} (caught on the W5 live battery)
1818    if let Some(list) = payloads
1819        .config_providers
1820        .get("providers")
1821        .and_then(|p| p.as_array())
1822    {
1823        for e in list {
1824            if let Some(pid) = e.get("id").and_then(|v| v.as_str()) {
1825                out.insert(
1826                    pid.to_string(),
1827                    json!([{"type": "api", "label": "API Key"}]),
1828                );
1829            }
1830        }
1831    }
1832    Json(serde_json::Value::Object(out))
1833}
1834
1835/// POST /global/dispose — v1 (global.ts:84): dispose + emit + true. Ocserve
1836/// analog: durable+live `server.instance.disposed` (EventReducer clears
1837/// client state) + payload reload. Divergence: no instance registry
1838/// (TESTING §1.6).
1839async fn global_dispose(State(st): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
1840    ocserve_store::append_event(&st.writer, None, "server.instance.disposed", &json!({})).map_err(
1841        |e| ApiError {
1842            status: StatusCode::INTERNAL_SERVER_ERROR,
1843            name: "InternalError",
1844            message: format!("{e:#}"),
1845        },
1846    )?;
1847    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
1848    st.bus.publish(ocserve_core::event::frame(
1849        &dir,
1850        "server.instance.disposed",
1851        json!({}),
1852    ));
1853    if let Err(e) = watch::reconcile(&st).await {
1854        tracing::warn!("payload reload failed: {e:#}");
1855    }
1856    tracing::info!("global dispose: emitted + payloads reloaded");
1857    Ok(Json(json!(true)))
1858}
1859
1860/// POST /session/search — W1 content search over part payloads (contract
1861/// block PLAN §17). ≥3 chars → trigram FTS substring MATCH; shorter → LIKE
1862/// fallback (same projection, one query either way). Inline AND blobbed
1863/// parts covered (projection stores uncompressed text).
1864async fn search_messages(
1865    State(st): State<Arc<AppState>>,
1866    Json(body): Json<Value>,
1867) -> Result<Json<Value>, ApiError> {
1868    let query = body
1869        .get("query")
1870        .and_then(|v| v.as_str())
1871        .unwrap_or("")
1872        .trim()
1873        .to_string();
1874    if query.is_empty() || query.chars().count() > 512 {
1875        return Err(ApiError {
1876            status: StatusCode::BAD_REQUEST,
1877            name: "BadRequest",
1878            message: "query must be a non-empty string of at most 512 characters".into(),
1879        });
1880    }
1881    let scope = body
1882        .get("sessionID")
1883        .and_then(|v| v.as_str())
1884        .map(str::to_string);
1885    let limit = body
1886        .get("limit")
1887        .and_then(|v| v.as_u64())
1888        .unwrap_or(50)
1889        .clamp(1, 200) as u32;
1890    let offset = body.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
1891    let needle = query.clone();
1892    let (hits, truncated) = run_blocking(&st.db, move |db| {
1893        ocserve_store::search_parts(db, &needle, scope.as_deref(), limit, offset)
1894    })
1895    .await
1896    .map_err(|e| ApiError {
1897        status: StatusCode::INTERNAL_SERVER_ERROR,
1898        name: "InternalError",
1899        message: format!("{e:#}"),
1900    })?;
1901    let out = json!({
1902        "hits": hits
1903            .iter()
1904            .map(|h| json!({
1905                "sessionID": h.session_id,
1906                "messageID": h.message_id,
1907                "partID": h.part_id,
1908                "role": h.role,
1909                "time": h.time,
1910                "snippet": ocserve_store::snippet(&h.text, &query),
1911            }))
1912            .collect::<Vec<_>>(),
1913        "truncated": truncated,
1914    });
1915    Ok(Json(out))
1916}
1917
1918/// POST /session/{id}/message — sync prompt (blocks until stream completes,
1919/// returns {info, parts}; captured in testdata/m2/prompt_response.json).
1920/// v1 currentModel(sessionID): payload.model → session's stored model
1921/// (updated at prompt time) → configured default.
1922fn resolve_model(st: &Arc<AppState>, payload: &Value, session_id: &str) -> (String, String) {
1923    if let (Some(p), Some(m)) = (
1924        payload
1925            .pointer("/model/providerID")
1926            .and_then(|v| v.as_str()),
1927        payload.pointer("/model/modelID").and_then(|v| v.as_str()),
1928    ) {
1929        return (p.to_string(), m.to_string());
1930    }
1931    if let Ok(conn) = ocserve_store::pragma::open_reader(&st.db) {
1932        let stored: Option<String> = conn
1933            .query_row(
1934                "SELECT model FROM session WHERE id = ?1",
1935                [session_id],
1936                |r| r.get(0),
1937            )
1938            .ok()
1939            .flatten();
1940        if let Some(raw) = stored
1941            && let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
1942        {
1943            let p = v.get("providerID").and_then(|x| x.as_str()).unwrap_or("");
1944            let m = v.get("id").and_then(|x| x.as_str()).unwrap_or("");
1945            if !p.is_empty() && !m.is_empty() {
1946                return (p.to_string(), m.to_string());
1947            }
1948        }
1949    }
1950    st.llm.read().default_model.clone()
1951}
1952
1953/// Resolve agent/model/system/endpoint/rules into a runnable prompt context.
1954/// Shared by POST /message (sync) and POST /prompt_async (backgrounded).
1955fn build_prompt_context(
1956    st: &Arc<AppState>,
1957    payload: &Value,
1958    session_id: &str,
1959) -> Result<ocserve_core::prompt::PromptContext, ApiError> {
1960    let agent = payload
1961        .get("agent")
1962        .and_then(|a| a.as_str())
1963        .filter(|a| !a.is_empty())
1964        .map(String::from)
1965        .unwrap_or_else(|| st.llm.read().default_agent.clone());
1966    let system = st
1967        .llm
1968        .read()
1969        .systems
1970        .get(&agent)
1971        .cloned()
1972        .unwrap_or_else(|| crate::BUILD_SYSTEM_BLURB.to_string());
1973    let (pid, mid) = resolve_model(st, payload, session_id);
1974    let (base_url, api_key) =
1975        st.llm
1976            .read()
1977            .endpoints
1978            .get(&pid)
1979            .cloned()
1980            .ok_or_else(|| ApiError {
1981                status: StatusCode::BAD_REQUEST,
1982                name: "BadRequest",
1983                message: format!("no endpoint configured for provider {pid}"),
1984            })?;
1985    let pricing = st
1986        .llm
1987        .read()
1988        .pricing
1989        .get(&(pid.clone(), mid.clone()))
1990        .copied();
1991    // agent permission rules (v1 wire shape → evaluator)
1992    let rules: Vec<ocserve_tools::Rule> = st
1993        .payloads
1994        .read()
1995        .agent
1996        .iter()
1997        .find(|a| a["name"].as_str() == Some(agent.as_str()))
1998        .and_then(|a| a["permission"].as_array())
1999        .map(|arr| {
2000            arr.iter()
2001                .filter_map(|r| {
2002                    Some(ocserve_tools::Rule {
2003                        permission: r["permission"].as_str()?.to_string(),
2004                        pattern: r["pattern"].as_str()?.to_string(),
2005                        action: r["action"].as_str()?.to_string(),
2006                    })
2007                })
2008                .collect()
2009        })
2010        .unwrap_or_default();
2011    let model_limit = st
2012        .llm
2013        .read()
2014        .limits
2015        .get(&(pid.clone(), mid.clone()))
2016        .cloned()
2017        .unwrap_or(serde_json::Value::Null);
2018    let compaction = {
2019        let payloads = st.payloads.read();
2020        ocserve_core::compact::CompactionCfg::from_value(Some(&payloads.compaction))
2021    };
2022    let compaction_system = st
2023        .llm
2024        .read()
2025        .systems
2026        .get("compaction")
2027        .cloned()
2028        .unwrap_or_else(|| system.clone());
2029    let ctx = ocserve_core::prompt::PromptContext {
2030        db: st.db.clone(),
2031        blobs: st.blobs.clone(),
2032        bus: st.bus.clone(),
2033        directory: st.paths["directory"].as_str().unwrap_or("/").to_string(),
2034        agent: agent.clone(),
2035        system,
2036        endpoint: ocserve_core::prompt::LlmEndpoint {
2037            base_url,
2038            api_key,
2039            pricing,
2040        },
2041        model_id: mid,
2042        provider_id: pid,
2043        rules,
2044        gate: st.gate.clone(),
2045        mcp: st.mcp.get().cloned(),
2046        plugins: st.plugins.get().cloned(),
2047        questions: st.question_gate.clone(),
2048        model_limit,
2049        max_rounds: st.max_rounds,
2050        cost_ceiling: st.cost_ceiling,
2051        compaction,
2052        compaction_system,
2053    };
2054    Ok(ctx)
2055}
2056
2057/// Per-session prompt lock (insert-only while active; bounded by in-flight).
2058/// JS-Number-style rendering for Effect query error messages (JS prints
2059/// ≥1e21 in exponent form WITH a plus sign: "1e+21"; Rust's Display does
2060/// not — mirror observed upstream bytes).
2061fn fmt_js(v: f64) -> String {
2062    if v.is_finite() && v.abs() >= 1e21 {
2063        let raw = format!("{v:e}"); // e.g. 1e21 / -1.5e21
2064        if let Some(pos) = raw.find('e') {
2065            return format!("{}e+{}", &raw[..pos], &raw[pos + 1..]);
2066        }
2067        raw
2068    } else {
2069        format!("{v}")
2070    }
2071}
2072
2073/// `?limit=` validation — freeze-probed matrix (message_page_contract.json):
2074/// absent/empty/0 → full history; integers (incl. "+5", "1e3") accepted up
2075/// to 2^53-1; negatives → range message; non-integers → integer message
2076/// (junk → "NaN"); mirror Effect's NumberFromString + Int + nonnegative
2077/// checks in observed order (integer-ness first, then ≥0). Rust's f64 parse
2078/// diverges from JS Number only on hex/whitespace forms — clients never
2079/// send those (documented, corpus note).
2080fn parse_limit(raw: &str) -> Result<u64, String> {
2081    const INT_MSG: &str = "Expected an integer, got {}\n  at [\"limit\"]";
2082    match raw.parse::<f64>() {
2083        Err(_) => Err(INT_MSG.replace("{}", "NaN")),
2084        Ok(v) if v.is_nan() => Err(INT_MSG.replace("{}", "NaN")),
2085        Ok(v) if v.is_infinite() => {
2086            Err(INT_MSG.replace("{}", if v > 0.0 { "Infinity" } else { "-Infinity" }))
2087        }
2088        Ok(v) if v.trunc() != v => Err(INT_MSG.replace("{}", &fmt_js(v))),
2089        Ok(v) if v.abs() > 9007199254740991.0 => Err(INT_MSG.replace("{}", &fmt_js(v))),
2090        Ok(v) if v < 0.0 => Err(format!(
2091            "Expected a value greater than or equal to 0, got {}\n  at [\"limit\"]",
2092            fmt_js(v)
2093        )),
2094        Ok(v) => Ok(v as u64),
2095    }
2096}
2097
2098fn tagged_bad_request() -> HttpError {
2099    HttpError::Tagged {
2100        status: StatusCode::BAD_REQUEST,
2101        tag: "BadRequest",
2102    }
2103}
2104
2105/// Abort-safe cleanup (antagonism A2): remove the prompt_locks entry when the
2106/// holder's task ends ANY way — normal completion, `?abort`, task panic,
2107/// delete_session — because post-run cleanup code never executes on abort
2108/// (entries only self-heal on the next prompt). Holds its own Arc clone so
2109/// the strong_count ≤ 2 check is exact during Drop. Concurrent holders or
2110/// waiters push the count above 2 → skip (entry stays — it's live).
2111struct LockRelease {
2112    locks: std::sync::Arc<
2113        parking_lot::Mutex<
2114            std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
2115        >,
2116    >,
2117    sid: String,
2118    lock: std::sync::Arc<tokio::sync::Mutex<()>>,
2119}
2120
2121impl LockRelease {
2122    fn arc(&self) -> &std::sync::Arc<tokio::sync::Mutex<()>> {
2123        &self.lock
2124    }
2125}
2126
2127impl Drop for LockRelease {
2128    fn drop(&mut self) {
2129        // EXACT reference accounting (the first design had callers holding a
2130        // separate Arc clone → drop-order dependent skip, caught by the abort
2131        // test): ALL locking goes through arc() so the only refs are the map
2132        // entry + this release. remove iff count ≤ 2 (map + self).
2133        let mut map = self.locks.lock();
2134        if let Some(arc) = map.get(&self.sid)
2135            && std::sync::Arc::strong_count(arc) <= 2
2136        {
2137            map.remove(&self.sid);
2138        }
2139    }
2140}
2141
2142/// Session prompt lock + abort-safe cleanup in ONE object: callers lock via
2143/// `release.arc()` (never a second Arc clone) so Drop's count is exact, and
2144/// dropping release — normal end, `?`, panic, or task abort — evicts the map
2145/// entry. Concurrent holders/waiters each hold their own release (count > 2)
2146/// so live entries are never evicted.
2147async fn lock_session(st: &Arc<AppState>, sid: &str) -> Result<LockRelease, ApiError> {
2148    let lock = {
2149        let mut map = st.prompt_locks.lock();
2150        if map.len() >= 64 && !map.contains_key(sid) {
2151            return Err(ApiError {
2152                status: StatusCode::SERVICE_UNAVAILABLE,
2153                name: "ServiceUnavailableError",
2154                message: "too many concurrent prompts".into(),
2155            });
2156        }
2157        map.entry(sid.to_string())
2158            .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
2159            .clone()
2160    };
2161    Ok(LockRelease {
2162        locks: std::sync::Arc::clone(&st.prompt_locks),
2163        sid: sid.to_string(),
2164        lock,
2165    })
2166}
2167
2168fn prompt_err(e: anyhow::Error) -> ApiError {
2169    let msg = format!("{e:#}");
2170    if msg.starts_with("Session not found") {
2171        ApiError::not_found(msg)
2172    } else {
2173        ApiError {
2174            status: StatusCode::INTERNAL_SERVER_ERROR,
2175            name: "InternalError",
2176            message: msg,
2177        }
2178    }
2179}
2180
2181async fn post_message(
2182    State(st): State<Arc<AppState>>,
2183    axum::extract::Path(id): axum::extract::Path<String>,
2184    Json(payload): Json<Value>,
2185) -> Result<impl IntoResponse, ApiError> {
2186    let ctx = build_prompt_context(&st, &payload, &id)?;
2187    let _release = lock_session(&st, &id).await?;
2188    let _guard = _release.arc().lock().await;
2189    let writer = st.writer.clone();
2190    let result = ocserve_core::prompt::run_prompt(&ctx, &writer, &id, &payload)
2191        .await
2192        .map_err(prompt_err)?;
2193    drop(_guard);
2194    let (info, parts) = result;
2195    Ok(Json(json!({"info": info, "parts": parts})))
2196}
2197
2198/// POST /session/{id}/prompt_async — v1 `session.prompt_async` (oc-remote's
2199/// send path): same PromptPayload as /message, **204 immediately**, prompt
2200/// runs in the background; results arrive on SSE. Errors: NotFound (bad
2201/// session) / BadRequest before spawn; background failures are logged
2202/// (upstream handlers/session.ts logs "prompt_async failed", no reply body).
2203async fn post_prompt_async(
2204    State(st): State<Arc<AppState>>,
2205    axum::extract::Path(id): axum::extract::Path<String>,
2206    Json(payload): Json<Value>,
2207) -> Result<StatusCode, ApiError> {
2208    let exists_id = id.clone();
2209    if !run_blocking(&st.db, move |db| {
2210        ocserve_store::session_exists(db, &exists_id)
2211    })
2212    .await
2213    .map_err(|e| ApiError {
2214        status: StatusCode::INTERNAL_SERVER_ERROR,
2215        name: "InternalError",
2216        message: format!("{e:#}"),
2217    })? {
2218        return Err(ApiError::not_found(format!("Session not found: {id}")));
2219    }
2220    let ctx = build_prompt_context(&st, &payload, &id)?;
2221    // release MOVES INTO the task: the handler returns 204 immediately, so a
2222    // handler-scoped guard would drop (and evict the lock entry) while the
2223    // prompt is still running (antagonism A2 — ownership, not just RAII).
2224    let release = lock_session(&st, &id).await?;
2225    let writer = st.writer.clone();
2226    let sid = id.clone();
2227    let generation = st
2228        .prompt_gen
2229        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2230    let tasks = st.prompt_tasks.clone();
2231    let tasks_insert = st.prompt_tasks.clone();
2232    let handle = tokio::spawn(async move {
2233        let _release = release;
2234        let guard = _release.arc().lock().await;
2235        match ocserve_core::prompt::run_prompt(&ctx, &writer, &sid, &payload).await {
2236            Ok(_) => {}
2237            Err(e) => tracing::error!("prompt_async failed session={sid}: {e:#}"),
2238        }
2239        drop(guard);
2240        let mut t = tasks.lock();
2241        if let Some((g, _)) = t.get(&sid)
2242            && *g == generation
2243        {
2244            t.remove(&sid);
2245        }
2246    });
2247    tasks_insert.lock().insert(id.clone(), (generation, handle));
2248    Ok(StatusCode::NO_CONTENT)
2249}
2250
2251// ---- oc-remote contract family (Batch 1: session/message/part) ----
2252
2253async fn get_children(
2254    State(st): State<Arc<AppState>>,
2255    axum::extract::Path(id): axum::extract::Path<String>,
2256) -> Result<Json<Value>, ApiError> {
2257    run_blocking(&st.db, move |db| ocserve_store::load_children(db, &id))
2258        .await
2259        .map(|v| Json(Value::Array(v)))
2260        .map_err(|e| ApiError {
2261            status: StatusCode::INTERNAL_SERVER_ERROR,
2262            name: "InternalError",
2263            message: format!("{e:#}"),
2264        })
2265}
2266
2267async fn get_todos(
2268    State(st): State<Arc<AppState>>,
2269    axum::extract::Path(id): axum::extract::Path<String>,
2270) -> Result<Json<Value>, ApiError> {
2271    run_blocking(&st.db, move |db| ocserve_store::load_todos(db, &id))
2272        .await
2273        .map(|v| Json(Value::Array(v)))
2274        .map_err(|e| ApiError {
2275            status: StatusCode::INTERNAL_SERVER_ERROR,
2276            name: "InternalError",
2277            message: format!("{e:#}"),
2278        })
2279}
2280
2281/// PATCH /session/{id} — oc-remote rename sends {title} only. Other
2282/// UpdatePayload fields (metadata/permission/time.archived) are not stored
2283/// by ocserve — accepted and ignored (documented divergence).
2284async fn patch_session(
2285    State(st): State<Arc<AppState>>,
2286    axum::extract::Path(id): axum::extract::Path<String>,
2287    Json(payload): Json<Value>,
2288) -> Result<Json<Value>, ApiError> {
2289    let exists_id = id.clone();
2290    if !run_blocking(&st.db, move |db| {
2291        ocserve_store::session_exists(db, &exists_id)
2292    })
2293    .await
2294    .map_err(|e| ApiError {
2295        status: StatusCode::INTERNAL_SERVER_ERROR,
2296        name: "InternalError",
2297        message: format!("{e:#}"),
2298    })? {
2299        return Err(ApiError::not_found(format!("Session not found: {id}")));
2300    }
2301    let mut dirty = false;
2302    if let Some(title) = payload.get("title").and_then(|t| t.as_str()) {
2303        ocserve_store::update_session_title(&st.writer, &id, title).map_err(|e| ApiError {
2304            status: StatusCode::INTERNAL_SERVER_ERROR,
2305            name: "InternalError",
2306            message: format!("{e:#}"),
2307        })?;
2308        dirty = true;
2309    }
2310    // K-MODEL-STATE: freeze Patch semantics for model/agent (oc-remote only
2311    // calls title; TUI/other clients patch model+agent). permission is
2312    // deliberately NOT patchable (no caller in evidence — ocserve's internal
2313    // key format differs from upstream's ruleset; divergence noted K row).
2314    if payload.get("model").is_some() {
2315        let raw = payload.get("model").cloned().unwrap_or(Value::Null);
2316        let model_json: Value = match raw {
2317            Value::Null => Value::Null, // clear → SQL NULL → resolve falls to default
2318            Value::Object(ref m) => {
2319                let ok = m
2320                    .get("id")
2321                    .and_then(|v| v.as_str())
2322                    .is_some_and(|v| !v.is_empty())
2323                    && m.get("providerID")
2324                        .and_then(|v| v.as_str())
2325                        .is_some_and(|v| !v.is_empty());
2326                if !ok {
2327                    return Err(ApiError {
2328                        status: StatusCode::BAD_REQUEST,
2329                        name: "BadRequest",
2330                        message: "model must be {id, providerID, variant?} or null".into(),
2331                    });
2332                }
2333                Value::String(
2334                    serde_json::to_string(&json!({
2335                        "id": m["id"],
2336                        "providerID": m["providerID"],
2337                        "variant": m.get("variant").cloned().unwrap_or(json!("default")),
2338                    }))
2339                    .map_err(|e| ApiError {
2340                        status: StatusCode::INTERNAL_SERVER_ERROR,
2341                        name: "InternalError",
2342                        message: format!("{e:#}"),
2343                    })?,
2344                )
2345            }
2346            _ => {
2347                return Err(ApiError {
2348                    status: StatusCode::BAD_REQUEST,
2349                    name: "BadRequest",
2350                    message: "model must be {id, providerID, variant?} or null".into(),
2351                });
2352            }
2353        };
2354        st.writer
2355            .write(vec![ocserve_store::WriteOp::Sql {
2356                sql: "UPDATE session SET model = ?2 WHERE id = ?1".into(),
2357                params: vec![id.clone().into(), model_json],
2358            }])
2359            .map_err(|e| ApiError {
2360                status: StatusCode::INTERNAL_SERVER_ERROR,
2361                name: "InternalError",
2362                message: format!("{e:#}"),
2363            })?;
2364        dirty = true;
2365    }
2366    if payload.get("agent").is_some() {
2367        let agent = match payload.get("agent") {
2368            Some(Value::Null) => String::new(),
2369            Some(Value::String(a)) if !a.is_empty() => a.clone(),
2370            _ => {
2371                return Err(ApiError {
2372                    status: StatusCode::BAD_REQUEST,
2373                    name: "BadRequest",
2374                    message: "agent must be a non-empty string or null".into(),
2375                });
2376            }
2377        };
2378        st.writer
2379            .write(vec![ocserve_store::WriteOp::Sql {
2380                sql: "UPDATE session SET agent = ?2 WHERE id = ?1".into(),
2381                params: vec![id.clone().into(), agent.into()],
2382            }])
2383            .map_err(|e| ApiError {
2384                status: StatusCode::INTERNAL_SERVER_ERROR,
2385                name: "InternalError",
2386                message: format!("{e:#}"),
2387            })?;
2388        dirty = true;
2389    }
2390    let info_id = id.clone();
2391    let info = run_blocking(&st.db, move |db| {
2392        ocserve_store::load_session_wire(db, &info_id)
2393    })
2394    .await
2395    .map_err(|e| ApiError {
2396        status: StatusCode::INTERNAL_SERVER_ERROR,
2397        name: "InternalError",
2398        message: format!("{e:#}"),
2399    })?
2400    .ok_or_else(|| ApiError::not_found(format!("Session not found: {id}")))?;
2401    if dirty {
2402        // partial session.updated (prompt's own partial shape) so other
2403        // clients re-sort/re-render without a refetch
2404        st.bus.publish(ocserve_core::event::frame(
2405            st.paths["directory"].as_str().unwrap_or("/"),
2406            "session.updated",
2407            json!({"sessionID": id, "info": {"id": id, "title": info["title"],
2408                                              "model": info["model"], "agent": info["agent"]}}),
2409        ));
2410    }
2411    Ok(Json(info))
2412}
2413
2414/// DELETE /session/{id} → true (v1 Schema.Boolean). Aborts any in-flight
2415/// async prompt first (it would write into a deleted session otherwise).
2416async fn delete_session_route(
2417    State(st): State<Arc<AppState>>,
2418    axum::extract::Path(id): axum::extract::Path<String>,
2419) -> Result<Json<Value>, ApiError> {
2420    if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
2421        handle.abort();
2422    }
2423    let found = ocserve_store::delete_session(&st.writer, &st.db, &id).map_err(|e| ApiError {
2424        status: StatusCode::INTERNAL_SERVER_ERROR,
2425        name: "InternalError",
2426        message: format!("{e:#}"),
2427    })?;
2428    if !found {
2429        return Err(ApiError::not_found(format!("Session not found: {id}")));
2430    }
2431    // P0e: magic-context consumes session.deleted (live publish — the
2432    // durable rows cascade away with the session).
2433    st.bus.publish(ocserve_core::event::frame(
2434        st.paths["directory"].as_str().unwrap_or("/"),
2435        "session.deleted",
2436        json!({"sessionID": id}),
2437    ));
2438    Ok(Json(json!(true)))
2439}
2440
2441/// POST /session/{id}/abort → true (idempotent: no active task = still true).
2442async fn post_abort(
2443    State(st): State<Arc<AppState>>,
2444    axum::extract::Path(id): axum::extract::Path<String>,
2445) -> Result<Json<Value>, ApiError> {
2446    if let Some((_, handle)) = st.prompt_tasks.lock().remove(&id) {
2447        handle.abort();
2448        tracing::info!("abort: cancelled prompt task for {id}");
2449    }
2450    Ok(Json(json!(true)))
2451}
2452
2453async fn delete_message_route(
2454    State(st): State<Arc<AppState>>,
2455    axum::extract::Path((sid, mid)): axum::extract::Path<(String, String)>,
2456) -> Result<Json<Value>, ApiError> {
2457    let found =
2458        ocserve_store::delete_message(&st.writer, &st.db, &sid, &mid).map_err(|e| ApiError {
2459            status: StatusCode::INTERNAL_SERVER_ERROR,
2460            name: "InternalError",
2461            message: format!("{e:#}"),
2462        })?;
2463    if !found {
2464        return Err(ApiError::not_found(format!("Message not found: {mid}")));
2465    }
2466    // P0e: magic-context consumes message.removed (live publish — durable
2467    // rows cascade away with the message).
2468    st.bus.publish(ocserve_core::event::frame(
2469        st.paths["directory"].as_str().unwrap_or("/"),
2470        "message.removed",
2471        json!({"sessionID": sid, "messageID": mid}),
2472    ));
2473    Ok(Json(json!(true)))
2474}
2475
2476async fn delete_part_route(
2477    State(st): State<Arc<AppState>>,
2478    axum::extract::Path((_sid, mid, pid)): axum::extract::Path<(String, String, String)>,
2479) -> Result<Json<Value>, ApiError> {
2480    ocserve_store::delete_part(&st.writer, &st.db, &mid, &pid)
2481        .map_err(|e| ApiError {
2482            status: StatusCode::INTERNAL_SERVER_ERROR,
2483            name: "InternalError",
2484            message: format!("{e:#}"),
2485        })?
2486        .then_some(Json(json!(true)))
2487        .ok_or_else(|| ApiError::not_found(format!("Part not found: {pid}")))
2488}
2489
2490/// PATCH .../part/{pid} — replace part JSON (native sessionID/messageID keys;
2491/// v1 verifies all three ids match the path — oc-remote pre-validates too).
2492async fn patch_part_route(
2493    State(st): State<Arc<AppState>>,
2494    axum::extract::Path((sid, mid, pid)): axum::extract::Path<(String, String, String)>,
2495    Json(part): Json<Value>,
2496) -> Result<Json<Value>, ApiError> {
2497    let body_sid = part.get("sessionID").and_then(|v| v.as_str()).unwrap_or("");
2498    let body_mid = part.get("messageID").and_then(|v| v.as_str()).unwrap_or("");
2499    let body_pid = part.get("id").and_then(|v| v.as_str()).unwrap_or("");
2500    if body_sid != sid || body_mid != mid || body_pid != pid {
2501        return Err(ApiError {
2502            status: StatusCode::BAD_REQUEST,
2503            name: "BadRequest",
2504            message: "part id/sessionID/messageID must match the path".into(),
2505        });
2506    }
2507    ocserve_store::update_part(&st.writer, &st.blobs, &sid, &mid, &pid, &part)
2508        .map_err(|e| ApiError {
2509            status: StatusCode::INTERNAL_SERVER_ERROR,
2510            name: "InternalError",
2511            message: format!("{e:#}"),
2512        })?
2513        .then_some(Json(part))
2514        .ok_or_else(|| ApiError::not_found(format!("Part not found: {pid}")))
2515}
2516
2517// ---- oc-remote contract family (Batch 2: file/find/question) ----
2518
2519fn scope_path(base: &std::path::Path, raw: &str) -> Result<std::path::PathBuf, ApiError> {
2520    let joined = if raw.is_empty() || raw == "." {
2521        base.to_path_buf()
2522    } else {
2523        let p = std::path::Path::new(raw);
2524        if p.is_absolute() {
2525            p.to_path_buf()
2526        } else {
2527            base.join(p)
2528        }
2529    };
2530    let canon_base = std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
2531    let canon = std::fs::canonicalize(&joined).unwrap_or(joined.clone());
2532    if !canon.starts_with(&canon_base) {
2533        return Err(ApiError {
2534            status: StatusCode::BAD_REQUEST,
2535            name: "BadRequest",
2536            message: format!("path escapes the session directory: {raw}"),
2537        });
2538    }
2539    Ok(canon)
2540}
2541
2542/// GET /file?path= — directory listing (FileNode list, oc-remote file browser).
2543async fn list_directory(
2544    State(st): State<Arc<AppState>>,
2545    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2546) -> Result<Json<Value>, ApiError> {
2547    let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
2548    let raw = q.get("path").map(String::as_str).unwrap_or(".");
2549    let dir = scope_path(base, raw)?;
2550    // M3 (same class as find_files): `read_dir` + per-entry `metadata()` is
2551    // blocking syscalls, inline on a tokio worker. A directory with thousands
2552    // of entries would park the worker for the whole loop.
2553    let dir_for_worker = dir.clone();
2554    let raw_owned = raw.to_string();
2555    let rd = tokio::task::spawn_blocking(move || std::fs::read_dir(&dir_for_worker))
2556        .await
2557        .map_err(|e| ApiError {
2558            status: StatusCode::INTERNAL_SERVER_ERROR,
2559            name: "InternalServerError",
2560            message: format!("list worker: {e}"),
2561        })?
2562        .map_err(|e| ApiError {
2563            status: StatusCode::BAD_REQUEST,
2564            name: "BadRequest",
2565            message: format!("list {}: {e}", dir.display()),
2566        })?;
2567    let mut nodes: Vec<Value> = Vec::new();
2568    for entry in rd.flatten() {
2569        let name = entry.file_name().to_string_lossy().to_string();
2570        let path = entry.path();
2571        let meta = entry.metadata().ok();
2572        let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
2573        let rel = if raw_owned.is_empty() || raw_owned == "." {
2574            name.clone()
2575        } else {
2576            format!("{}/{}", raw_owned.trim_end_matches('/'), name)
2577        };
2578        let ignored =
2579            name.starts_with('.') || name == "node_modules" || name == "target" || name == ".git";
2580        nodes.push(json!({
2581            "name": name,
2582            "path": rel,
2583            "type": if is_dir { "directory" } else { "file" },
2584            "absolute": path.to_string_lossy(),
2585            "ignored": ignored,
2586            "size": meta.as_ref().filter(|m| m.is_file()).map(|m| m.len()),
2587            "modified": meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
2588                t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as i64)
2589            }),
2590        }));
2591    }
2592    // directories first, then name (browser expectations), bounded
2593    nodes.sort_by(|a, b| {
2594        let da = a["type"] == "directory";
2595        let db = b["type"] == "directory";
2596        db.cmp(&da)
2597            .then_with(|| a["name"].as_str().cmp(&b["name"].as_str()))
2598    });
2599    nodes.truncate(5000);
2600    Ok(Json(Value::Array(nodes)))
2601}
2602
2603/// GET /file/content?path= — {type: text|binary, content, encoding}.
2604async fn read_file_content(
2605    State(st): State<Arc<AppState>>,
2606    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2607) -> Result<Json<Value>, ApiError> {
2608    let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
2609    let raw = q.get("path").map(String::as_str).ok_or_else(|| ApiError {
2610        status: StatusCode::BAD_REQUEST,
2611        name: "BadRequest",
2612        message: "missing path".into(),
2613    })?;
2614    let path = scope_path(base, raw)?;
2615    let bytes = std::fs::read(&path).map_err(|e| ApiError {
2616        status: StatusCode::BAD_REQUEST,
2617        name: "BadRequest",
2618        message: format!("read {}: {e}", path.display()),
2619    })?;
2620    // bound: MEMORY §6 parse cap — truncate at 1MB
2621    let truncated = bytes.len() > 1024 * 1024;
2622    let bytes = if truncated {
2623        &bytes[..1024 * 1024]
2624    } else {
2625        &bytes[..]
2626    };
2627    let (kind, content) = match std::str::from_utf8(bytes) {
2628        Ok(text) => ("text", text.to_string()),
2629        Err(_) => ("binary", String::from_utf8_lossy(bytes).into_owned()),
2630    };
2631    Ok(Json(json!({
2632        "type": kind,
2633        "content": content,
2634        "encoding": "utf-8",
2635    })))
2636}
2637
2638/// GET /find/file?query=&type=&limit= — file-name/path search → List<String>
2639/// (bounded walk; case-insensitive substring, glob when the query has *).
2640async fn find_files(
2641    State(st): State<Arc<AppState>>,
2642    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2643) -> Result<Json<Value>, ApiError> {
2644    let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/")).to_path_buf();
2645    let query = q
2646        .get("query")
2647        .map(String::as_str)
2648        .unwrap_or("")
2649        .to_lowercase();
2650    if query.is_empty() {
2651        return Ok(Json(json!([])));
2652    }
2653    let want_type = q.get("type").map(String::as_str).unwrap_or("").to_string();
2654    let limit: usize = q
2655        .get("limit")
2656        .and_then(|l| l.parse().ok())
2657        .unwrap_or(50)
2658        .clamp(1, 200);
2659    let glob_mode = query.contains('*') || query.contains('?');
2660    // M3: the walk is a blocking filesystem scan (bounded at 20,000 dirs, but
2661    // a big worktree still means thousands of `read_dir` syscalls per
2662    // request). It ran INLINE on a tokio worker — the same convoy class as the
2663    // pre-F1 SQLite work, which is exactly what `run_blocking` exists for.
2664    // The load harness never caught this because its LIST_PATH is an empty
2665    // /tmp dir; on a real worktree it would park a worker.
2666    let out = tokio::task::spawn_blocking(move || {
2667        walk_files(&base, &query, &want_type, limit, glob_mode)
2668    })
2669    .await
2670    .map_err(|e| ApiError {
2671        status: StatusCode::INTERNAL_SERVER_ERROR,
2672        name: "InternalServerError",
2673        message: format!("find_files worker: {e}"),
2674    })?
2675    .map_err(|e| ApiError {
2676        status: StatusCode::BAD_REQUEST,
2677        name: "BadRequest",
2678        message: format!("find_files: {e:#}"),
2679    })?;
2680    Ok(Json(Value::Array(
2681        out.into_iter().map(Value::String).collect(),
2682    )))
2683}
2684
2685/// Thread the last `walk_files` executed on (M3 test seam).
2686///
2687/// Global, not `thread_local!`: the walk runs on a *blocking-pool* thread, so
2688/// a thread-local record would be written on one thread and read on another —
2689/// the first version of this seam was invisible for exactly that reason (caught
2690/// by the test asserting on it).
2691static WALK_THREAD: std::sync::Mutex<Option<std::thread::ThreadId>> = std::sync::Mutex::new(None);
2692
2693/// Blocking body of `GET /find/file` — a bounded, single-threaded directory
2694/// walk. Existing bounds kept exactly (20,000 dirs visited, stop once the
2695/// result set reaches 4×limit); only the *thread* it runs on changed.
2696fn walk_files(
2697    base: &std::path::Path,
2698    query: &str,
2699    want_type: &str,
2700    limit: usize,
2701    glob_mode: bool,
2702) -> anyhow::Result<Vec<String>> {
2703    // M3 test seam: which thread executed the walk. Async tokio workers and
2704    // the blocking pool are different threads, so comparing this with the
2705    // thread a request body runs on proves the walk was moved OFF the worker
2706    // without depending on wall-clock timing (a small tree walks in
2707    // microseconds — the first version of this test used a ticker and passed
2708    // even with the walk inline, i.e. it was not discriminating).
2709    if let Ok(mut g) = WALK_THREAD.lock() {
2710        *g = Some(std::thread::current().id());
2711    }
2712    let mut out: Vec<String> = Vec::new();
2713    let mut stack = vec![(base.to_path_buf(), String::new())];
2714    let mut visited = 0usize;
2715    while let Some((dir, rel)) = stack.pop() {
2716        visited += 1;
2717        if visited > 20_000 || out.len() >= limit * 4 {
2718            break; // bounded (AGENTS §2.3)
2719        }
2720        let Ok(rd) = std::fs::read_dir(&dir) else {
2721            continue;
2722        };
2723        for entry in rd.flatten() {
2724            let name = entry.file_name().to_string_lossy().to_string();
2725            if name == ".git" || name == "node_modules" {
2726                continue;
2727            }
2728            let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
2729            let child_rel = if rel.is_empty() {
2730                name.clone()
2731            } else {
2732                format!("{rel}/{name}")
2733            };
2734            let matched = if glob_mode {
2735                ocserve_tools::wildcard_match(&name, query)
2736            } else {
2737                child_rel.to_lowercase().contains(query)
2738            };
2739            if matched
2740                && (want_type.is_empty()
2741                    || (want_type == "dir" && is_dir)
2742                    || (want_type == "file" && !is_dir))
2743            {
2744                out.push(child_rel.clone());
2745            }
2746            if is_dir {
2747                stack.push((entry.path(), child_rel));
2748            }
2749        }
2750    }
2751    // prefix matches first, then lexicographic; capped
2752    out.sort_by(|a, b| {
2753        let pa = a.to_lowercase().starts_with(query);
2754        let pb = b.to_lowercase().starts_with(query);
2755        pb.cmp(&pa).then_with(|| a.cmp(b))
2756    });
2757    out.truncate(limit);
2758    Ok(out)
2759}
2760
2761/// GET /find?pattern= — text search → SearchMatch list. Single `grep -rnE`
2762/// spawn (bounded output, 5s); absoluteOffset is not derivable from grep
2763/// output and this route has no callers in oc-remote — recorded as 0
2764/// (documented, not silently invented).
2765async fn find_text(
2766    State(st): State<Arc<AppState>>,
2767    axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
2768) -> Result<Json<Value>, ApiError> {
2769    let base = std::path::Path::new(st.paths["directory"].as_str().unwrap_or("/"));
2770    let pattern = q.get("pattern").map(String::as_str).unwrap_or("");
2771    if pattern.is_empty() {
2772        return Ok(Json(json!([])));
2773    }
2774    let out = std::process::Command::new("grep")
2775        .args([
2776            "-rnE",
2777            "--binary-files=without-match",
2778            "-m",
2779            "50",
2780            "--",
2781            pattern,
2782        ])
2783        .arg(base)
2784        .env("LC_ALL", "C")
2785        .output();
2786    let out = match out {
2787        Ok(o) => o,
2788        Err(e) => {
2789            return Err(ApiError {
2790                status: StatusCode::INTERNAL_SERVER_ERROR,
2791                name: "InternalError",
2792                message: format!("grep spawn: {e}"),
2793            });
2794        }
2795    };
2796    // grep: 0=matches, 1=none, >1=error (e.g. bad pattern → 2 → 400)
2797    if out.status.code().unwrap_or(0) > 1 {
2798        return Err(ApiError {
2799            status: StatusCode::BAD_REQUEST,
2800            name: "BadRequest",
2801            message: "invalid pattern".into(),
2802        });
2803    }
2804    let mut matches: Vec<Value> = Vec::new();
2805    for line in String::from_utf8_lossy(&out.stdout).lines().take(200) {
2806        // path:line:content
2807        let (p1, rest) = match line.split_once(':') {
2808            Some(x) => x,
2809            None => continue,
2810        };
2811        let (ln, text) = match rest.split_once(':') {
2812            Some(x) => x,
2813            None => continue,
2814        };
2815        let ln: i64 = ln.parse().unwrap_or(0);
2816        let rel = std::path::Path::new(p1)
2817            .strip_prefix(base)
2818            .map(|r| r.to_string_lossy().to_string())
2819            .unwrap_or_else(|_| p1.to_string());
2820        matches.push(json!({
2821            "path": rel,
2822            "lines": text,
2823            "lineNumber": ln,
2824            "absoluteOffset": 0,
2825        }));
2826    }
2827    Ok(Json(Value::Array(matches)))
2828}
2829
2830/// GET /question — all pending question requests (v1 Question.Request list;
2831/// oc-remote ConnectionService polls this while a dialog may be open).
2832async fn get_questions(State(st): State<Arc<AppState>>) -> Json<Value> {
2833    Json(Value::Array(st.question_gate.list()))
2834}
2835
2836/// POST /question/{id}/reply — {answers: [[labels]]} (v1 Reply payload) → true.
2837async fn post_question_reply(
2838    State(st): State<Arc<AppState>>,
2839    axum::extract::Path(id): axum::extract::Path<String>,
2840    Json(body): Json<Value>,
2841) -> Result<Json<Value>, ApiError> {
2842    let raw = body
2843        .get("answers")
2844        .and_then(|a| a.as_array())
2845        .ok_or_else(|| ApiError {
2846            status: StatusCode::BAD_REQUEST,
2847            name: "BadRequest",
2848            message: "body must be {answers: [[String]]}".into(),
2849        })?;
2850    let mut answers: Vec<Vec<String>> = Vec::with_capacity(raw.len());
2851    for a in raw {
2852        let row = a.as_array().ok_or_else(|| ApiError {
2853            status: StatusCode::BAD_REQUEST,
2854            name: "BadRequest",
2855            message: "each answer must be an array of labels".into(),
2856        })?;
2857        answers.push(
2858            row.iter()
2859                .map(|l| l.as_str().unwrap_or_default().to_string())
2860                .collect(),
2861        );
2862    }
2863    if st.question_gate.reply(&id, answers) {
2864        Ok(Json(json!(true)))
2865    } else {
2866        Err(ApiError::not_found(format!("Question not found: {id}")))
2867    }
2868}
2869
2870/// POST /question/{id}/reject — no body (oc-remote posts empty) → true.
2871async fn post_question_reject(
2872    State(st): State<Arc<AppState>>,
2873    axum::extract::Path(id): axum::extract::Path<String>,
2874) -> Result<Json<Value>, ApiError> {
2875    if st.question_gate.reject(&id) {
2876        Ok(Json(json!(true)))
2877    } else {
2878        Err(ApiError::not_found(format!("Question not found: {id}")))
2879    }
2880}
2881
2882// ---- oc-remote contract family (Batch 4: command/shell) ----
2883
2884/// v1 argsRegex tokenizer: quoted strings or non-space runs, then
2885/// quoteTrimRegex strips one leading/trailing quote per token.
2886pub fn split_command_args(arguments: &str) -> Vec<String> {
2887    let bytes = arguments.as_bytes();
2888    let mut out = Vec::new();
2889    let mut i = 0;
2890    while i < bytes.len() {
2891        let c = bytes[i];
2892        if c.is_ascii_whitespace() {
2893            i += 1;
2894            continue;
2895        }
2896        let (tok, next) = if c == b'"' || c == b'\'' {
2897            let quote = c;
2898            let mut j = i + 1;
2899            while j < bytes.len() && bytes[j] != quote {
2900                j += 1;
2901            }
2902            let end = (j + 1).min(bytes.len());
2903            (
2904                std::str::from_utf8(&bytes[i..end])
2905                    .unwrap_or("")
2906                    .to_string(),
2907                end,
2908            )
2909        } else {
2910            let mut j = i;
2911            while j < bytes.len() && !bytes[j].is_ascii_whitespace() {
2912                j += 1;
2913            }
2914            (
2915                std::str::from_utf8(&bytes[i..j]).unwrap_or("").to_string(),
2916                j,
2917            )
2918        };
2919        // quoteTrim: strip one leading then one trailing quote char
2920        let mut t = tok.as_str();
2921        if t.starts_with(['"', '\'']) {
2922            t = &t[1..];
2923        }
2924        if t.ends_with(['"', '\'']) && !t.is_empty() {
2925            t = &t[..t.len() - 1];
2926        }
2927        out.push(t.to_string());
2928        i = next;
2929    }
2930    out
2931}
2932
2933/// v1 command template expansion (prompt.ts command()): $N placeholders (last
2934/// position swallows the remaining args), then $ARGUMENTS, then append rule.
2935/// `!`…`` shell snippets (ConfigMarkdown.shell) are NOT evaluated — neither
2936/// built-in template uses them (documented gap for exotic config commands).
2937pub fn expand_command(template: &str, arguments: &str) -> String {
2938    let args = split_command_args(arguments);
2939    // max placeholder position
2940    let mut last = 0usize;
2941    let b = template.as_bytes();
2942    let mut i = 0;
2943    while i + 1 < b.len() {
2944        if b[i] == b'$' && b[i + 1].is_ascii_digit() {
2945            let mut j = i + 1;
2946            while j < b.len() && b[j].is_ascii_digit() {
2947                j += 1;
2948            }
2949            if let Some(n) = std::str::from_utf8(&b[i + 1..j])
2950                .ok()
2951                .and_then(|d| d.parse::<usize>().ok())
2952            {
2953                last = last.max(n);
2954            }
2955            i = j;
2956        } else {
2957            i += 1;
2958        }
2959    }
2960    // replace $N
2961    let mut with_args = String::new();
2962    let mut i = 0;
2963    let mut had_placeholder = false;
2964    while i < b.len() {
2965        if b[i] == b'$' && i + 1 < b.len() && b[i + 1].is_ascii_digit() {
2966            let mut j = i + 1;
2967            while j < b.len() && b[j].is_ascii_digit() {
2968                j += 1;
2969            }
2970            let n: usize = std::str::from_utf8(&b[i + 1..j])
2971                .unwrap_or("0")
2972                .parse()
2973                .unwrap_or(0);
2974            had_placeholder = true;
2975            let arg_index = n.saturating_sub(1);
2976            let value = if arg_index >= args.len() {
2977                String::new()
2978            } else if n == last {
2979                args[arg_index..].join(" ")
2980            } else {
2981                args[arg_index].clone()
2982            };
2983            with_args.push_str(&value);
2984            i = j;
2985        } else {
2986            // copy one utf-8 char
2987            let ch_len = {
2988                let ch = template[i..].chars().next().unwrap_or(' ');
2989                ch.len_utf8()
2990            };
2991            with_args.push_str(&template[i..i + ch_len]);
2992            i += ch_len;
2993        }
2994    }
2995    let uses_arguments = template.contains("$ARGUMENTS");
2996    let mut expanded = with_args.replace("$ARGUMENTS", arguments);
2997    if !had_placeholder && !uses_arguments && !arguments.trim().is_empty() {
2998        expanded = format!("{expanded}\n\n{arguments}");
2999    }
3000    expanded.trim().to_string()
3001}
3002
3003/// v1 Session.Event.Error on unknown commands (oc-remote toast via
3004/// session.error SSE) + durable+s twin.
3005fn emit_session_error_event(st: &Arc<AppState>, sid: &str, message: &str) {
3006    let props = json!({
3007        "sessionID": sid,
3008        "error": {"name": "UnknownError", "data": {"message": message}},
3009    });
3010    if let Err(e) = ocserve_store::append_event(&st.writer, Some(sid), "session.error", &props) {
3011        tracing::warn!("session.error persist failed: {e:#}");
3012    }
3013    let seq = ocserve_store::next_event_seq(&st.db, sid).unwrap_or(0);
3014    let dir = st.paths["directory"].as_str().unwrap_or("/");
3015    st.bus.publish(ocserve_core::event::frame(
3016        dir,
3017        "session.error",
3018        props.clone(),
3019    ));
3020    st.bus.publish(ocserve_core::event::sync_frame(
3021        dir,
3022        "session.error",
3023        props,
3024        seq as u64,
3025        sid,
3026    ));
3027}
3028
3029/// Busy rejection (v1 mapBusy → SessionBusyError): command/shell never queue
3030/// — they fail fast when the session is mid-prompt (upstream semantics).
3031fn session_busy(id: &str) -> ApiError {
3032    ApiError {
3033        status: StatusCode::CONFLICT,
3034        name: "SessionBusyError",
3035        message: format!("Session is busy: {id}"),
3036    }
3037}
3038
3039/// POST /session/{id}/command — v1 promptSvc.command: look up the command
3040/// (built-ins init/review + config), expand the template, run the prompt
3041/// synchronously, return WithParts. Unknown → session.error SSE + 400.
3042async fn post_command(
3043    State(st): State<Arc<AppState>>,
3044    axum::extract::Path(id): axum::extract::Path<String>,
3045    Json(payload): Json<Value>,
3046) -> Result<Json<Value>, ApiError> {
3047    let exists_id = id.clone();
3048    if !run_blocking(&st.db, move |db| {
3049        ocserve_store::session_exists(db, &exists_id)
3050    })
3051    .await
3052    .map_err(|e| ApiError {
3053        status: StatusCode::INTERNAL_SERVER_ERROR,
3054        name: "InternalError",
3055        message: format!("{e:#}"),
3056    })? {
3057        return Err(ApiError::not_found(format!("Session not found: {id}")));
3058    }
3059    let name = payload
3060        .get("command")
3061        .and_then(|c| c.as_str())
3062        .unwrap_or("");
3063    let arguments = payload
3064        .get("arguments")
3065        .and_then(|a| a.as_str())
3066        .unwrap_or("");
3067    // scoped: parking_lot guards are !Send — never held across run_prompt's
3068    // .await (Handler would stop accepting the future)
3069    let (template, available_owned): (Option<String>, Vec<String>) = {
3070        let payloads = st.payloads.read();
3071        let t = payloads
3072            .command
3073            .iter()
3074            .find(|c| c["name"].as_str() == Some(name))
3075            .and_then(|c| c["template"].as_str())
3076            .map(str::to_string);
3077        let names = payloads
3078            .command
3079            .iter()
3080            .filter_map(|c| c["name"].as_str().map(str::to_string))
3081            .collect();
3082        (t, names)
3083    };
3084    let available: Vec<&str> = available_owned.iter().map(String::as_str).collect();
3085    let Some(template) = template.as_deref() else {
3086        let msg = format!(
3087            "Command not found: \"{name}\".{}",
3088            if available.is_empty() {
3089                String::new()
3090            } else {
3091                format!(" Available commands: {}", available.join(", "))
3092            }
3093        );
3094        emit_session_error_event(&st, &id, &msg);
3095        return Err(ApiError {
3096            status: StatusCode::BAD_REQUEST,
3097            name: "BadRequest",
3098            message: msg,
3099        });
3100    };
3101    let expanded = expand_command(template, arguments);
3102    let mut cmd_payload = json!({
3103        "parts": [{"type": "text", "text": expanded}],
3104    });
3105    if let Some(a) = payload.get("agent").and_then(|a| a.as_str()) {
3106        cmd_payload["agent"] = json!(a);
3107    }
3108    if let Some(m) = payload.get("model") {
3109        cmd_payload["model"] = m.clone();
3110    }
3111    if let Some(mid) = payload.get("messageID").and_then(|m| m.as_str()) {
3112        cmd_payload["messageId"] = json!(mid);
3113    }
3114    // v1 parity: command.execute.before (prompt.ts:1461) — input
3115    // {command, sessionID, arguments}, output {parts} mutated before the
3116    // prompt runs. Fail-open (no sidecar/hook error → parts unchanged).
3117    let parts_out = ocserve_core::prompt::hook_mutate(
3118        st.plugins.get(),
3119        "command.execute.before",
3120        json!({
3121            "command": name,
3122            "sessionID": id,
3123            "arguments": arguments,
3124        }),
3125        json!({"parts": cmd_payload["parts"].clone()}),
3126    )
3127    .await;
3128    if let Some(p) = parts_out.get("parts").and_then(|p| p.as_array()) {
3129        cmd_payload["parts"] = Value::Array(p.clone());
3130    }
3131
3132    let ctx = build_prompt_context(&st, &cmd_payload, &id)?;
3133    let _release = lock_session(&st, &id).await?;
3134    let Ok(_guard) = _release.arc().try_lock() else {
3135        return Err(session_busy(&id));
3136    };
3137    let writer = st.writer.clone();
3138    let (info, parts) = ocserve_core::prompt::run_prompt_with(
3139        &ctx,
3140        &writer,
3141        &id,
3142        &cmd_payload,
3143        ocserve_core::prompt::RunOpts {
3144            // K-TITLE: a command must never name the session after itself
3145            auto_title: false,
3146            ..Default::default()
3147        },
3148    )
3149    .await
3150    .map_err(prompt_err)?;
3151    drop(_guard);
3152    Ok(Json(json!({"info": info, "parts": parts})))
3153}
3154
3155/// POST /session/{id}/shell — v1 promptSvc.shell: NO model. Runs the command
3156/// directly (bash), records synthetic-user + assistant/bash tool messages,
3157/// returns WithParts. Port note: shell.env plugin hook skipped (no configured
3158/// plugin subscribes); abort-of-running-shell not wired (documented).
3159async fn post_shell(
3160    State(st): State<Arc<AppState>>,
3161    axum::extract::Path(id): axum::extract::Path<String>,
3162    Json(payload): Json<Value>,
3163) -> Result<Json<Value>, ApiError> {
3164    let exists_id = id.clone();
3165    if !run_blocking(&st.db, move |db| {
3166        ocserve_store::session_exists(db, &exists_id)
3167    })
3168    .await
3169    .map_err(|e| ApiError {
3170        status: StatusCode::INTERNAL_SERVER_ERROR,
3171        name: "InternalError",
3172        message: format!("{e:#}"),
3173    })? {
3174        return Err(ApiError::not_found(format!("Session not found: {id}")));
3175    }
3176    let command = payload
3177        .get("command")
3178        .and_then(|c| c.as_str())
3179        .ok_or_else(|| ApiError {
3180            status: StatusCode::BAD_REQUEST,
3181            name: "BadRequest",
3182            message: "command required".into(),
3183        })?;
3184    let agent = payload
3185        .get("agent")
3186        .and_then(|a| a.as_str())
3187        .ok_or_else(|| ApiError {
3188            status: StatusCode::BAD_REQUEST,
3189            name: "BadRequest",
3190            message: "agent required".into(),
3191        })?;
3192    let (pid, mid) = resolve_model(&st, &payload, &id);
3193
3194    let _release = lock_session(&st, &id).await?;
3195    let Ok(_guard) = _release.arc().try_lock() else {
3196        return Err(session_busy(&id));
3197    };
3198    let dir = st.paths["directory"].as_str().unwrap_or("/").to_string();
3199
3200    // busy → run → idle (client spinner parity)
3201    let status_frame = |kind: &str| {
3202        ocserve_core::event::frame(
3203            &dir,
3204            "session.status",
3205            json!({"sessionID": id, "status": {"type": kind}}),
3206        )
3207    };
3208    st.bus.publish(status_frame("busy"));
3209
3210    let started = now_ms_local();
3211    let user_msg_id = ocserve_core::ids::msg_id();
3212    let user_part = json!({
3213        "type": "text", "id": ocserve_core::ids::prt_id(),
3214        "sessionID": id, "messageID": user_msg_id,
3215        "text": "The following tool was executed by the user",
3216        "synthetic": true,
3217    });
3218    let user_info = json!({
3219        "id": user_msg_id,
3220        "sessionID": id,
3221        "role": "user",
3222        "agent": agent,
3223        "model": {"providerID": pid, "modelID": mid},
3224        "time": {"created": started},
3225        "summary": {"diffs": []},
3226    });
3227    let assistant_id = ocserve_core::ids::msg_id();
3228    let call_id = ocserve_core::ids::evt_id();
3229    let mut tool_part = json!({
3230        "type": "tool", "id": ocserve_core::ids::prt_id(),
3231        "sessionID": id, "messageID": assistant_id,
3232        "callID": call_id, "tool": "bash",
3233        "state": {
3234            "status": "running",
3235            "input": {"command": command},
3236            "time": {"start": started},
3237        },
3238    });
3239
3240    // persist user msg + assistant msg, emit running state (upstream order)
3241    let writer = st.writer.clone();
3242    insert_message_http(
3243        &writer,
3244        &st,
3245        &id,
3246        &user_info,
3247        std::slice::from_ref(&user_part),
3248    )?;
3249    let assistant_info = json!({
3250        "id": assistant_id,
3251        "parentID": user_msg_id,
3252        "role": "assistant",
3253        "mode": agent,
3254        "agent": agent,
3255        "path": {"cwd": dir, "root": "/"},
3256        "cost": 0.0,
3257        "tokens": {"total": 0, "input": 0, "output": 0, "reasoning": 0,
3258                   "cache": {"write": 0, "read": 0}},
3259        "modelID": mid,
3260        "providerID": pid,
3261        "time": {"created": started, "completed": started},
3262        "finish": "stop",
3263        "id": assistant_id,
3264        "sessionID": id,
3265    });
3266    insert_message_http(&writer, &st, &id, &assistant_info, &[])?;
3267    emit_durable_http(
3268        &st,
3269        &id,
3270        "message.updated",
3271        json!({"sessionID": id, "info": user_info.clone()}),
3272    )?;
3273    emit_durable_http(
3274        &st,
3275        &id,
3276        "message.updated",
3277        json!({"sessionID": id, "info": assistant_info.clone()}),
3278    )?;
3279    emit_durable_http(
3280        &st,
3281        &id,
3282        "message.part.updated",
3283        json!({"sessionID": id, "part": user_part}),
3284    )?;
3285    emit_durable_http(
3286        &st,
3287        &id,
3288        "message.part.updated",
3289        json!({"sessionID": id, "part": tool_part.clone()}),
3290    )?;
3291
3292    // execute directly (bash executor: stderr merged, output bounded,
3293    // default 120s cap — ShellInput carries no timeout). Off-worker: a long
3294    // shell command must not pin a tokio worker (K-EFFICIENCY).
3295    let exec = {
3296        let cmd = json!({"command": command});
3297        let d = dir.clone();
3298        tokio::task::spawn_blocking(move || {
3299            ocserve_tools::execute("bash", &cmd, std::path::Path::new(&d))
3300        })
3301        .await
3302        .map_err(|e| ApiError {
3303            status: StatusCode::INTERNAL_SERVER_ERROR,
3304            name: "InternalError",
3305            message: format!("tool join: {e}"),
3306        })?
3307    };
3308    let (status, output) = match exec {
3309        Ok(r) => ("completed", r.output),
3310        Err(e) => ("error", format!("Error: {e:#}")),
3311    };
3312    let completed = now_ms_local();
3313    // v1 finish() shape: completed always carries {title:"", output,
3314    // metadata:{output}} — no exit field (capture-verified)
3315    tool_part["state"] = json!({
3316        "status": status,
3317        "input": {"command": command},
3318        "output": output,
3319        "title": "",
3320        "metadata": {"output": ""},
3321        "time": {"start": started, "end": completed},
3322    });
3323    tool_part["state"]["metadata"]["output"] = tool_part["state"]["output"].clone();
3324    insert_message_http(
3325        &writer,
3326        &st,
3327        &id,
3328        &assistant_info,
3329        std::slice::from_ref(&tool_part),
3330    )?;
3331    emit_durable_http(
3332        &st,
3333        &id,
3334        "message.part.updated",
3335        json!({"sessionID": id, "part": tool_part.clone()}),
3336    )?;
3337    st.bus.publish(status_frame("idle"));
3338
3339    Ok(Json(json!({"info": assistant_info, "parts": [tool_part]})))
3340}
3341
3342fn now_ms_local() -> i64 {
3343    std::time::SystemTime::now()
3344        .duration_since(std::time::UNIX_EPOCH)
3345        .map(|d| d.as_millis() as i64)
3346        .unwrap_or(0)
3347}
3348
3349/// Persist a message via the store + emit its updated/part durable events.
3350fn insert_message_http(
3351    writer: &ocserve_store::Writer,
3352    st: &Arc<AppState>,
3353    sid: &str,
3354    info: &Value,
3355    parts: &[Value],
3356) -> Result<(), ApiError> {
3357    ocserve_store::insert_message(writer, Some(&*st.blobs), sid, info, parts).map_err(|e| {
3358        ApiError {
3359            status: StatusCode::INTERNAL_SERVER_ERROR,
3360            name: "InternalError",
3361            message: format!("{e:#}"),
3362        }
3363    })
3364}
3365
3366/// Durable event from HTTP handlers (no PromptContext): persist + plain +
3367/// sync twin (seq from next_event_seq — single-flight per handler call).
3368fn emit_durable_http(
3369    st: &Arc<AppState>,
3370    sid: &str,
3371    event_type: &str,
3372    props: Value,
3373) -> Result<(), ApiError> {
3374    ocserve_store::append_event(&st.writer, Some(sid), event_type, &props).map_err(|e| {
3375        ApiError {
3376            status: StatusCode::INTERNAL_SERVER_ERROR,
3377            name: "InternalError",
3378            message: format!("{e:#}"),
3379        }
3380    })?;
3381    let seq = ocserve_store::next_event_seq(&st.db, sid).unwrap_or(0);
3382    let dir = st.paths["directory"].as_str().unwrap_or("/");
3383    st.bus
3384        .publish(ocserve_core::event::frame(dir, event_type, props.clone()));
3385    st.bus.publish(ocserve_core::event::sync_frame(
3386        dir, event_type, props, seq as u64, sid,
3387    ));
3388    Ok(())
3389}
3390
3391/// Freeze-shaped event id: `evt_` + 26 hex chars (upstream ids are time+random
3392/// 26-char alphanumerics; ours hash a nanos counter — clients only require
3393/// uniqueness, and byte-golden normalizes this span).
3394fn evt_id(kind: &str) -> String {
3395    use sha2::{Digest, Sha256};
3396    let nanos = std::time::SystemTime::now()
3397        .duration_since(std::time::UNIX_EPOCH)
3398        .map(|d| d.as_nanos())
3399        .unwrap_or(0);
3400    let mut h = Sha256::new();
3401    h.update(nanos.to_le_bytes());
3402    h.update(kind.as_bytes());
3403    let hex = hex::encode(h.finalize());
3404    format!("evt_{}", &hex[..26])
3405}
3406
3407/// SSE endpoint. Freeze facts (byte-golden capture, PLAN §3):
3408/// - response headers: Cache-Control: no-cache, no-transform; X-Content-Type-Options: nosniff;
3409///   x-accel-buffering: no; Vary: Origin; chunked transfer
3410/// - NO `id:` lines, NO `retry:` lines
3411/// - first frame: server.connected WITHOUT directory/project wrapper
3412/// - then 10s JSON heartbeats
3413/// - frames wrapped: {"directory":..,"project":..,"payload":..} (except server.connected)
3414async fn global_event(State(st): State<Arc<AppState>>) -> Response {
3415    let connected = Event::default().data(
3416        json!({"payload":{"id":evt_id("connected"),"type":"server.connected","properties":{}}})
3417            .to_string(),
3418    );
3419    let rx = st.bus.subscribe();
3420    ocserve_metrics::gauge(
3421        "ocserve_sse_clients",
3422        st.bus.subscriber_count().max(1) as i64,
3423    );
3424    // The stream must own a bus clone: if the Router/state were dropped while
3425    // a subscriber still streams (oneshot tests), the Sender must survive.
3426    let bus_keepalive = st.bus.clone();
3427    // Drop-guard: decrement the client gauge when the stream ends (any exit).
3428    struct SseClientGuard;
3429    impl Drop for SseClientGuard {
3430        fn drop(&mut self) {
3431            ocserve_metrics::gauge_delta("ocserve_sse_clients", -1);
3432        }
3433    }
3434    let sse_guard = SseClientGuard;
3435    // Timeout-driven merge: heartbeat at exact 10s cadence regardless of
3436    // traffic; bus frames yielded as they arrive; Lagged/Closed ends the
3437    // stream (bounded-queue disconnect, PLAN §4).
3438    let events = stream::once(async move { Ok::<_, std::convert::Infallible>(connected) }).chain(
3439        stream::unfold(
3440            (
3441                rx,
3442                tokio::time::Instant::now() + Duration::from_secs(10),
3443                bus_keepalive,
3444                sse_guard,
3445            ),
3446            |(mut rx, mut next_hb, bus, guard)| async move {
3447                {
3448                    let dur = next_hb.saturating_duration_since(tokio::time::Instant::now());
3449                    match tokio::time::timeout(dur.max(Duration::from_millis(1)), rx.recv()).await {
3450                        Ok(Ok(frame)) => {
3451                            ocserve_metrics::counter("ocserve_sse_events_total", 1);
3452                            Some((Ok(Event::default().data(&frame)), (rx, next_hb, bus, guard)))
3453                        }
3454                        Ok(Err(_lagged_or_closed)) => {
3455                            ocserve_metrics::labeled_counter(
3456                                "ocserve_event_ring_lag_total",
3457                                "reason=\"lagged_or_closed\"",
3458                                1,
3459                            );
3460                            None
3461                        }
3462                        Err(_elapsed) => {
3463                            let hb = Event::default().data(
3464                                json!({"payload":{"id":evt_id("heartbeat"),
3465                                    "type":"server.heartbeat","properties":{}}})
3466                                .to_string(),
3467                            );
3468                            next_hb += Duration::from_secs(10);
3469                            Some((Ok(hb), (rx, next_hb, bus, guard)))
3470                        }
3471                    }
3472                }
3473            },
3474        ),
3475    );
3476    // No axum KeepAlive: the freeze stream carries zero comment/retry frames
3477    // (PLAN §3 byte-golden); the 10s heartbeat payload is the only activity.
3478    let mut resp = Sse::new(events).into_response();
3479    // Freeze header set — axum's defaults differ (no-transform / nosniff / accel).
3480    let h = resp.headers_mut();
3481    for (k, v) in sse_expected_headers() {
3482        if let (Ok(name), Ok(val)) = (
3483            axum::http::HeaderName::try_from(k),
3484            HeaderValue::from_str(v),
3485        ) {
3486            h.insert(name, val);
3487        }
3488    }
3489    resp
3490}
3491
3492/// Basic auth middleware (N6/PLAN §3: auth mode is a freeze artifact — live
3493/// instance is passwordless, so default is off; `basic` mode 401s everything
3494/// without matching credentials, including SSE and /metrics).
3495async fn auth_gate(
3496    State(st): State<Arc<AppState>>,
3497    req: axum::extract::Request,
3498    next: axum::middleware::Next,
3499) -> Result<Response, ApiError> {
3500    st.requests
3501        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3502    // SRE §2: request metrics (bounded route labels; measured at both exits)
3503    ocserve_metrics::counter("ocserve_requests_total", 1);
3504    let metric_t0 = std::time::Instant::now();
3505    let metric_label = format!(
3506        "route=\"{}\",method=\"{}\"",
3507        ocserve_metrics::route_label(req.uri().path()),
3508        req.method()
3509    );
3510    let record = move |t0: std::time::Instant| {
3511        ocserve_metrics::observe(
3512            "ocserve_http_request_duration_seconds",
3513            &metric_label,
3514            t0.elapsed().as_micros() as u64,
3515        );
3516    };
3517    // Hit-set capture (M1 §PLAN C4): every request path logged for the
3518    // differential route inventory.
3519    tracing::info!("req {} {}", req.method(), req.uri().path());
3520    if let Some((user, pass)) = &st.auth {
3521        let authorized = req
3522            .headers()
3523            .get(axum::http::header::AUTHORIZATION)
3524            .and_then(|v| v.to_str().ok())
3525            .and_then(|v| v.strip_prefix("Basic "))
3526            .and_then(|b64| {
3527                use base64::Engine;
3528                base64::engine::general_purpose::STANDARD.decode(b64).ok()
3529            })
3530            .map(|raw| {
3531                let text = String::from_utf8_lossy(&raw);
3532                text == format!("{user}:{pass}")
3533            })
3534            .unwrap_or(false);
3535        if !authorized {
3536            record(metric_t0);
3537            return Err(ApiError {
3538                status: StatusCode::UNAUTHORIZED,
3539                name: "UnauthorizedError",
3540                message: "unauthorized".into(),
3541            });
3542        }
3543    }
3544    let resp = next.run(req).await;
3545    record(metric_t0);
3546    Ok(resp)
3547}
3548
3549/// Router for the P0 surface. Auth is an AppState decision (freeze §3).
3550pub fn router(state: Arc<AppState>) -> Router {
3551    Router::new()
3552        .route("/global/health", get(health))
3553        .route("/config", get(get_config))
3554        .route("/config/providers", get(get_config_providers))
3555        .route("/provider", get(get_provider))
3556        .route("/agent", get(get_agent))
3557        .route("/command", get(get_command))
3558        .route("/experimental/console", get(get_console))
3559        .route("/experimental/tool", get(get_experimental_tool))
3560        .route("/experimental/tool/ids", get(get_experimental_tool_ids))
3561        .route("/experimental/capabilities", get(get_capabilities))
3562        .route("/path", get(get_path))
3563        .route("/project", get(get_projects))
3564        .route("/project/current", get(get_project_current))
3565        .route("/session", get(get_sessions).post(post_session))
3566        .route("/session/{id}/fork", axum::routing::post(post_fork))
3567        .route("/session/status", get(session_status))
3568        .route("/session/search", axum::routing::post(search_messages))
3569        .route("/mcp/{name}/{action}", axum::routing::post(mcp_action))
3570        .route("/mcp/{name}/auth", axum::routing::delete(mcp_auth_remove))
3571        .route(
3572            "/auth/{providerID}",
3573            axum::routing::put(auth_put).delete(auth_delete),
3574        )
3575        .route("/provider/auth", axum::routing::get(provider_auth_methods))
3576        .route("/global/dispose", axum::routing::post(global_dispose))
3577        .route("/config", axum::routing::patch(patch_config))
3578        .route("/global/config", axum::routing::patch(patch_config))
3579        .route(
3580            "/session/{id}/summarize",
3581            axum::routing::post(post_summarize),
3582        )
3583        .route(
3584            "/session/{id}",
3585            get(get_session)
3586                .patch(patch_session)
3587                .delete(delete_session_route),
3588        )
3589        .route(
3590            "/session/{id}/message",
3591            get(get_messages).post(post_message),
3592        )
3593        .route("/global/event", get(global_event))
3594        // upstream serves /event, /global/event and /api/event from ONE handler
3595        // (public.ts:155) — same handler here; oc-remote uses /global/event, TUI/SDK
3596        // probes hit /event (was a tolerated 404; now freeze-faithful)
3597        .route("/event", get(global_event))
3598        .route("/metrics", get(metrics))
3599        // TUI-attach probes (captured live; PLAN §2 hit-set expansion)
3600        .route("/api/location", get(get_api_location))
3601        .route("/api/agent", get(get_api_agent))
3602        .route("/api/command", get(get_api_command))
3603        .route("/api/reference", get(get_api_reference))
3604        .route("/experimental/resource", get(experimental_resource))
3605        .route("/experimental/workspace", get(experimental_workspace))
3606        // TUI attach calls this at boot (observed live 2026-10-08); freeze
3607        // answers [] with 200 under an empty workspace state.
3608        .route(
3609            "/experimental/workspace/status",
3610            get(experimental_workspace_status),
3611        )
3612        .route("/formatter", get(formatter_list))
3613        .route("/lsp", get(lsp_list))
3614        .route("/project/{id}/directories", get(project_directories))
3615        .route("/vcs", get(vcs_info))
3616        .route(
3617            "/session/{id}/prompt_async",
3618            axum::routing::post(post_prompt_async),
3619        )
3620        .route("/session/{id}/children", get(get_children))
3621        .route("/file", get(list_directory))
3622        .route("/file/content", get(read_file_content))
3623        .route("/find/file", get(find_files))
3624        .route("/find", get(find_text))
3625        .route("/question", get(get_questions))
3626        .route(
3627            "/question/{id}/reply",
3628            axum::routing::post(post_question_reply),
3629        )
3630        .route(
3631            "/question/{id}/reject",
3632            axum::routing::post(post_question_reject),
3633        )
3634        .route("/session/{id}/todo", get(get_todos))
3635        .route("/session/{id}/abort", axum::routing::post(post_abort))
3636        .route("/session/{id}/command", axum::routing::post(post_command))
3637        .route("/session/{id}/shell", axum::routing::post(post_shell))
3638        .route(
3639            "/session/{id}/message/{mid}",
3640            axum::routing::delete(delete_message_route),
3641        )
3642        .route(
3643            "/session/{id}/message/{mid}/part/{pid}",
3644            axum::routing::delete(delete_part_route).patch(patch_part_route),
3645        )
3646        .route("/experimental/session", get(get_experimental_sessions))
3647        // /tui/* — external-controller ingress (PLAN §17; VS Code extension)
3648        .route(
3649            "/tui/append-prompt",
3650            axum::routing::post(tui::append_prompt),
3651        )
3652        .route("/tui/open-help", axum::routing::post(tui::open_help))
3653        .route(
3654            "/tui/open-sessions",
3655            axum::routing::post(tui::open_sessions),
3656        )
3657        .route("/tui/open-themes", axum::routing::post(tui::open_themes))
3658        .route("/tui/open-models", axum::routing::post(tui::open_models))
3659        .route(
3660            "/tui/submit-prompt",
3661            axum::routing::post(tui::submit_prompt),
3662        )
3663        .route("/tui/clear-prompt", axum::routing::post(tui::clear_prompt))
3664        .route(
3665            "/tui/execute-command",
3666            axum::routing::post(tui::execute_command),
3667        )
3668        .route("/tui/show-toast", axum::routing::post(tui::show_toast))
3669        .route("/tui/publish", axum::routing::post(tui::publish_event))
3670        .route(
3671            "/tui/select-session",
3672            axum::routing::post(tui::select_session),
3673        )
3674        .route("/tui/control/next", get(tui::control_next))
3675        .route(
3676            "/tui/control/response",
3677            axum::routing::post(tui::control_response),
3678        )
3679        .route("/mcp", get(get_mcp))
3680        .route("/permission", get(get_permissions))
3681        .route(
3682            "/permission/{id}/reply",
3683            axum::routing::post(post_permission_reply),
3684        )
3685        .layer(axum::middleware::from_fn_with_state(
3686            state.clone(),
3687            auth_gate,
3688        ))
3689        // CORS (upstream cors.ts parity) sits OUTSIDE auth: preflights must
3690        // answer without credentials, exactly like upstream.
3691        .layer(axum::middleware::from_fn_with_state(
3692            state.clone(),
3693            crate::ui::cors_gate,
3694        ))
3695        // Catch-all: unmatched paths serve the web UI (embedded upstream-side
3696        // or proxied to app.opencode.ai), matching v1 serveUIEffect. Disabled
3697        // via OCSERVE_UI=0 → JSON 404.
3698        .fallback(crate::ui::ui_fallback)
3699        .with_state(state)
3700}
3701
3702/// CORS extras: OCSERVE_CORS (comma-separated) — the `--cors` equivalent.
3703/// Config-file `server.cors` support arrives with the config passthrough;
3704/// env is the runtime-tunable equivalent until then.
3705fn read_cors_env() -> Vec<String> {
3706    std::env::var("OCSERVE_CORS")
3707        .ok()
3708        .map(|v| {
3709            v.split(',')
3710                .map(str::trim)
3711                .filter(|s| !s.is_empty())
3712                .map(str::to_string)
3713                .collect()
3714        })
3715        .unwrap_or_default()
3716}
3717
3718/// JSON key-path projection: `$`, `$.a`, `$.a[].b`, … (values ignored).
3719/// Shared oracle for keys-mode golden comparisons (manifest mode=keys).
3720pub fn keypaths(v: &serde_json::Value, prefix: &str, out: &mut std::collections::BTreeSet<String>) {
3721    match v {
3722        serde_json::Value::Object(m) => {
3723            for (k, val) in m {
3724                let p = format!("{prefix}.{k}");
3725                out.insert(p.clone());
3726                keypaths(val, &p, out);
3727            }
3728        }
3729        serde_json::Value::Array(a) => {
3730            let p = format!("{prefix}[]");
3731            if let Some(first) = a.first() {
3732                keypaths(first, &p, out);
3733            } else {
3734                out.insert(p);
3735            }
3736        }
3737        _ => {}
3738    }
3739}
3740
3741/// Header values the SSE route must carry (asserted by tests against golden bytes).
3742pub fn sse_expected_headers() -> Vec<(&'static str, &'static str)> {
3743    vec![
3744        ("cache-control", "no-cache, no-transform"),
3745        ("x-content-type-options", "nosniff"),
3746        ("content-type", "text/event-stream"),
3747        ("x-accel-buffering", "no"),
3748    ]
3749}
3750
3751#[allow(dead_code)]
3752fn _assert_header_value(v: &str) -> HeaderValue {
3753    HeaderValue::from_str(v).expect("static header value")
3754}
3755
3756#[cfg(test)]
3757mod limit_matrix {
3758    use super::parse_limit;
3759
3760    #[test]
3761    fn matches_probe_envelopes() {
3762        // Byte strings from message_page_contract.json probes (upstream 1.18.31)
3763        assert_eq!(
3764            parse_limit("abc").unwrap_err(),
3765            "Expected an integer, got NaN\n  at [\"limit\"]"
3766        );
3767        assert_eq!(
3768            parse_limit("5.5").unwrap_err(),
3769            "Expected an integer, got 5.5\n  at [\"limit\"]"
3770        );
3771        assert_eq!(
3772            parse_limit("-0.5").unwrap_err(),
3773            "Expected an integer, got -0.5\n  at [\"limit\"]"
3774        );
3775        assert_eq!(
3776            parse_limit("-1").unwrap_err(),
3777            "Expected a value greater than or equal to 0, got -1\n  at [\"limit\"]"
3778        );
3779        assert_eq!(
3780            parse_limit("999999999999999999999").unwrap_err(),
3781            "Expected an integer, got 1e+21\n  at [\"limit\"]"
3782        );
3783        assert_eq!(
3784            parse_limit("Infinity").unwrap_err(),
3785            "Expected an integer, got Infinity\n  at [\"limit\"]"
3786        );
3787    }
3788
3789    #[test]
3790    fn accepts_integers_like_js_number() {
3791        assert_eq!(parse_limit("+5"), Ok(5));
3792        assert_eq!(parse_limit("1e3"), Ok(1000)); // JS Number("1e3") = 1000
3793        assert_eq!(parse_limit("0"), Ok(0));
3794        assert_eq!(parse_limit("9007199254740991"), Ok(9007199254740991)); // 2^53-1
3795    }
3796}
3797
3798#[cfg(test)]
3799mod f1_blocking_tests {
3800    use super::{AppState, Payloads, router, run_blocking};
3801    use std::sync::Arc;
3802    use std::sync::atomic::{AtomicU64, Ordering};
3803    use std::time::Duration;
3804
3805    /// PERF-10X F1: a blocking store call must NOT park the async worker.
3806    /// Single-worker runtime makes this discriminating: with the call inlined
3807    /// (the pre-F1 shape) the ticker cannot advance while the call runs
3808    /// (after == before → red); via the blocking pool it keeps ticking.
3809    /// M3: `GET /find/file`'s directory walk must execute OFF the tokio
3810    /// worker. Structural proof via thread identity, not timing: a ticker-
3811    /// based version of this test passed with the walk still inline (a few
3812    /// thousand files walk in microseconds), so it proved nothing.
3813    ///
3814    /// The probe drives the REAL route — calling `walk_files` directly would
3815    /// bypass the very `spawn_blocking` under test, which the first version
3816    /// of this test did (and it failed for exactly that reason).
3817    #[tokio::test]
3818    async fn find_files_walk_runs_off_the_worker() {
3819        use tower::ServiceExt as _;
3820        let dir = std::env::temp_dir().join(format!(
3821            "ocserve-m3-walk-{}-{}",
3822            std::process::id(),
3823            std::time::SystemTime::now()
3824                .duration_since(std::time::UNIX_EPOCH)
3825                .unwrap()
3826                .as_nanos()
3827        ));
3828        std::fs::create_dir_all(&dir).unwrap();
3829        for i in 0..200 {
3830            std::fs::write(dir.join(format!("f{i}.txt")), b"x").unwrap();
3831        }
3832
3833        let mut payloads = Payloads::default();
3834        payloads.config["directory"] = serde_json::Value::String(dir.to_string_lossy().to_string());
3835        let st = AppState::with_payloads(None, payloads);
3836
3837        let handler_thread = std::thread::current().id();
3838        let req = axum::http::Request::builder()
3839            .uri("/find/file?query=f1&limit=10")
3840            .body(axum::body::Body::empty())
3841            .unwrap();
3842        let resp = tokio::spawn(async move { router(st).oneshot(req).await })
3843            .await
3844            .expect("route task")
3845            .expect("infallible");
3846        assert_eq!(resp.status(), 200);
3847
3848        let walked_on = super::WALK_THREAD.lock().ok().and_then(|g| *g);
3849        let walked_on = walked_on.expect("walk_files must record its thread");
3850        assert_ne!(
3851            walked_on, handler_thread,
3852            "the walk ran on the async worker — blocking syscalls must go \
3853             through spawn_blocking (M3)"
3854        );
3855        let _ = std::fs::remove_dir_all(&dir);
3856    }
3857
3858    /// PERF-10X F1: a blocking store call must NOT park the async worker.
3859    /// Single-worker runtime makes this discriminating: with the call inlined
3860    /// (the pre-F1 shape) the ticker cannot advance while the call runs
3861    /// (after == before → red); via the blocking pool it keeps ticking.
3862    /// Negative control: swap run_blocking for the inline sleep → red.
3863    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3864
3865    async fn run_blocking_frees_the_worker() {
3866        let ticks = Arc::new(AtomicU64::new(0));
3867        let t = ticks.clone();
3868        let ticker = tokio::spawn(async move {
3869            let mut i = 0u64;
3870            loop {
3871                tokio::time::sleep(Duration::from_millis(10)).await;
3872                i += 1;
3873                t.store(i, Ordering::Relaxed);
3874            }
3875        });
3876        tokio::time::sleep(Duration::from_millis(30)).await;
3877        let before = ticks.load(Ordering::Relaxed);
3878        assert!(before > 0, "ticker must be running before the probe");
3879
3880        // The probe runs as a SPAWNED task: only spawned tasks execute on
3881        // worker threads (the #[tokio::test] body itself runs on block_on's
3882        // thread — an inline sleep there parked nothing and the first version
3883        // of this test passed its own negative control; caught 2026-10-06).
3884        let probe = tokio::spawn(async move {
3885            run_blocking(std::path::Path::new("/nonexistent/f1-probe"), |_db| {
3886                std::thread::sleep(Duration::from_millis(250));
3887                Ok::<(), anyhow::Error>(())
3888            })
3889            .await
3890            .expect("probe op");
3891        });
3892        probe.await.expect("probe join");
3893
3894        let after = ticks.load(Ordering::Relaxed);
3895        ticker.abort();
3896        assert!(
3897            after > before,
3898            "blocking call parked the worker: before={before} after={after}"
3899        );
3900    }
3901}
3902
3903#[cfg(test)]
3904mod f5_wire {
3905    use super::*;
3906
3907    /// Identity control: cached bytes == what axum's Json would produce
3908    /// (both serde_json::to_vec) — one value per key, no drift.
3909    #[test]
3910    fn wire_bytes_are_serde_identical() {
3911        let st = AppState::new();
3912        let p = st.payloads.read();
3913        let w = st.wire.read();
3914        for (k, v) in [
3915            ("config", &p.config),
3916            ("config_providers", &p.config_providers),
3917            ("provider", &p.provider),
3918            ("console", &p.console),
3919            ("capabilities", &p.capabilities),
3920        ] {
3921            let cached = w.get(k).unwrap_or_else(|| panic!("missing wire key {k}"));
3922            let want = serde_json::to_vec(v).unwrap();
3923            assert_eq!(
3924                &cached[..],
3925                &want[..],
3926                "wire bytes for {k} must equal Value serialization"
3927            );
3928        }
3929        for (k, v) in [("agent", &p.agent), ("command", &p.command)] {
3930            let cached = w.get(k).unwrap_or_else(|| panic!("missing wire key {k}"));
3931            let want = serde_json::to_vec(v).unwrap();
3932            assert_eq!(&cached[..], &want[..]);
3933        }
3934    }
3935
3936    /// Kill-switch: wire_off => no cache, wire_json falls back (None).
3937    #[test]
3938    fn wire_off_disables_cache() {
3939        let empty = rebuild_wire(&Payloads::default(), true);
3940        assert!(empty.is_empty(), "off => nothing serialized");
3941        let st = AppState::new();
3942        st.wire_off
3943            .store(true, std::sync::atomic::Ordering::Relaxed);
3944        assert!(wire_json(&st, "config").is_none(), "off => Value path");
3945        st.wire_off
3946            .store(false, std::sync::atomic::Ordering::Relaxed);
3947        assert!(wire_json(&st, "config").is_some(), "on => Bytes path");
3948    }
3949}